diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index ff5c78759..bb0921032 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,6 +1,7 @@ --- version: 2 updates: + - package-ecosystem: "pip" directory: "/" schedule: @@ -12,5 +13,24 @@ updates: include: "scope" labels: - dependencies + - dependabot open-pull-requests-limit: 10 rebase-strategy: auto + versioning-strategy: "increase-if-necessary" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "18:00" + commit-message: + prefix: "ci" + labels: + - dependencies + - dependabot + rebase-strategy: auto + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e59cde4d0..bdab128aa 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,17 +14,17 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }} @@ -43,9 +43,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.8 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.8 - name: Install mypy & dev packages @@ -53,7 +53,11 @@ jobs: python -m pip install ".[dev, mypy]" - name: ruff run: | - python -m ruff check . --config pyproject.toml --diff --show-source + python -m ruff check . \ + --config pyproject.toml \ + --diff \ + --show-source \ + --exit-non-zero-on-fix - name: mypy run: | python -m mypy --ignore-missing-imports semantic_release @@ -71,18 +75,19 @@ jobs: steps: - name: Set up Python 3.9 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.9 - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Install Black - run: python -m pip install black + - name: Install Ruff + run: python -m pip install ".[dev]" - - name: Beautify with Black - run: python -m black . + - name: Format + run: | + python -m ruff format . - name: Commit and push changes uses: github-actions-x/commit@v2.9 @@ -104,13 +109,16 @@ jobs: concurrency: push needs: [test, lint, beautify] if: github.repository == 'python-semantic-release/python-semantic-release' + environment: + name: pypi + url: https://pypi.org/project/python-semantic-release/ permissions: # https://docs.github.com/en/rest/overview/permissions-required-for-github-apps?apiVersion=2022-11-28#metadata id-token: write contents: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 ref: ${{ github.ref_name }} @@ -121,20 +129,15 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} root_options: "-vv" - # https://github.com/pypa/gh-action-pypi-publish#specifying-a-different-username - # This will need converting to use trusted publishing at a later date # see https://docs.pypi.org/trusted-publishers/ - name: Publish package distributions to PyPI id: pypi-publish - # NOTE: DO NOT wrap the conditional in ${{ }} as it will always evaluate to true. # See https://github.com/actions/runner/issues/1173 if: steps.release.outputs.released == 'true' uses: pypa/gh-action-pypi-publish@release/v1 with: verbose: true - user: ${{ secrets.PYPI_USERNAME }} - password: ${{ secrets.PYPI_PASSWORD }} - name: Publish package distributions to GitHub Releases id: github-release diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 3511c1d46..72973afd2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -10,18 +10,18 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('pyproject.toml') }} @@ -40,9 +40,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.8 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.8 - name: Install mypy & dev packages @@ -50,7 +50,12 @@ jobs: python -m pip install ".[dev, mypy]" - name: ruff run: | - python -m ruff check . --config pyproject.toml --diff --show-source + python -m ruff check . \ + --config pyproject.toml \ + --diff \ + --show-source \ + --exit-non-zero-on-fix + - name: mypy run: python -m mypy --ignore-missing-imports semantic_release @@ -58,7 +63,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: wagoid/commitlint-github-action@v5 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..207a6c61d --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,45 @@ +name: 'Stale Bot' +on: + schedule: + # Execute Daily at 7:15 AM UTC + - cron: '15 7 * * *' + +# Default token permissions = None +permissions: {} + + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + steps: + - uses: actions/stale@v9 + with: + # default: 30, GitHub Actions API Rate limit is 1000/hr + operations-per-run: 200 + exempt-all-milestones: true + # exempt-all-assignees: false (default) + stale-issue-label: stale + days-before-issue-stale: 90 + days-before-issue-close: 7 + exempt-issue-labels: confirmed, help-wanted + stale-issue-message: > + This issue is stale because it has not been confirmed or planned by the maintainers + and has been open 90 days with no recent activity. It will be closed in 7 days, + if no further activity occurs. Thank you for your contributions. + close-issue-message: > + This issue was closed because activity was dormant for 97 days. + # PR Configurations + stale-pr-label: stale + days-before-pr-stale: 60 + days-before-pr-close: 10 + exempt-pr-labels: confirmed, dependabot + stale-pr-message: > + This PR is stale because it has not been confirmed or planned by the maintainers + and had been open 60 days with no recent activity. It will be closed in 10 days, + if no further activity occurs. Thank you for your contributions. + close-pr-message: > + This PR was closed because activity was dormant for 70 days. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ce114cb3d..3a0f314c3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,6 +2,8 @@ default_language_version: python: python3 +exclude: "^CHANGELOG.md$" + repos: # Meta hooks - repo: meta @@ -30,37 +32,43 @@ repos: - id: check-ast # Formatters that may modify source files automatically - - repo: https://github.com/psf/black - rev: 23.9.1 + - repo: https://github.com/astral-sh/ruff-pre-commit + # Keep old ruff formatting rules until other merge requests are completed + rev: v0.1.11 hooks: - - id: black + - id: ruff-format + name: ruff (format) + args: ["."] + pass_filenames: false + - repo: https://github.com/adamchainz/blacken-docs rev: 1.16.0 hooks: - id: blacken-docs - additional_dependencies: ["black==23.7.0"] + additional_dependencies: ["black==23.10.1"] - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 + rev: v3.15.1 hooks: - id: pyupgrade - args: ["--py37-plus", "--keep-runtime-typing"] + args: ["--py38-plus", "--keep-runtime-typing"] # Linters and validation - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.292 + rev: v0.3.2 hooks: - id: ruff + name: ruff (lint) args: - "--fix" - "--exit-non-zero-on-fix" - "--statistics" - - "--format=text" + - "--output-format=text" - "." pass_filenames: false - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.5.1" + rev: "v1.9.0" hooks: - id: mypy args: ["--config-file", "pyproject.toml"] @@ -85,7 +93,7 @@ repos: - id: rst-inline-touching-normal - repo: https://github.com/jendrikseipp/vulture - rev: "v2.10" + rev: "v2.11" hooks: - id: vulture args: @@ -96,7 +104,7 @@ repos: - "tests" - repo: https://github.com/pycqa/bandit - rev: 1.7.5 + rev: 1.7.8 hooks: - id: bandit args: @@ -110,7 +118,7 @@ repos: # GHA linting - repo: https://github.com/python-jsonschema/check-jsonschema - rev: "0.27.0" + rev: "0.28.0" hooks: - id: check-github-workflows - id: check-readthedocs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e40c38db..fb8217120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,664 @@ +## v9.3.1 (2024-03-24) + +### Build + +* build(deps-dev): bump ruff from 0.1.11 to 0.3.3 (#862) ([`8a6adeb`](https://github.com/python-semantic-release/python-semantic-release/commit/8a6adeb6824a0b78e3dd297c1e62e5453a9f779f)) + +### Fix + +* fix(cli-version): change implementation to only push the tag we generated + +Restricts the git push command to only push the explicit tag we created +which will eliminate the possibility of pushing another tag that could +cause an error. + +Resolves: #803 ([`8a9da4f`](https://github.com/python-semantic-release/python-semantic-release/commit/8a9da4feb8753e3ab9ea752afa25decd2047675a)) + +* fix(algorithm): handle merge-base errors gracefully + +Merge-base errors generally occur from a shallow clone that is +primarily used by CI environments and will cause PSR to explode +prior to this change. Now it exits with an appropriate error. + +Resolves: #724 ([`4c998b7`](https://github.com/python-semantic-release/python-semantic-release/commit/4c998b77a3fe5e12783d1ab2d47789a10b83f247)) + +### Performance + +* perf(algorithm): simplify logs & use lookup when searching for commit & tag match ([`3690b95`](https://github.com/python-semantic-release/python-semantic-release/commit/3690b9511de633ab38083de4d2505b6d05853346)) + +### Style + +* style: beautify 8a9da4feb8753e3ab9ea752afa25decd2047675a ([`3bf98d5`](https://github.com/python-semantic-release/python-semantic-release/commit/3bf98d5c18886f9dcc60d122a834e6fe93d0ea07)) + + +## v9.3.0 (2024-03-21) + +### Feature + +* feat(cmd-version): changelog available to bundle (#779) + +* test(util): fix overlooked file differences in folder comparison + +* test(version): tracked changelog as changed file on version create + +Removes the temporary release_notes hack to prevent CHANGELOG generation on +execution of version command. Now that it is implemented we can remove the +fixture to properly pass the tests. + +* feat(cmd-version): create changelog prior to build enabling doc bundling ([`37fdb28`](https://github.com/python-semantic-release/python-semantic-release/commit/37fdb28e0eb886d682b5dea4cc83a7c98a099422)) + +### Style + +* style: beautify 37fdb28e0eb886d682b5dea4cc83a7c98a099422 ([`bc841cd`](https://github.com/python-semantic-release/python-semantic-release/commit/bc841cd171c46e1e23681f0f02d8f314281857c4)) + + +## v9.2.2 (2024-03-19) + +### Fix + +* fix(cli): enable subcommand help even if config is invalid + +Refactors configuration loading to use lazy loading by subcommands +triggered by the property access of the runtime_ctx object. Resolves +the issues when running `--help` on subcommands when a configuration +is invalid + +Resolves: #840 ([`91d221a`](https://github.com/python-semantic-release/python-semantic-release/commit/91d221a01266e5ca6de5c73296b0a90987847494)) + +### Test + +* test: update references in test cases ([`e083056`](https://github.com/python-semantic-release/python-semantic-release/commit/e083056892086fdc70beb7a42d952e7c9604e995)) + +* test(cli-help): add test cases of --help failures ([`1d53879`](https://github.com/python-semantic-release/python-semantic-release/commit/1d538790a727852d61c5edfe91c5707ed3b6af5a)) + + +## v9.2.1 (2024-03-19) + +### Chore + +* chore(pre-commit): upgrade hooks for pyupgrade, mypy, vulture, bandit, and check-jsonschema ([`d59f593`](https://github.com/python-semantic-release/python-semantic-release/commit/d59f5931e6b7fac3604f9d5f13a1947688b285d9)) + +* chore(deps-dev): bump ruff from 0.1.11 to 0.3.2 ([`ccdc976`](https://github.com/python-semantic-release/python-semantic-release/commit/ccdc9763b2f588bafe7a8c7d71342d4ebcb09f13)) + +* chore(pre-commit): cleanup ruff warnings ([`4cb4ca1`](https://github.com/python-semantic-release/python-semantic-release/commit/4cb4ca19dc3d649a54e7a1b1e9a44a196624eef0)) + +* chore(pre-commit): cleanup 'check blanket type ignore' warnings ([`7e938e6`](https://github.com/python-semantic-release/python-semantic-release/commit/7e938e69af7d45c6d77dcf0a3627e5663cb35bff)) + +* chore(pre-commit): exclude generated file CHANGELOG.md from checks ([`6e1f5cb`](https://github.com/python-semantic-release/python-semantic-release/commit/6e1f5cbd1c01f9a8c6cd8bafd706e5b5ec6ab007)) + +* chore(pre-commit): cleanup vulture warnings ([`4b2467a`](https://github.com/python-semantic-release/python-semantic-release/commit/4b2467a1d7b14cd1afc3b355e69e81804fcc5b4d)) + +### Fix + +* fix(parse-git-url): handle urls with url-safe special characters ([`27cd93a`](https://github.com/python-semantic-release/python-semantic-release/commit/27cd93a0a65ee3787ca51be4c91c48f6ddb4269c)) + +### Refactor + +* refactor: drop other 3.7 references ([`04bbefd`](https://github.com/python-semantic-release/python-semantic-release/commit/04bbefdebcdcf07667c2c297f1ce516ad35a1733)) + +### Style + +* style: beautify d59f5931e6b7fac3604f9d5f13a1947688b285d9 ([`585a098`](https://github.com/python-semantic-release/python-semantic-release/commit/585a0983198fcf5ced9a1a337e521cbd8f4d5de4)) + +### Test + +* test(helpers): add git url parse cases for urls with special characters ([`fc74ef2`](https://github.com/python-semantic-release/python-semantic-release/commit/fc74ef20e92645a9939efb0fcea69254a6a89681)) + + +## v9.2.0 (2024-03-18) + +### Build + +* build(MANIFEST): fix sdist contents to include docs & tests ([`228347c`](https://github.com/python-semantic-release/python-semantic-release/commit/228347c8f64cc46f01b717ccdc2daf15384c7f2e)) + +* build(deps): add click-option-group for grouping exclusive flags ([`bd892b8`](https://github.com/python-semantic-release/python-semantic-release/commit/bd892b89c26df9fccc9335c84e2b3217e3e02a37)) + +### Chore + +* chore(stalebot): add config to manage aging issues & PRs ([`d69a69b`](https://github.com/python-semantic-release/python-semantic-release/commit/d69a69bb636c06d384ae29601c62971cf1d6e88a)) + +* chore(dependabot): adjust conf to relax bumping flexible requirement specs ([`a040aa4`](https://github.com/python-semantic-release/python-semantic-release/commit/a040aa43eb2218d76b3e56d280c7853633af4f45)) + +### Documentation + +* docs(configuration): clarify the `major_on_zero` configuration option ([`f7753cd`](https://github.com/python-semantic-release/python-semantic-release/commit/f7753cdabd07e276bc001478d605fca9a4b37ec4)) + +* docs(configuration): add description of `allow-zero-version` configuration option ([`4028f83`](https://github.com/python-semantic-release/python-semantic-release/commit/4028f8384a0181c8d58c81ae81cf0b241a02a710)) + +### Feature + +* feat(version-config): add option to disable 0.x.x versions ([`dedb3b7`](https://github.com/python-semantic-release/python-semantic-release/commit/dedb3b765c8530379af61d3046c3bb9c160d54e5)) + +* feat(version): add new version print flags to display the last released version and tag ([`814240c`](https://github.com/python-semantic-release/python-semantic-release/commit/814240c7355df95e9be9a6ed31d004b800584bc0)) + +### Fix + +* fix(changelog-generation): fix incorrect release timezone determination ([`f802446`](https://github.com/python-semantic-release/python-semantic-release/commit/f802446bd0693c4c9f6bdfdceae8b89c447827d2)) + +* fix(changelog): make sure default templates render ending in 1 newline ([`0b4a45e`](https://github.com/python-semantic-release/python-semantic-release/commit/0b4a45e3673d0408016dc8e7b0dce98007a763e3)) + +### Style + +* style: resolve linter & formatting across codebase ([`747fe1d`](https://github.com/python-semantic-release/python-semantic-release/commit/747fe1da682764da1a7c9199f43c02fdf998739d)) + +* style(test-changelog): change to direct fixture reference & improve clarity ([`a841f3b`](https://github.com/python-semantic-release/python-semantic-release/commit/a841f3baa909768947ffac742a33ca18a40cc01b)) + +* style(tests): add additional typing to test args ([`74c9dec`](https://github.com/python-semantic-release/python-semantic-release/commit/74c9dec2869c41700bbfe378f3f7833fa02a821c)) + +* style: apply ruff formatting to codebase ([`ced4caa`](https://github.com/python-semantic-release/python-semantic-release/commit/ced4caadf35b26eb573f6d6e19452d05c7e7dabb)) + +### Test + +* test(fixtures): correct the ordering of commits in changelog expectations ([`adce520`](https://github.com/python-semantic-release/python-semantic-release/commit/adce5200910aa287c0925a6ca2c742fbd470209f)) + +* test(fixtures): adjust scipy changelog expectations related to parse errors ([`6242b61`](https://github.com/python-semantic-release/python-semantic-release/commit/6242b61a4d87f6f4e27e3a65b2cd57e22394da48)) + +* test: add bitbucket to hvcs parameter list & bitbucket to configs ([`b226906`](https://github.com/python-semantic-release/python-semantic-release/commit/b226906b3717d0307d242924a77cd847e7f9a50c)) + +* test(changelog): increase changelog rigor to all commit types ([`1573b6b`](https://github.com/python-semantic-release/python-semantic-release/commit/1573b6b7def84fabeaebdeb590d65068e6ed9a5d)) + +* test(changelog): enforce common single newline after generated docs ([`77c3816`](https://github.com/python-semantic-release/python-semantic-release/commit/77c3816f79ad3c1ef9687304b4773e6485e4ee2f)) + +* test(fixtures): refactor for better chronological ordering for test success ([`929b861`](https://github.com/python-semantic-release/python-semantic-release/commit/929b861a5d827913efac325f9db6ae7b2f44ef77)) + +* test(unit-changelog): refactor template testing to be fast & simple ([`f8a718f`](https://github.com/python-semantic-release/python-semantic-release/commit/f8a718f7c00baa58521ffb21b231437652d1e102)) + +* test(unit-release-notes): refactor template testing + +Drop the test related release notes template as that is one more thing to +maintain and use the one provided to the user. ([`620d62a`](https://github.com/python-semantic-release/python-semantic-release/commit/620d62a60c47c3a8ffdc79cb74ee36ca330767e3)) + +* test(unit-changelog): drop context test & duplicate/incorrect template + +Drop the test related changelog template as that is one more thing to +maintain and does not actually match the embedded template provided to +the user. + +Secondly, drop the context unit test as it does not provide value that +the template ultimately will fail in other unit tests. ([`ebb5ca3`](https://github.com/python-semantic-release/python-semantic-release/commit/ebb5ca3ea208c15170f25376e749e61e3475d2ee)) + +* test(repo-commits): fix angular syntax for scopes in commits ([`a2b2b8f`](https://github.com/python-semantic-release/python-semantic-release/commit/a2b2b8f2ddeedb79e60cdb90de7a1d981509e814)) + +* test(fixtures): trigger changelog generation in repo fixtures ([`2a89f68`](https://github.com/python-semantic-release/python-semantic-release/commit/2a89f68bf14f540234cd218e3aab1c9e12707f21)) + +* test(fixtures): remove changelog generation prevention ([`7af8373`](https://github.com/python-semantic-release/python-semantic-release/commit/7af8373c3612e6aeec19c52c006187faa418b5be)) + +* test(cli-version): ensure CHANGELOG is included in changed files ([`8d119df`](https://github.com/python-semantic-release/python-semantic-release/commit/8d119df1b0ca04f4e06446c608a2322497b30aac)) + +* test: improve reliability & error readability of assertions ([`0b07786`](https://github.com/python-semantic-release/python-semantic-release/commit/0b0778617b8e0d73365f5b223270625227c3ca30)) + +* test(changelog): add assertion to check for changelog file exist before read ([`5556c3a`](https://github.com/python-semantic-release/python-semantic-release/commit/5556c3a881bfcc206df8adb21075b532a5551053)) + +* test(cli-changelog): refactor changelog re-gen test to show debuggable results ([`3f4ff05`](https://github.com/python-semantic-release/python-semantic-release/commit/3f4ff05c71d261b825d117ecd030d34e22009731)) + +* test(fixtures): add common fixture to write default changelog from repo definition ([`d2904b2`](https://github.com/python-semantic-release/python-semantic-release/commit/d2904b250b1285017316575d5effeb42b4440dfe)) + +* test(next-version): adapt scipy commits test for new allow_zero_version config option ([`d157ecf`](https://github.com/python-semantic-release/python-semantic-release/commit/d157ecf579594177de0b7cbc61b706f41db56fb9)) + +* test(next-version): adapt emoji commits test for new allow_zero_version config option ([`fc05b0e`](https://github.com/python-semantic-release/python-semantic-release/commit/fc05b0e334e5e8f9263626f3b68c32ee3a3b9bcc)) + +* test(next-version): adapt angular commits test for new allow_zero_version config option ([`5893831`](https://github.com/python-semantic-release/python-semantic-release/commit/58938315a3ea04db798dc40b7940f9d323a00aae)) + +* test(next-version): adapt tag commits test for new allow_zero_version config option ([`efe1672`](https://github.com/python-semantic-release/python-semantic-release/commit/efe1672788fd3659f53f60cc2fd72ca1bb9e40c0)) + +* test(next-version): add new test case to ensure minimum version determinations ([`7ed5fe5`](https://github.com/python-semantic-release/python-semantic-release/commit/7ed5fe504234acbfed72dd5fe4e76381475d4cce)) + +* test(next-version): refactor fixture references for maintainability ([`a2b43e2`](https://github.com/python-semantic-release/python-semantic-release/commit/a2b43e2759b334775edc9c65b0327b70808416d3)) + +* test(fixtures): add config modifier fixture ([`e3bb4d3`](https://github.com/python-semantic-release/python-semantic-release/commit/e3bb4d3dc8aa5e14b11396f4e9fe1901f20ed4a3)) + +* test(scenario): add variation of `allow_zero_version` flag ([`36142a6`](https://github.com/python-semantic-release/python-semantic-release/commit/36142a6d96446f4117220205fec8e6fe3d63ad42)) + +* test(unit): update unit test for incrementing version ([`791c69d`](https://github.com/python-semantic-release/python-semantic-release/commit/791c69d8d9968357284d884344e1710b9289688a)) + + +## v9.1.1 (2024-02-25) + +### Fix + +* fix(parse_git_url): fix bad url with dash ([`1c25b8e`](https://github.com/python-semantic-release/python-semantic-release/commit/1c25b8e6f1e43c15ca7d5a59dca0a13767f9bc33)) + +### Style + +* style: beautify 1c25b8e6f1e43c15ca7d5a59dca0a13767f9bc33 ([`85b2a2f`](https://github.com/python-semantic-release/python-semantic-release/commit/85b2a2f5cf4c49aa3e394dfe3574e24266f93bfc)) + + +## v9.1.0 (2024-02-14) + +### Build + +* build(deps): bump minimum required `tomlkit` to `>=0.11.0` + +TOMLDocument is missing the `unwrap()` function in `v0.10.2` which +causes an AttributeError to occur when attempting to read a the text +in `pyproject.toml` as discovered with #834 + +Resolves: #834 ([`291aace`](https://github.com/python-semantic-release/python-semantic-release/commit/291aacea1d0429a3b27e92b0a20b598f43f6ea6b)) + +### Documentation + +* docs: add bitbucket to token table ([`56f146d`](https://github.com/python-semantic-release/python-semantic-release/commit/56f146d9f4c0fc7f2a84ad11b21c8c45e9221782)) + +* docs: add bitbucket authentication ([`b78a387`](https://github.com/python-semantic-release/python-semantic-release/commit/b78a387d8eccbc1a6a424a183254fc576126199c)) + +* docs: fix typo ([`b240e12`](https://github.com/python-semantic-release/python-semantic-release/commit/b240e129b180d45c1d63d464283b7dfbcb641d0c)) + +### Feature + +* feat: add bitbucket hvcs ([`bbbbfeb`](https://github.com/python-semantic-release/python-semantic-release/commit/bbbbfebff33dd24b8aed2d894de958d532eac596)) + +### Fix + +* fix: remove unofficial environment variables ([`a5168e4`](https://github.com/python-semantic-release/python-semantic-release/commit/a5168e40b9a14dbd022f62964f382b39faf1e0df)) + +### Refactor + +* refactor: add lint workaround ([`55d6e03`](https://github.com/python-semantic-release/python-semantic-release/commit/55d6e0349303e7b1a6a598a8f4eb54d708d1b27d)) + +### Style + +* style: ruff linter ([`f7f7c8a`](https://github.com/python-semantic-release/python-semantic-release/commit/f7f7c8a8d499126a16238e7183cde54e3ea4cea0)) + +* style: beautify 710d96482edae38438c090e5e631f2d6b6e990f2 ([`d2314f8`](https://github.com/python-semantic-release/python-semantic-release/commit/d2314f86946df8a6ef30ca7a5bd163c5989e46f7)) + +* style(fixtures): update styling of imports on git repo fixture ([`710d964`](https://github.com/python-semantic-release/python-semantic-release/commit/710d96482edae38438c090e5e631f2d6b6e990f2)) + +* style: beautify 8e3f87ce801498fdbb0c1b2261b9768941c0a21c ([`16c057a`](https://github.com/python-semantic-release/python-semantic-release/commit/16c057a8da471a696f6d6484f2cc821fd883f5ed)) + +* style(tests): update typing & import issues ([`2597ea5`](https://github.com/python-semantic-release/python-semantic-release/commit/2597ea584ab5084ef6ab398b9ec6186a6191ac1f)) + +### Test + +* test: remove environment variable tests ([`5c3fe69`](https://github.com/python-semantic-release/python-semantic-release/commit/5c3fe694de5db0fc6a157a6a7717fa8c4d26f6f6)) + +* test: add bitbucket to changelog unit test ([`c33f8ff`](https://github.com/python-semantic-release/python-semantic-release/commit/c33f8ff37e007a944b5fb1884b9e05b33ccd2047)) + +* test(fixtures): refactor repos to use common fixtures to simplify workflow ([`9ad8296`](https://github.com/python-semantic-release/python-semantic-release/commit/9ad829693589e0cfe0962159f41cc2efd886eacc)) + +* test(fixtures): add common repo build/setup fixture ([`8da2840`](https://github.com/python-semantic-release/python-semantic-release/commit/8da284095173f7bcece768e0452d8d18378454d4)) + +* test(fixtures): apply new repo definition to repos ([`1e13e2c`](https://github.com/python-semantic-release/python-semantic-release/commit/1e13e2cee25b2760058bdd58238a9f55d5999c5e)) + +* test(fixtures): define new repo definition type ([`e0e8792`](https://github.com/python-semantic-release/python-semantic-release/commit/e0e8792d9dcd3b7d3f4a41ef0f8fe2c92a761f55)) + +* test(fixtures): add generic multi-commit executor from definition ([`e9605f3`](https://github.com/python-semantic-release/python-semantic-release/commit/e9605f37b001ff478f08f781587448c16ba31687)) + +* test(fixtures): add commit & changelog entry derivation function ([`29dbc7c`](https://github.com/python-semantic-release/python-semantic-release/commit/29dbc7cf6f9a066d9f6bd785fbeb11e700a586ac)) + +* test(fixtures): add manual release commit creation fixture ([`93dc523`](https://github.com/python-semantic-release/python-semantic-release/commit/93dc523776af303e0870f42618bb1590948fb826)) + +* test(fixtures): expand hvcs use fixtures to set remote.domain value ([`1303a5a`](https://github.com/python-semantic-release/python-semantic-release/commit/1303a5a5fcaec326f2cbe625d847c7de821a853d)) + +* test(fixtures): use const to set example project domain ([`1a15761`](https://github.com/python-semantic-release/python-semantic-release/commit/1a15761c32de93b03628be0032f6a57f196d6ee1)) + +* test(constants): set starting example project version to 0.0.0 ([`310af11`](https://github.com/python-semantic-release/python-semantic-release/commit/310af1150129e9d6ef833dd9a18860119b15ee01)) + +* test(fixtures): remove unused fixtures & types ([`8e3f87c`](https://github.com/python-semantic-release/python-semantic-release/commit/8e3f87ce801498fdbb0c1b2261b9768941c0a21c)) + +* test(util): add util func for removing readonly .git/* files ([`180a053`](https://github.com/python-semantic-release/python-semantic-release/commit/180a053a992c0ffa03e1d9800cc4bf743d6331b2)) + +* test(fixtures): rename repo main and feature branches fixture across tests ([`f006e2b`](https://github.com/python-semantic-release/python-semantic-release/commit/f006e2b2e9e2025a3b07d14d79714420d59a63fe)) + +* test(fixtures): add caching to github flow development repos ([`693b143`](https://github.com/python-semantic-release/python-semantic-release/commit/693b14352da4fa2c7fcd969c4b8c5230ec90055c)) + +* test(fixtures): add caching to git flow development repos ([`3db33f0`](https://github.com/python-semantic-release/python-semantic-release/commit/3db33f0c6556701de54ecc07dde91b65fc5d60a5)) + +* test(fixtures): add caching to trunk development w/ tags repos ([`4e3b6b6`](https://github.com/python-semantic-release/python-semantic-release/commit/4e3b6b6ef3f493ca613e25b5a5bdb5545fa12e9f)) + +* test(fixtures): refactor for session level fixture use ([`a42b032`](https://github.com/python-semantic-release/python-semantic-release/commit/a42b032ff44bedee81d20765163758c8a2ca6153)) + +* test(utils): add a utility to temporary change directory ([`68e12f3`](https://github.com/python-semantic-release/python-semantic-release/commit/68e12f369deced9df9e2c8d08dae7a4e4ece3fee)) + +* test(fixtures): modularize git repo file into sub-modules ([`18d0877`](https://github.com/python-semantic-release/python-semantic-release/commit/18d0877f6a6db38ed127b1c07d191075dda46904)) + +* test(fixtures): deconflict colliding fixtures for file dependent fixture execution ([`1890cf2`](https://github.com/python-semantic-release/python-semantic-release/commit/1890cf2e8989e5fea182894e663c3659edf6903a)) + +* test(fixtures): cache the base example git directory ([`0cd0f44`](https://github.com/python-semantic-release/python-semantic-release/commit/0cd0f44019a5dacf01040b6917b35f2a918d00ca)) + +* test(fixtures): adapt cli conftest fixtures to read when file is known to exist ([`0193cde`](https://github.com/python-semantic-release/python-semantic-release/commit/0193cde64cf7e07a7ecd5a0ea9513105f6207386)) + + +## v9.0.3 (2024-02-08) + +### Chore + +* chore: modernize ruff configuration to work with ruff >= 0.2 ([`613d240`](https://github.com/python-semantic-release/python-semantic-release/commit/613d240499c081b185c5774d1d8a4665d3f5cc28)) + +### Fix + +* fix(algorithm): correct bfs to not abort on previously visited node ([`02df305`](https://github.com/python-semantic-release/python-semantic-release/commit/02df305db43abfc3a1f160a4a52cc2afae5d854f)) + +### Performance + +* perf(algorithm): refactor bfs search to use queue rather than recursion ([`8b742d3`](https://github.com/python-semantic-release/python-semantic-release/commit/8b742d3db6652981a7b5f773a74b0534edc1fc15)) + +### Style + +* style: beautify 8b742d3db6652981a7b5f773a74b0534edc1fc15 ([`f95be0c`](https://github.com/python-semantic-release/python-semantic-release/commit/f95be0c207e499e214b12c578825bfcee0f2bbf8)) + +### Test + +* test(algorithm): add bfs unit test on fake git history ([`2c8a36e`](https://github.com/python-semantic-release/python-semantic-release/commit/2c8a36ea5b1d1fb19cfe90a3b8a1bce5077c717c)) + + +## v9.0.2 (2024-02-08) + +### Chore + +* chore: update pre-commit hooks (#796) ([`e238452`](https://github.com/python-semantic-release/python-semantic-release/commit/e23845226120b4fb934dd8755ce1b3f822cac041)) + +### Ci + +* ci: Configure trusted publishing in pypi ([`8e3c00b`](https://github.com/python-semantic-release/python-semantic-release/commit/8e3c00b238859559d82ff692bcee15f70bf4f6ad)) + +* ci: bump the github-actions group with 3 updates (#831) ([`bf96143`](https://github.com/python-semantic-release/python-semantic-release/commit/bf961436fd81c6398ca2c456143b64517a2b4cac)) + +* ci: add grouped github-actions section to dependabot config (#794) ([`3eb15c4`](https://github.com/python-semantic-release/python-semantic-release/commit/3eb15c413cec0430fe2b27a313185068f900c61d)) + +### Documentation + +* docs: Remove duplicate note in configuration.rst (#807) ([`fb6f243`](https://github.com/python-semantic-release/python-semantic-release/commit/fb6f243a141642c02469f1080180ecaf4f3cec66)) + +### Fix + +* fix(util): properly parse windows line-endings in commit messages + +Due to windows line-endings `\r\n`, it would improperly split the commit +description (it failed to split at all) and cause detection of Breaking changes +to fail. The breaking changes regular expression looks to the start of the line +for the proper syntax. + +Resolves: #820 ([`70193ba`](https://github.com/python-semantic-release/python-semantic-release/commit/70193ba117c1a6d3690aed685fee8a734ba174e5)) + +### Style + +* style: beautify 70193ba117c1a6d3690aed685fee8a734ba174e5 ([`c777bb2`](https://github.com/python-semantic-release/python-semantic-release/commit/c777bb261e8e46497c5c4b3fd84303db71772142)) + +* style: beautify 229c6471efc2c1bee002c3b89f58caf391b89e78 ([`c7be6e2`](https://github.com/python-semantic-release/python-semantic-release/commit/c7be6e2330c3527302915fcf3334923ab5252480)) + +### Test + +* test(util): add windows line-endings possibilities for commit parsing ([`c57b082`](https://github.com/python-semantic-release/python-semantic-release/commit/c57b0825c632da166c6dbe5b976c9edb1aa5882b)) + +* test(fixtures): cache the base example project directory (#799) ([`229c647`](https://github.com/python-semantic-release/python-semantic-release/commit/229c6471efc2c1bee002c3b89f58caf391b89e78)) + + +## v9.0.1 (2024-02-06) + +### Fix + +* fix(config): set commit parser opt defaults based on parser choice (#782) ([`9c594fb`](https://github.com/python-semantic-release/python-semantic-release/commit/9c594fb6efac7e4df2b0bfbd749777d3126d03d7)) + +### Style + +* style: beautify 9c594fb6efac7e4df2b0bfbd749777d3126d03d7 ([`6ed24fe`](https://github.com/python-semantic-release/python-semantic-release/commit/6ed24fe81ae2cf2a3823a1dff78552abbd9fd363)) + + +## v9.0.0 (2024-02-06) + +### Breaking + +* fix!: drop support for Python 3.7 (#828) ([`ad086f5`](https://github.com/python-semantic-release/python-semantic-release/commit/ad086f5993ae4741d6e20fee618d1bce8df394fb)) + + +## v8.7.2 (2024-01-03) + +### Build + +* build(deps-dev): bump ruff from 0.1.8 to 0.1.11 (#792) + +Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.8 to 0.1.11. +- [Release notes](https://github.com/astral-sh/ruff/releases) +- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) +- [Commits](https://github.com/astral-sh/ruff/compare/v0.1.8...v0.1.11) + +--- +updated-dependencies: +- dependency-name: ruff + dependency-type: direct:production + update-type: version-update:semver-patch +... + +Signed-off-by: dependabot[bot] <support@github.com> +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ([`6835fca`](https://github.com/python-semantic-release/python-semantic-release/commit/6835fcad54b452ac212ee132a8424453c1c1e150)) + +### Fix + +* fix(lint): correct linter errors ([`c9556b0`](https://github.com/python-semantic-release/python-semantic-release/commit/c9556b0ca6df6a61e9ce909d18bc5be8b6154bf8)) + + +## v8.7.1 (2024-01-03) + +### Documentation + +* docs(contributing): add docs-build, testing conf, & build instructions (#787) ([`011b072`](https://github.com/python-semantic-release/python-semantic-release/commit/011b0729cba3045b4e7291fd970cb17aad7bae60)) + +* docs(configuration): change defaults definition of token default to table (#786) ([`df1df0d`](https://github.com/python-semantic-release/python-semantic-release/commit/df1df0de8bc655cbf8f86ae52aff10efdc66e6d2)) + +* docs: add note on default envvar behaviour (#780) ([`0b07cae`](https://github.com/python-semantic-release/python-semantic-release/commit/0b07cae71915c5c82d7784898b44359249542a64)) + +### Fix + +* fix(cli-generate-config): ensure configuration types are always toml parsable (#785) ([`758e649`](https://github.com/python-semantic-release/python-semantic-release/commit/758e64975fe46b961809f35977574729b7c44271)) + +### Style + +* style: beautify 011b0729cba3045b4e7291fd970cb17aad7bae60 ([`06d5c61`](https://github.com/python-semantic-release/python-semantic-release/commit/06d5c610642f5a515317b9030368f279086696fc)) + +* style: beautify d6c4ae0db458f8108c88d75ac4e07079bc747d32 ([`253c99e`](https://github.com/python-semantic-release/python-semantic-release/commit/253c99e72c1f4ddefd806c87cae10d1b72ff461b)) + +### Test + +* test(infrastructure): refactor test fixtures & configuration for higher resiliency (#773) ([`d6c4ae0`](https://github.com/python-semantic-release/python-semantic-release/commit/d6c4ae0db458f8108c88d75ac4e07079bc747d32)) + + +## v8.7.0 (2023-12-22) + +### Feature + +* feat(config): enable default environment token per hvcs (#774) ([`26528eb`](https://github.com/python-semantic-release/python-semantic-release/commit/26528eb8794d00dfe985812269702fbc4c4ec788)) + +### Style + +* style: beautify 26528eb8794d00dfe985812269702fbc4c4ec788 ([`514f558`](https://github.com/python-semantic-release/python-semantic-release/commit/514f5580fbec0143f88d3f637be260c769136377)) + + +## v8.6.0 (2023-12-22) + +### Documentation + +* docs: minor correction to commit-parsing documentation (#777) ([`245e878`](https://github.com/python-semantic-release/python-semantic-release/commit/245e878f02d5cafec6baf0493c921c1e396b56e8)) + +### Feature + +* feat(utils): expand parsable valid git remote url formats (#771) + +Git remote url parsing now supports additional formats (ssh, https, file, git) ([`cf75f23`](https://github.com/python-semantic-release/python-semantic-release/commit/cf75f237360488ebb0088e5b8aae626e97d9cbdd)) + +### Style + +* style: beautify cf75f237360488ebb0088e5b8aae626e97d9cbdd ([`2de634d`](https://github.com/python-semantic-release/python-semantic-release/commit/2de634d6e1fed29e8ce55a1c57fd23bf838badd9)) + + +## v8.5.2 (2023-12-19) + +### Build + +* build(deps-dev): bump ruff from 0.1.7 to 0.1.8 (#775) + +Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.7 to 0.1.8. +- [Release notes](https://github.com/astral-sh/ruff/releases) +- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) +- [Commits](https://github.com/astral-sh/ruff/compare/v0.1.7...v0.1.8) + +--- +updated-dependencies: +- dependency-name: ruff + dependency-type: direct:production + update-type: version-update:semver-patch +... + +Signed-off-by: dependabot[bot] <support@github.com> +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ([`5efda8a`](https://github.com/python-semantic-release/python-semantic-release/commit/5efda8acfed938d3188cd55678ace20ecac7f798)) + +* build(deps-dev): bump ruff from 0.1.6 to 0.1.7 (#769) + +* build(deps-dev): bump ruff from 0.1.6 to 0.1.7 + +Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.6 to 0.1.7. +- [Release notes](https://github.com/astral-sh/ruff/releases) +- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) +- [Commits](https://github.com/astral-sh/ruff/compare/v0.1.6...v0.1.7) + +--- +updated-dependencies: +- dependency-name: ruff + dependency-type: direct:production + update-type: version-update:semver-patch +... + +Signed-off-by: dependabot[bot] <support@github.com> + +* ci: remove hardcoded ruff version in workflows + +--------- + +Signed-off-by: dependabot[bot] <support@github.com> +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> +Co-authored-by: Bernard Cooke <bernard-cooke@hotmail.com> ([`c48c3b3`](https://github.com/python-semantic-release/python-semantic-release/commit/c48c3b370335931d63391d1a4f5802937deff178)) + +### Fix + +* fix(cli): gracefully output configuration validation errors (#772) + +* test(fixtures): update example project workflow & add config modifier + +* test(cli-main): add test for raw config validation error + +* fix(cli): gracefully output configuration validation errors ([`e8c9d51`](https://github.com/python-semantic-release/python-semantic-release/commit/e8c9d516c37466a5dce75a73766d5be0f9e74627)) + +### Style + +* style: beautify 5efda8acfed938d3188cd55678ace20ecac7f798 ([`98b10b3`](https://github.com/python-semantic-release/python-semantic-release/commit/98b10b3f08af16ab5cb00096b288afefbee1b74f)) + +* style: beautify c48c3b370335931d63391d1a4f5802937deff178 ([`bb3b631`](https://github.com/python-semantic-release/python-semantic-release/commit/bb3b63111d0e02bd53c2ed25d5ab0e5a3d532136)) + + +## v8.5.1 (2023-12-12) + +### Documentation + +* docs(configuration): adjust wording and improve clarity (#766) + +* docs(configuration): fix typo in text + +* docs(configuration): adjust wording and improve clarity ([`6b2fc8c`](https://github.com/python-semantic-release/python-semantic-release/commit/6b2fc8c156e122ee1b43fdb513b2dc3b8fd76724)) + +### Fix + +* fix(config): gracefully fail when repo is in a detached HEAD state (#765) + +* fix(config): cleanly handle repository in detached HEAD state + +* test(cli-main): add detached head cli test ([`ac4f9aa`](https://github.com/python-semantic-release/python-semantic-release/commit/ac4f9aacb72c99f2479ae33369822faad011a824)) + +* fix(cmd-version): handle committing of git-ignored file gracefully (#764) + +* fix(version): only commit non git-ignored files during version commit + +* test(version): set version file as ignored file + +Tweaks tests to use one committed change file and the version file +as an ignored change file. This allows us to verify that our commit +mechanism does not crash if a file that is changed is ignored by user ([`ea89fa7`](https://github.com/python-semantic-release/python-semantic-release/commit/ea89fa72885e15da91687172355426a22c152513)) + +### Style + +* style: beautify 6b2fc8c156e122ee1b43fdb513b2dc3b8fd76724 ([`9bf69d7`](https://github.com/python-semantic-release/python-semantic-release/commit/9bf69d7005eee75f20b356bda97fea2d250a91de)) + + +## v8.5.0 (2023-12-07) + +### Feature + +* feat: allow template directories to contain a '.' at the top-level (#762) ([`07b232a`](https://github.com/python-semantic-release/python-semantic-release/commit/07b232a3b34be0b28c6af08aea4852acb1b9bd56)) + + +## v8.4.0 (2023-12-07) + +### Build + +* build(deps-dev): bump ruff from 0.1.2 to 0.1.6 (#757) + +Bumps [ruff](https://github.com/astral-sh/ruff) from 0.1.2 to 0.1.6. +- [Release notes](https://github.com/astral-sh/ruff/releases) +- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) +- [Commits](https://github.com/astral-sh/ruff/compare/v0.1.2...v0.1.6) + +--- +updated-dependencies: +- dependency-name: ruff + dependency-type: direct:production + update-type: version-update:semver-patch +... + +Signed-off-by: dependabot[bot] <support@github.com> +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ([`90db8f1`](https://github.com/python-semantic-release/python-semantic-release/commit/90db8f1bd8986eda1b913cd4bab5abd41192f01f)) + +* build(deps-dev): update python-gitlab requirement from <4,>=2 to >=2,<5 (#748) + +Updates the requirements on [python-gitlab](https://github.com/python-gitlab/python-gitlab) to permit the latest version. +- [Release notes](https://github.com/python-gitlab/python-gitlab/releases) +- [Changelog](https://github.com/python-gitlab/python-gitlab/blob/main/CHANGELOG.md) +- [Commits](https://github.com/python-gitlab/python-gitlab/compare/v2.0.0...v4.1.1) + +--- +updated-dependencies: +- dependency-name: python-gitlab + dependency-type: direct:production +... + +Signed-off-by: dependabot[bot] <support@github.com> +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ([`a176d62`](https://github.com/python-semantic-release/python-semantic-release/commit/a176d626f28ba68ae8a938b2f04f74da841a7eeb)) + +* build(deps-dev): bump ruff from 0.0.292 to 0.1.1 ([`9c5bbe0`](https://github.com/python-semantic-release/python-semantic-release/commit/9c5bbe0b0ef96e0fadae9e65918fc8939d0d3e60)) + +### Documentation + +* docs(migration): fix comments about publish command (#747) ([`90380d7`](https://github.com/python-semantic-release/python-semantic-release/commit/90380d797a734dcca5040afc5fa00e3e01f64152)) + +### Feature + +* feat(cmd-version): add `--tag/--no-tag` option to version command (#752) + +* fix(version): separate push tags from commit push when not committing changes + +* feat(version): add `--no-tag` option to turn off tag creation + +* test(version): add test for `--tag` option & `--no-tag/commit` + +* docs(commands): update `version` subcommand options ([`de6b9ad`](https://github.com/python-semantic-release/python-semantic-release/commit/de6b9ad921e697b5ea2bb2ea8f180893cecca920)) + +### Style + +* style: beautify de6b9ad921e697b5ea2bb2ea8f180893cecca920 ([`c94fb6f`](https://github.com/python-semantic-release/python-semantic-release/commit/c94fb6f53bd8bdeaa4f40219886fa7c6e8755f29)) + +* style: convert formatter from black to ruff (#746) ([`deb4dba`](https://github.com/python-semantic-release/python-semantic-release/commit/deb4dbaba3de5396e0eb2389e728d0b1fe702843)) + +### Test + +* test(commandline-main): prevent git gpgsign config from failing tests (#760) ([`744ff25`](https://github.com/python-semantic-release/python-semantic-release/commit/744ff256f10c895a97241dc085bc132d10c9f737)) + +### Unknown + +* Revert "feat(action): use composite action for semantic release (#692)" + +This reverts commit 4648d87bac8fb7e6cc361b765b4391b30a8caef8. ([`f145257`](https://github.com/python-semantic-release/python-semantic-release/commit/f1452578cc064edbe64d61ae3baab4bc9bd4b666)) + + ## v8.3.0 (2023-10-23) ### Feature @@ -2028,11 +2686,7 @@ Fixes #237 ([`fe6a7e7`](https://github.com/python-semantic-release/python-semant ## v7.0.0 (2020-05-22) -### Documentation - -* docs: add conda-forge badge ([`e9536bb`](https://github.com/python-semantic-release/python-semantic-release/commit/e9536bbe119c9e3b90c61130c02468e0e1f14141)) - -### Feature +### Breaking * feat(changelog): add changelog components (#240) @@ -2057,6 +2711,10 @@ BREAKING CHANGE: The `compare_url` option has been removed in favor of using Changelog components may now receive the value of `changelog_sections`, split and ready to use. ([`3e17a98`](https://github.com/python-semantic-release/python-semantic-release/commit/3e17a98d7fa8468868a87e62651ac2c010067711)) +### Documentation + +* docs: add conda-forge badge ([`e9536bb`](https://github.com/python-semantic-release/python-semantic-release/commit/e9536bbe119c9e3b90c61130c02468e0e1f14141)) + ### Style * style: improve code formatting ([`1dfca97`](https://github.com/python-semantic-release/python-semantic-release/commit/1dfca97c3856e496e9e2cda429b8aa093799bd5b)) @@ -2077,7 +2735,7 @@ Fixes #239 ([`34acbbc`](https://github.com/python-semantic-release/python-semant ## v6.4.0 (2020-05-15) -### Feature +### Breaking * feat(history): create emoji parser (#238) @@ -2446,11 +3104,7 @@ will get the version they specify. ([`123984d`](https://github.com/python-semant ## v5.0.0 (2020-03-22) -### Documentation - -* docs(pypi): update docstings in pypi.py ([`6502d44`](https://github.com/python-semantic-release/python-semantic-release/commit/6502d448fa65e5dc100e32595e83fff6f62a881a)) - -### Feature +### Breaking * feat(build): allow config setting for build command (#195) @@ -2460,6 +3114,10 @@ BREAKING CHANGE: Previously the build_commands configuration variable set the ty Closes #188 ([`740f4bd`](https://github.com/python-semantic-release/python-semantic-release/commit/740f4bdb26569362acfc80f7e862fc2c750a46dd)) +### Documentation + +* docs(pypi): update docstings in pypi.py ([`6502d44`](https://github.com/python-semantic-release/python-semantic-release/commit/6502d448fa65e5dc100e32595e83fff6f62a881a)) + ### Fix * fix: Rename default of build_command config ([`d5db22f`](https://github.com/python-semantic-release/python-semantic-release/commit/d5db22f9f7acd05d20fd60a8b4b5a35d4bbfabb8)) @@ -3790,7 +4448,7 @@ file. ([`005dba0`](https://github.com/python-semantic-release/python-semantic-re * Merge branch 'tag-parser' ([`2519b42`](https://github.com/python-semantic-release/python-semantic-release/commit/2519b42c7381fe8217b34150bd1ad06b23c9a56d)) -## v2.1.4 (2015-08-23) +## v2.1.4 (2015-08-24) ### Fix @@ -3854,7 +4512,7 @@ Properties can only be used from instances. ([`7ecdeb2`](https://github.com/pyth * 2.1.1 ([`7cf3a7d`](https://github.com/python-semantic-release/python-semantic-release/commit/7cf3a7d9aa2adc5a3cebf9d1151b113388117312)) -## v2.1.0 (2015-08-19) +## v2.1.0 (2015-08-20) ### Chore @@ -4141,7 +4799,7 @@ related to #9 ([`a71b536`](https://github.com/python-semantic-release/python-sem * 0.3.2 ([`1d3ee00`](https://github.com/python-semantic-release/python-semantic-release/commit/1d3ee00c3601f06f900bc1694f3c7c32106a6e14)) -## v0.3.1 (2015-07-27) +## v0.3.1 (2015-07-28) ### Unknown diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index c88d3cf6d..33fcc3297 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -29,19 +29,69 @@ that should result in a new release it will happen if the build is green. Development ~~~~~~~~~~~ -Install this module and the development dependencies:: +Install this module and the development dependencies - pip install -e ".[test,dev]" +.. code-block:: bash -And if you'd like to build the documentation locally:: + pip install -e ".[dev,mypy,test]" + +And if you'd like to build the documentation locally + +.. code-block:: bash pip install -e ".[docs]" + sphinx-autobuild --open-browser docs docs/_build/html Testing ~~~~~~~ To test your modifications locally: -:: +.. code-block:: bash + # Run type-checking, all tests across all supported Python versions tox + + # Run all tests for your current installed Python version (with full error output) + pytest -vv tests/ + +If you need to run tests in a debugger, such as VSCode, you will need to adjust +``pyproject.toml`` temporarily: + +.. code-block:: diff + + diff --git a/pyproject.toml b/pyproject.toml + + [tool.pytest.ini_options] + addopts = [ + + "-n0", + - "-nauto", + "-ra", + "--cache-clear", + - "--cov=semantic_release", + - "--cov-context=test", + - "--cov-report", + - "html:coverage-html", + - "--cov-report", + - "term", + ] + +.. note:: + + The ``-n0`` option disables ``xdist``'s parallel testing. The removal of the coverage options + is to avoid a bug in ``pytest-cov`` that prevents VSCode from stopping at the breakpoints. + +Building +~~~~~~~~ + +This project is designed to be versioned and built by itself using the ``tool.semantic_release`` +configuration in ``pyproject.toml``. The setting ``tool.semantic_release.build_command`` defines +the command to run to build the package. + +The following is a copy of the ``build_command`` setting which can be run manually to build the +package locally: + +.. code-block:: bash + + python -m pip install build~=0.10.0 + python -m build . diff --git a/MANIFEST.in b/MANIFEST.in index 257ad0ed9..e09e8dd87 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,9 @@ -include README.md MANIFEST.in LICENSE pyproject.toml -recursive-include *.j2 +# Make sure Jinja templates are included +recursive-include ./ *.j2 + +# Make sure non-python files are included graft semantic_release/data + +# include docs & testing into sdist, ignored for wheel build +graft tests/ +graft docs/ diff --git a/README.rst b/README.rst index 73f30b366..70167f553 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,7 @@ Python Semantic Release *********************** -|Black| |Ruff| |Test Status| |PyPI Version| |conda-forge version| |Read the Docs Status| |Pre-Commit Enabled| +|Ruff| |Test Status| |PyPI Version| |conda-forge version| |Read the Docs Status| |Pre-Commit Enabled| Automatic Semantic Versioning for Python projects. This is a Python implementation of `semantic-release`_ for JS by Stephan Bönnemann. If @@ -30,9 +30,6 @@ Read more about the setup and configuration in our `getting started guide`_. .. _GitHub Action: https://python-semantic-release.readthedocs.io/en/latest/automatic-releases/github-actions.html .. _conda-forge: https://anaconda.org/conda-forge/python-semantic-release -.. |Black| image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/psf/black - :alt: black .. |Test Status| image:: https://img.shields.io/github/actions/workflow/status/python-semantic-release/python-semantic-release/main.yml?branch=master&label=Test%20Status&logo=github :target: https://github.com/python-semantic-release/python-semantic-release/actions/workflows/main.yml :alt: test-status diff --git a/docs/commands.rst b/docs/commands.rst index 113600acf..185f00015 100644 --- a/docs/commands.rst +++ b/docs/commands.rst @@ -144,6 +144,33 @@ command line options:: semantic-release version --patch --print semantic-release version --prerelease --print +.. _cmd-version-option-print-tag: + +``--print-tag`` +*************** + +Same as the :ref:`cmd-version-option-print` flag but prints the complete tag +name (ex. ``v1.0.0`` or ``py-v1.0.0``) instead of the raw version number +(``1.0.0``). + +.. _cmd-version-option-print-last-released: + +``--print-last-released`` +************************* + +Print the last released version based on the Git tags. This flag is useful if you just +want to see the released version without determining what the next version will be. +Note if the version can not be found nothing will be printed. + +.. _cmd-version-option-print-last-released-tag: + +``--print-last-released-tag`` +*************** + +Same as the :ref:`cmd-version-option-print-last-released` flag but prints the +complete tag name (ex. ``v1.0.0`` or ``py-v1.0.0``) instead of the raw version +number (``1.0.0``). + .. _cmd-version-option-force-level: ``--major/--minor/--patch`` @@ -213,13 +240,26 @@ For example, assuming a project is currently at version 1.2.3:: Whether or not to perform a ``git commit`` on modifications to source files made by ``semantic-release`` during this command invocation, and to run ``git tag`` on this new commit with a tag corresponding to the new version. -If ``--no-commit`` is supplied, a number of other options are also disabled; please see below. +If ``--no-commit`` is supplied, it may disable other options derivatively; please see below. **Default:** ``--commit`` .. seealso:: - :ref:`tag_format ` +.. _cmd-version-option-tag: + +``--tag/--no-tag`` +************************ + +Whether or not to perform a ``git tag`` to apply a tag of the corresponding to the new version during this +command invocation. This option manages the tag application separate from the commit handled by the ``--commit`` +option. + +If ``--no-tag`` is supplied, it may disable other options derivatively; please see below. + +**Default:** ``--tag`` + .. _cmd-version-option-changelog: ``--changelog/--no-changelog`` @@ -239,10 +279,10 @@ version released. ``--push/--no-push`` ******************** -Whether or not to push new commits and tags to the remote repository. +Whether or not to push new commits and/or tags to the remote repository. -**Default:** ``--no-push`` if :ref:`--no-commit ` is also -supplied, otherwise ``--push`` +**Default:** ``--no-push`` if :ref:`--no-commit ` and +:ref:`--no-tag ` is also supplied, otherwise ``push`` is the default. .. _cmd-version-option-vcs-release: diff --git a/docs/commit-parsing.rst b/docs/commit-parsing.rst index 10e53af39..b1c525e9c 100644 --- a/docs/commit-parsing.rst +++ b/docs/commit-parsing.rst @@ -284,7 +284,7 @@ available. .. _Rust's error handling: https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html .. _black: https://github.com/psf/black/blob/main/src/black/rusty.py .. _catching exceptions in Python is slower: https://docs.python.org/3/faq/design.html#how-fast-are-exceptions -.. _namedtuple: https://docs.python.org/3.7/library/typing.html#typing.NamedTuple +.. _namedtuple: https://docs.python.org/3/library/typing.html#typing.NamedTuple .. _commit-parsing-parser-options: @@ -354,7 +354,7 @@ Therefore, a custom commit parser could be implemented via: class MyCommitParser( semantic_release.CommitParser[semantic_release.ParseResult, MyParserOptions] ): - def parse(self, commit: git.object.commit.Commit) -> semantic_release.ParseResult: + def parse(self, commit: git.objects.commit.Commit) -> semantic_release.ParseResult: ... .. _angular commit guidelines: https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits diff --git a/docs/configuration.rst b/docs/configuration.rst index 1ffe68ebb..b3a1432be 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -4,9 +4,9 @@ Configuration ============= Configuration is read from a file which can be specified using the -:ref:`--config ` option to :ref:`cmd-main`. Python Semantic -Release currently supports either TOML- or JSON-formatted configuration, and will -attempt to detect the configuration format and parse it. +:ref:`\\\\-\\\\-config ` option to :ref:`cmd-main`. Python Semantic +Release currently supports a configuration in either TOML or JSON format, and will +attempt to auto-detect and parse either format. When using a JSON-format configuration file, Python Semantic Release looks for its settings beneath a top-level ``semantic_release`` key; when using a TOML-format @@ -50,37 +50,33 @@ the relevant settings. Environment Variables --------------------- -Some settings can be configured via environment variables. In order to do this, -you must indicate that Python Semantic Release should use a particular environment -variable as follows. - -Suppose for example that you would like to set :ref:`remote.token `. -It is possible to do so by pasting your token in plaintext into your -configuration file (**Note: this is not advisable**): +Some settings are best pulled from environment variables rather than being stored +in plaintext in your configuration file. Python Semantic Release can be configured +to look for an environment variable value to use for a given setting, but this feature +is not available for all settings. In order to use an environment variable for a setting, +you must indicate in your configuration file the name of the environment variable to use. + +The traditional and most common use case for environment variable use is for passing +authentication tokens to Python Semantic Release. You do **NOT** want to hard code your +authentication token in your configuration file, as this is a **security risk**. A plaintext +token in your configuration file could be exposed to anyone with access to your repository, +including long after its deleted if a token is in your git history. Instead, define the name +of the environment variable which contains your :ref:`remote.token `, +such as ``GH_TOKEN``, in your configuration file, and Python Semantic Release will do the +rest, as seen below. .. code-block:: toml - [tool.semantic_release.remote] - token = "very secret 123" + [tool.semantic_release.remote.token] + env = "GH_TOKEN" -Unfortunately, this configuration lives in your Git repository along with your source -code, and this would represent insecure management of your password. It is recommended -to use an environment variable to provide the required password. Suppose you would -like to specify that should be read from the environment variable ``GH_TOKEN``. -In this case, you should modify your configuration to the following: +Given basic TOML syntax compatibility, this is equivalent to: .. code-block:: toml [tool.semantic_release.remote] token = { env = "GH_TOKEN" } -This is equivalent to the default: - -.. code-block:: toml - - [tool.semantic_release.remote.token] - env = "GH_TOKEN" - The general format for specifying that some configuration should be sourced from an environment variable is: @@ -104,11 +100,6 @@ In this structure: Settings -------- -.. note:: - If you are using the built-in GitHub Action, the default value is set to - ``github-actions ``. You can modify this with the - ``git_committer_name`` and ``git_committer_email`` inputs. - .. _config-root: ``[tool.semantic_release]`` @@ -187,13 +178,14 @@ adding the old message pattern(s) to :ref:`exclude_commit_patterns `, ``"emoji"`` for -:ref:`EmojiCommitParser `, ``"scipy"`` for -:ref:`` or ``"tag"`` for -:ref:`TagCommitParser `. However you can also specify your own -commit parser in ``module:attr`` form, in which case this will be imported and used -instead. +Built-in parsers: + * ``angular`` - :ref:`AngularCommitParser ` + * ``emoji`` - :ref:`EmojiCommitParser ` + * ``scipy`` - :ref:`ScipyCommitParser ` + * ``tag`` - :ref:`TagCommitParser ` + +You can set any of the built-in parsers by their keyword but you can also specify +your own commit parser in ``module:attr`` form. For more information see :ref:`commit-parsing`. @@ -210,27 +202,61 @@ or transformation. For more information, see :ref:`commit-parsing-parser-options`. -The default values are the defaults for :ref:`commit-parser-angular` - -**Default:** - -.. code-block:: toml +The default value for this setting depends on what you specify as +:ref:`commit_parser `. The table below outlines +the expections from ``commit_parser`` value to default options value. + +================== == ================================= +``commit_parser`` Default ``commit_parser_options`` +================== == ================================= +``"angular"`` -> .. code-block:: toml + + [tool.semantic_release.commit_parser_options] + allowed_types = [ + "build", "chore", "ci", "docs", "feat", "fix", + "perf", "style", "refactor", "test" + ] + minor_types = ["feat"] + patch_types = ["fix", "perf"] + +``"emoji"`` -> .. code-block:: toml + + [tool.semantic_release.commit_parser_options] + major_tags = [":boom:"] + minor_tags = [ + ":sparkles:", ":children_crossing:", ":lipstick:", + ":iphone:", ":egg:", ":chart_with_upwards_trend:" + ] + patch_tags = [ + ":ambulance:", ":lock:", ":bug:", ":zap:", ":goal_net:", + ":alien:", ":wheelchair:", ":speech_balloon:", ":mag:", + ":apple:", ":penguin:", ":checkered_flag:", ":robot:", + ":green_apple:" + ] + +``"scipy"`` -> .. code-block:: toml + + [tool.semantic_release.commit_parser_options] + allowed_tags = [ + "API", "DEP", "ENH", "REV", "BUG", "MAINT", "BENCH", + "BLD", "DEV", "DOC", "STY", "TST", "REL", "FEAT", "TEST", + ] + major_tags = ["API",] + minor_tags = ["DEP", "DEV", "ENH", "REV", "FEAT"] + patch_tags = ["BLD", "BUG", "MAINT"] + +``"tag"`` -> .. code-block:: toml + + [tool.semantic_release.commit_parser_options] + minor_tag = ":sparkles:" + patch_tag = ":nut_and_bolt:" + +``"module:class"`` -> ``**module:class.parser_options()`` +================== == ================================= + +**Default:** ``ParserOptions { ... }``, where ``...`` depends on +:ref:`commit_parser ` as indicated above. - [tool.semantic_release.commit_parser_options] - allowed_tags = [ - "build", - "chore", - "ci", - "docs", - "feat", - "fix", - "perf", - "style", - "refactor", - "test", - ] - minor_tags = ["feat"] - patch_tags = ["fix", "perf"] .. _config-logging-use-named-masks: @@ -242,13 +268,39 @@ identifying which secrets were replaced, or use a generic string to mask them. **Default:** ``false`` +.. _config-allow-zero-version: + +``allow_zero_version (bool)`` +""""""""""""""""""""""""""""" + +This flag controls whether or not Python Semantic Release will use version +numbers aligning with the ``0.x.x`` pattern. + +If set to ``true`` and starting at ``0.0.0``, a minor bump would set the +next version as ``0.1.0`` whereas a patch bump would set the next version as +``0.0.1``. A breaking change (ie. major bump) would set the next version as +``1.0.0`` unless the :ref:`major_on_zero` is set to ``false``. + +If set to ``false``, Python Semantic Release will consider the first possible +version to be ``1.0.0``, regardless of patch, minor, or major change level. +Additionally, when ``allow_zero_version`` is set to ``false``, +the :ref:`major_on_zero` setting is ignored. + +**Default:** ``true`` + .. _config-major-on-zero: ``major_on_zero (bool)`` """""""""""""""""""""""" +This flag controls whether or not Python Semantic Release will increment the major +version upon a breaking change when the version matches ``0.y.z``. This value is +set to ``true`` by default, where breaking changes will increment the ``0`` major +version to ``1.0.0`` like normally expected. + If set to ``false``, major (breaking) releases will increment the minor digit of the -version while the major version is ``0``, instead of the major digit. +version while the major version is ``0``, instead of the major digit. This allows for +continued breaking changes to be made while the major version remains ``0``. From the `Semantic Versioning Specification`_: @@ -257,6 +309,11 @@ From the `Semantic Versioning Specification`_: .. _Semantic Versioning Specification: https://semver.org/spec/v2.0.0.html#spec-item-4 +When you are ready to release a stable version, set ``major_on_zero`` to ``true`` and +run Python Semantic Release again. This will increment the major version to ``1.0.0``. + +When :ref:`allow_zero_version` is set to ``false``, this setting is ignored. + **Default:** ``true`` .. _config-tag-format: @@ -375,7 +432,7 @@ The patterns in this list are treated as regular expressions. ************************************************* .. note:: - This section of the configuration contains options which customise the template + This section of the configuration contains options which customize the template environment used to render templates such as the changelog. Most options are passed directly to the `jinja2.Environment`_ constructor, and further documentation one these parameters can be found there. @@ -541,8 +598,8 @@ Name of the remote to push to using ``git push -u $name `` """""""""""""" The type of the remote VCS. Currently, Python Semantic Release supports ``"github"``, -``"gitlab"`` and ``"gitea"``. Not all functionality is available with all remote types, -but we welcome pull requests to help improve this! +``"gitlab"``, ``"gitea"`` and ``"bitbucket"``. Not all functionality is available with all +remote types, but we welcome pull requests to help improve this! **Default:** ``"github"`` @@ -559,12 +616,13 @@ pushing. .. _config-remote-token: -``token`` (:ref:`Environment Variable `) -"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" +``token (Dict['env': str])`` +"""""""""""""""""""""""""""" -Environment variable from which to source the authentication token for the remote VCS. -Common examples include ``"GH_TOKEN"``, ``"GITLAB_TOKEN"`` or ``"GITEA_TOKEN"``, however -you can choose to use a custom environment variable if you wish. +:ref:`Environment Variable ` from which to source the +authentication token for the remote VCS. Common examples include ``"GH_TOKEN"``, +``"GITLAB_TOKEN"`` or ``"GITEA_TOKEN"``, however, you may choose to use a custom +environment variable if you wish. .. note:: By default, this is a **mandatory** environment variable that must be set before @@ -580,7 +638,21 @@ you can choose to use a custom environment variable if you wish. configuring the environment variable for your remote VCS authentication token. -**Default:** ``{ env = "GH_TOKEN" }`` +The default value for this setting depends on what you specify as +:ref:`remote.type `. Review the table below to see what the +default token value will be for each remote type. + +================ == =============================== +``remote.type`` Default ``remote.token`` +================ == =============================== +``"github"`` -> ``{ env = "GH_TOKEN" }`` +``"gitlab"`` -> ``{ env = "GITLAB_TOKEN" }`` +``"gitea"`` -> ``{ env = "GITEA_TOKEN" }`` +``"bitbucket"`` -> ``{ env = "BITBUCKET_TOKEN" }`` +================ == =============================== + +**Default:** ``{ env = "" }``, where ```` depends on +:ref:`remote.type ` as indicated above. .. _config-publish: @@ -603,9 +675,9 @@ list should be a string containing a Unix-style glob pattern. ``upload_to_vcs_release (bool)`` """""""""""""""""""""""""""""""" -If set to ``true``, upload artefacts matching +If set to ``true``, upload any artifacts matched by the :ref:`dist_glob_patterns ` to the release created -in the remote VCS corresponding to the latest tag, if that is supported by the -:ref:`VCS type `. +in the remote VCS corresponding to the latest tag. Artifacts are only uploaded if +release artifact uploads are supported by the :ref:`VCS type `. **Default:** ``true`` diff --git a/docs/index.rst b/docs/index.rst index fbaf97ea2..7c7cd12b1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -129,11 +129,11 @@ usage. This token should be stored in the ``GH_TOKEN`` environment variable To generate a token go to https://github.com/settings/tokens and click on *Personal access token*. -GitLab (``GL_TOKEN``) -""""""""""""""""""""" +GitLab (``GITLAB_TOKEN``) +""""""""""""""""""""""""" A personal access token from GitLab. This is used for authenticating when pushing -tags, publishing releases etc. This token should be stored in the ``GL_TOKEN`` +tags, publishing releases etc. This token should be stored in the ``GITLAB_TOKEN`` environment variable. Gitea (``GITEA_TOKEN``) @@ -142,6 +142,19 @@ Gitea (``GITEA_TOKEN``) A personal access token from Gitea. This token should be stored in the ``GITEA_TOKEN`` environment variable. +Bitbucket (``BITBUCKET_TOKEN``) +""""""""""""""""""""""""""""""" + +Bitbucket does not support uploading releases but can still benefit from automated tags +and changelogs. The user has three options to push changes to the repository: + +#. Use SSH keys. +#. Use an `App Secret`_, store the secret in the ``BITBUCKET_TOKEN`` environment variable and the username in ``BITBUCKET_USER``. +#. Use an `Access Token`_ for the repository and store it in the ``BITBUCKET_TOKEN`` environment variable. + +.. _App Secret: https://support.atlassian.com/bitbucket-cloud/docs/push-back-to-your-repository/#App-secret +.. _Access Token: https://support.atlassian.com/bitbucket-cloud/docs/repository-access-tokens + .. seealso:: - :ref:`Changelog ` - customize your project's changelog. - :ref:`Customizing VCS Release Notes ` - customize diff --git a/docs/migrating_from_v7.rst b/docs/migrating_from_v7.rst index d99c2c004..8db5afc35 100644 --- a/docs/migrating_from_v7.rst +++ b/docs/migrating_from_v7.rst @@ -176,7 +176,8 @@ To achieve a similar flow of logic such as 4. Push the changes to the metadata and changelog to the remote repository 5. Create a release in the remote version control system 6. Build a wheel - 7. Publish the wheel to PyPI and to the release in the remote VCS + 7. Publish the wheel to PyPI + 8. Publish the distribution artifacts to the release in the remote VCS You should run:: @@ -184,8 +185,9 @@ You should run:: twine upload dist/* # or whichever path your distributions are placed in semantic-release publish -With steps 1-5 being handled by the :ref:`cmd-version` command, and steps 6 and 7 -handled by the :ref:`cmd-publish` command. +With steps 1-6 being handled by the :ref:`cmd-version` command, step 7 being left +to the developer to handle, and lastly step 8 to be handled by the +:ref:`cmd-publish` command. .. _breaking-removed-define-option: diff --git a/pyproject.toml b/pyproject.toml index 8089115b6..c562bc92c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,14 +6,13 @@ build-backend = "setuptools.build_meta" [project] name = "python-semantic-release" -version = "8.3.0" +version = "9.3.1" description = "Automatic Semantic Versioning for Python projects" -requires-python = ">=3.7" +requires-python = ">=3.8" license = { text = "MIT" } classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -24,11 +23,12 @@ readme = "README.rst" authors = [{ name = "Rolf Erik Lekang", email = "me@rolflekang.com" }] dependencies = [ "click>=8,<9", + "click-option-group~=0.5", "gitpython>=3.0.8,<4", "requests>=2.25,<3", "jinja2>=3.1.2,<4", - "python-gitlab>=2,<4", - "tomlkit~=0.10", + "python-gitlab>=2,<5", + "tomlkit~=0.11", "dotty-dict>=1.3.0,<2", "importlib-resources>=5.7,<7", "pydantic>=2,<3", @@ -57,6 +57,7 @@ docs = [ test = [ "coverage[toml]>=6,<8", "pytest>=7,<8", + "pytest-env~=1.0", "pytest-xdist>=2,<4", "pytest-mock>=3,<4", "pytest-lazy-fixture~=0.6.3", @@ -67,13 +68,17 @@ test = [ "requests-mock>=1.10.0,<2", "types-pytest-lazy-fixture>=0.6.3.3", ] -dev = ["pre-commit", "tox", "black", "ruff==0.1.1"] +dev = ["pre-commit", "tox", "ruff==0.3.3"] mypy = ["mypy", "types-requests"] [tool.pytest.ini_options] +env = [ + "PYTHONHASHSEED = 123456" +] addopts = [ "-nauto", "-ra", + "--diff-symbols", "--cache-clear", "--cov=semantic_release", "--cov-context=test", @@ -98,7 +103,7 @@ legacy_tox_ini = """ [tox] envlist = mypy, - py{37,38,39,310,311,312}, + py{38,39,310,311,312}, coverage ruff skipsdist = True @@ -108,7 +113,6 @@ passenv = CI setenv = PYTHONPATH = {toxinidir} TESTING = True - PYTHONHASHSEED = 123456 deps = .[test] commands = coverage run -p --source=semantic_release -m pytest {posargs:tests} @@ -132,7 +136,7 @@ commands = """ [tool.mypy] -python_version = 3.7 +python_version = "3.8" packages = ["semantic_release"] show_column_numbers = true show_error_context = true @@ -160,26 +164,32 @@ allow_incomplete_defs = true allow_untyped_calls = true [tool.ruff] +line-length = 88 +target-version = "py38" +force-exclude = true +output-format = "grouped" +show-fixes = true +src = ["semantic_release", "tests"] + +[tool.ruff.lint] select = ["ALL"] -# See https://beta.ruff.rs/docs/rules +# See https://docs.astral.sh/ruff/rules/ +# for any of these codes you can also run `ruff rule [CODE]` +# which explains it in the terminal ignore = [ # attribute shadows builtin (e.g. Foo.list()) "A003", # Annotations (flake8-annotations) # missing "self" type-hint "ANN101", - # missing "cls" type-hint "ANN102", - # no typing.Any "ANN401", # flake8-bugbear - # no functools.lru_cache "B019", # flake8-commas "COM", - # Missing docstrings. These would be nice to enable - # but not done yet + # Missing docstrings - eventually want to enable "D100", "D101", "D102", @@ -188,17 +198,11 @@ ignore = [ "D105", "D107", - # class docstrings must be preceded by a newline "D203", - # one blank line between summary and description for docstrings "D205", - # multiline summary should start on first line of docstring "D212", - # first line should end with a period "D400", - # "an imperative mood" "D401", - # First word of docstring should be "This" "D404", "D415", # flake8-datetimez @@ -217,13 +221,14 @@ ignore = [ "INP001", # Errors should end with "Error" "N818", + # Fixtures that do not return a value need an underscore prefix. The rule + # does not handle generators. + "PT004", # flake8-pytest-style, values rowtype (list|tuple) "PT007", # pytest.raises needs a match - eventually want to enable "PT011", - # pytest.raises must be alone in a with-block "PT012", - # use only simple "import pytest" "PT013", # pylint "PLR", @@ -243,20 +248,36 @@ ignore = [ # tryceratops "TRY003", "TRY401", + + # other errors that conflict with ruff format + # indentation-with-invalid-multiple + "W191", + "E111", + "E114", + "E117", + "E501", + "D206", + "D300", + "Q000", + "Q001", + "Q002", + "Q003", + "COM812", + "COM812", + "ISC001", + "ISC002", ] external = ["V"] -target-version = "py37" -force-exclude = true -line-length = 88 -output-format = "grouped" ignore-init-module-imports = true -show-source = true -show-fixes = true -src = ["semantic_release", "tests"] task-tags = ["NOTE", "TODO", "FIXME", "XXX"] -[tool.ruff.per-file-ignores] +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" + +[tool.ruff.lint.per-file-ignores] # Imported but unused "__init__.py" = ["F401"] # pydantic 1 can't handle __future__ annotations-enabled syntax on < 3.10 @@ -267,7 +288,7 @@ task-tags = ["NOTE", "TODO", "FIXME", "XXX"] "semantic_release/hvcs/_base.py" = ["ARG002"] # from tests.fixtures import * is deliberate "tests/conftest.py" = ["F403"] -"tests/fixtures/__init__.py" = ["F403"] +"tests/fixtures/**/__init__.py" = ["F403"] "tests/*" = [ # unused arguments - likely fixtures to be moved to @@ -288,26 +309,33 @@ task-tags = ["NOTE", "TODO", "FIXME", "XXX"] "SLF001", # Annotations "ANN", + # Using format instead of f-string for readablity + "UP032" ] -[tool.ruff.mccabe] + +[tool.ruff.lint.mccabe] max-complexity = 10 -[tool.ruff.flake8-implicit-str-concat] +[tool.ruff.lint.flake8-implicit-str-concat] allow-multiline = true -[tool.ruff.flake8-tidy-imports] +[tool.ruff.lint.flake8-quotes] +inline-quotes = "double" +multiline-quotes = "double" + +[tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = "all" -[tool.ruff.flake8-type-checking] +[tool.ruff.lint.flake8-type-checking] strict = true -[tool.ruff.flake8-pytest-style] +[tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false mark-parentheses = false parametrize-names-type = "csv" -[tool.ruff.isort] +[tool.ruff.lint.isort] # required-imports = ["from __future__ import annotations"] combine-as-imports = true known-first-party = ["semantic_release"] @@ -323,6 +351,9 @@ section-order = [ ] sections = { "tests" = ["tests"] } +[tool.vulture] +ignore_names = ["change_to_ex_proj_dir", "init_example_project"] + [tool.semantic_release] logging_use_named_masks = true tag_format = "v{version}" diff --git a/semantic_release/__init__.py b/semantic_release/__init__.py index 04e9f9c22..29ae9f614 100644 --- a/semantic_release/__init__.py +++ b/semantic_release/__init__.py @@ -1,4 +1,5 @@ """Python Semantic Release""" + from __future__ import annotations from semantic_release.commit_parser import ( @@ -23,7 +24,7 @@ tags_and_versions, ) -__version__ = "8.3.0" +__version__ = "9.3.1" def setup_hook(argv: list[str]) -> None: diff --git a/semantic_release/changelog/release_history.py b/semantic_release/changelog/release_history.py index 483d2117e..8d23e8bcd 100644 --- a/semantic_release/changelog/release_history.py +++ b/semantic_release/changelog/release_history.py @@ -3,20 +3,16 @@ import logging from collections import defaultdict from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Iterable, Iterator +from typing import TYPE_CHECKING, TypedDict from git.objects.tag import TagObject -# For Python3.7 compatibility -from typing_extensions import TypedDict - -from semantic_release.commit_parser import ( - ParseError, -) +from semantic_release.commit_parser import ParseError from semantic_release.version.algorithm import tags_and_versions if TYPE_CHECKING: - import re + from re import Pattern + from typing import Iterable, Iterator from git.repo.base import Repo from git.util import Actor @@ -39,7 +35,7 @@ def from_git_history( repo: Repo, translator: VersionTranslator, commit_parser: CommitParser[ParseResult, ParserOptions], - exclude_commit_patterns: Iterable[re.Pattern[str]] = (), + exclude_commit_patterns: Iterable[Pattern[str]] = (), ) -> ReleaseHistory: all_git_tags_and_versions = tags_and_versions(repo.tags, translator) unreleased: dict[str, list[ParseResult]] = defaultdict(list) @@ -81,7 +77,9 @@ def from_git_history( if isinstance(tag.object, TagObject): tagger = tag.object.tagger committer = tag.object.tagger.committer() - _tz = timezone(timedelta(seconds=tag.object.tagger_tz_offset)) + _tz = timezone( + timedelta(seconds=-1 * tag.object.tagger_tz_offset) + ) tagged_date = datetime.fromtimestamp( tag.object.tagged_date, tz=_tz ) @@ -89,7 +87,9 @@ def from_git_history( # For some reason, sometimes tag.object is a Commit tagger = tag.object.author committer = tag.object.author - _tz = timezone(timedelta(seconds=tag.object.author_tz_offset)) + _tz = timezone( + timedelta(seconds=-1 * tag.object.author_tz_offset) + ) tagged_date = datetime.fromtimestamp( tag.object.committed_date, tz=_tz ) diff --git a/semantic_release/changelog/template.py b/semantic_release/changelog/template.py index 3a2608ba7..cefc9accd 100644 --- a/semantic_release/changelog/template.py +++ b/semantic_release/changelog/template.py @@ -12,8 +12,10 @@ from semantic_release.helpers import dynamic_import if TYPE_CHECKING: + from typing import Literal + from jinja2 import Environment - from typing_extensions import Literal + log = logging.getLogger(__name__) @@ -81,22 +83,28 @@ def recursive_render( ) -> list[str]: rendered_paths: list[str] = [] for root, file in ( - (root, file) + (Path(root), file) for root, _, files in os.walk(template_dir) for file in files - if not any(elem.startswith(".") for elem in root.split(os.sep)) + # we slice relpath.parts[1:] to allow the top-level + # template folder to have a dot prefix. + # e.g. to permit ".github/psr-templates" to contain the templates, + # rather than enforcing a top-level, non-hidden directory + if not any( + elem.startswith(".") + for elem in Path(root).relative_to(template_dir).parts[1:] + ) and not file.startswith(".") ): - src_path = Path(root) - output_path = (_root_dir / src_path.relative_to(template_dir)).resolve() - log.info("Rendering templates from %s to %s", src_path, output_path) - os.makedirs(str(output_path), exist_ok=True) + output_path = (_root_dir / root.relative_to(template_dir)).resolve() + log.info("Rendering templates from %s to %s", root, output_path) + output_path.mkdir(parents=True, exist_ok=True) if file.endswith(".j2"): # We know the file ends with .j2 by the filter in the for-loop output_filename = file[:-3] # Strip off the template directory from the front of the root path - # that's the output location relative to the repo root - src_file_path = str((src_path / file).relative_to(template_dir)) + src_file_path = str((root / file).relative_to(template_dir)) output_file_path = str((output_path / output_filename).resolve()) log.debug("rendering %s to %s", src_file_path, output_file_path) @@ -107,7 +115,7 @@ def recursive_render( rendered_paths.append(output_file_path) else: - src_file = str((src_path / file).resolve()) + src_file = str((root / file).resolve()) target_file = str((output_path / file).resolve()) log.debug( "source file %s is not a template, copying to %s", src_file, target_file diff --git a/semantic_release/cli/commands/changelog.py b/semantic_release/cli/commands/changelog.py index 8623219dd..b2d13918c 100644 --- a/semantic_release/cli/commands/changelog.py +++ b/semantic_release/cli/commands/changelog.py @@ -16,7 +16,7 @@ from semantic_release.cli.util import noop_report if TYPE_CHECKING: - from semantic_release.cli.config import RuntimeContext + from semantic_release.cli.commands.cli_context import CliContextObj from semantic_release.version import Version log = logging.getLogger(__name__) @@ -34,10 +34,11 @@ default=None, help="Post the generated release notes to the remote VCS's release for this tag", ) -@click.pass_context -def changelog(ctx: click.Context, release_tag: str | None = None) -> None: +@click.pass_obj +def changelog(cli_ctx: CliContextObj, release_tag: str | None = None) -> None: """Generate and optionally publish a changelog for your project""" - runtime: RuntimeContext = ctx.obj + ctx = click.get_current_context() + runtime = cli_ctx.runtime_ctx repo = runtime.repo parser = runtime.commit_parser translator = runtime.version_translator @@ -67,7 +68,7 @@ def changelog(ctx: click.Context, release_tag: str | None = None) -> None: ) else: changelog_text = render_default_changelog_file(env) - changelog_file.write_text(changelog_text, encoding="utf-8") + changelog_file.write_text(f"{changelog_text}\n", encoding="utf-8") else: if runtime.global_cli_options.noop: @@ -112,7 +113,7 @@ def changelog(ctx: click.Context, release_tag: str | None = None) -> None: else: try: hvcs_client.create_or_update_release( - release_tag, release_notes, prerelease=version.is_prerelease + release_tag, f"{release_notes}\n", prerelease=version.is_prerelease ) except Exception as e: log.exception(e) diff --git a/semantic_release/cli/commands/cli_context.py b/semantic_release/cli/commands/cli_context.py new file mode 100644 index 000000000..7f71dc73b --- /dev/null +++ b/semantic_release/cli/commands/cli_context.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import click +from click.core import ParameterSource +from git import InvalidGitRepositoryError +from pydantic import ValidationError + +from semantic_release.cli.config import ( + RawConfig, + RuntimeContext, +) +from semantic_release.cli.util import load_raw_config_file, rprint +from semantic_release.errors import InvalidConfiguration, NotAReleaseBranch + +if TYPE_CHECKING: + from semantic_release.cli.config import GlobalCommandLineOptions + + class CliContext(click.Context): + obj: CliContextObj + + +class CliContextObj: + def __init__( + self, + ctx: click.Context, + logger: logging.Logger, + global_opts: GlobalCommandLineOptions, + ) -> None: + self._runtime_ctx: RuntimeContext | None = None + self.ctx = ctx + self.logger = logger + self.global_opts = global_opts + + @property + def runtime_ctx(self) -> RuntimeContext: + """ + Lazy load the runtime context. This is done to avoid configuration loading when + the command is not run. This is useful for commands like `--help` and `--version` + """ + if self._runtime_ctx is None: + self._runtime_ctx = self._init_runtime_ctx() + return self._runtime_ctx + + def _init_runtime_ctx(self) -> RuntimeContext: + config_path = Path(self.global_opts.config_file) + conf_file_exists = config_path.exists() + was_conf_file_user_provided = bool( + self.ctx.get_parameter_source("config_file") + not in ( + ParameterSource.DEFAULT, + ParameterSource.DEFAULT_MAP, + ) + ) + + try: + if was_conf_file_user_provided and not conf_file_exists: + raise FileNotFoundError( + f"File {self.global_opts.config_file} does not exist" + ) + + config_obj = ( + {} if not conf_file_exists else load_raw_config_file(config_path) + ) + if not config_obj: + self.logger.info( + "configuration empty, falling back to default configuration" + ) + + raw_config = RawConfig.model_validate(config_obj) + runtime = RuntimeContext.from_raw_config( + raw_config, + global_cli_options=self.global_opts, + ) + except NotAReleaseBranch as exc: + rprint(f"[bold {'red' if self.global_opts.strict else 'orange1'}]{exc!s}") + # If not strict, exit 0 so other processes can continue. For example, in + # multibranch CI it might be desirable to run a non-release branch's pipeline + # without specifying conditional execution of PSR based on branch name + self.ctx.exit(2 if self.global_opts.strict else 0) + except FileNotFoundError as exc: + click.echo(str(exc), err=True) + self.ctx.exit(2) + except ( + ValidationError, + InvalidConfiguration, + InvalidGitRepositoryError, + ) as exc: + click.echo(str(exc), err=True) + self.ctx.exit(1) + + # This allows us to mask secrets in the logging + # by applying it to all the configured handlers + for handler in logging.getLogger().handlers: + handler.addFilter(runtime.masker) + + return runtime diff --git a/semantic_release/cli/commands/generate_config.py b/semantic_release/cli/commands/generate_config.py index 296d87e75..a6bf36013 100644 --- a/semantic_release/cli/commands/generate_config.py +++ b/semantic_release/cli/commands/generate_config.py @@ -38,9 +38,11 @@ def generate_config(fmt: str = "toml", is_pyproject_toml: bool = False) -> None: your needs. For example, to append the default configuration to your pyproject.toml file, you can use the following command: - semantic-release generate-config -f toml >> pyproject.toml + semantic-release generate-config --pyproject >> pyproject.toml """ - config = RawConfig().model_dump(exclude_none=True) + # 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 = RawConfig().model_dump(mode="json", exclude_none=True) config_dct = {"semantic_release": config} if is_pyproject_toml and fmt == "toml": diff --git a/semantic_release/cli/commands/main.py b/semantic_release/cli/commands/main.py index 65c0af552..90d1b5457 100644 --- a/semantic_release/cli/commands/main.py +++ b/semantic_release/cli/commands/main.py @@ -1,25 +1,21 @@ from __future__ import annotations import logging -from pathlib import Path +# from typing import TYPE_CHECKING import click -from click.core import ParameterSource -from git import InvalidGitRepositoryError -from git.repo.base import Repo from rich.console import Console from rich.logging import RichHandler import semantic_release -from semantic_release.cli.commands.generate_config import generate_config -from semantic_release.cli.config import ( - GlobalCommandLineOptions, - RawConfig, - RuntimeContext, -) +from semantic_release.cli.commands.cli_context import CliContextObj +from semantic_release.cli.config import GlobalCommandLineOptions from semantic_release.cli.const import DEFAULT_CONFIG_FILE -from semantic_release.cli.util import load_raw_config_file, rprint -from semantic_release.errors import InvalidConfiguration, NotAReleaseBranch +from semantic_release.cli.util import rprint + +# if TYPE_CHECKING: +# pass + FORMAT = "[%(name)s] %(levelname)s %(module)s.%(funcName)s: %(message)s" @@ -93,19 +89,8 @@ def main( ], ) - log = logging.getLogger(__name__) - - if ctx.invoked_subcommand == generate_config.name: - # generate-config doesn't require any of the usual setup, - # so exit out early and delegate to it - log.debug("Forwarding to %s", generate_config.name) - return - - log.debug("logging level set to: %s", logging.getLevelName(log_level)) - try: - repo = Repo(".", search_parent_directories=True) - except InvalidGitRepositoryError: - ctx.fail("Not in a valid Git repository") + logger = logging.getLogger(__name__) + logger.debug("logging level set to: %s", logging.getLevelName(log_level)) if noop: rprint( @@ -114,50 +99,9 @@ def main( ) cli_options = GlobalCommandLineOptions( - noop=noop, - verbosity=verbosity, - config_file=config_file, - strict=strict, + noop=noop, verbosity=verbosity, config_file=config_file, strict=strict ) - log.debug("global cli options: %s", cli_options) - - config_path = Path(config_file) - # default no config loaded - config_text = {} - if not config_path.exists(): - if ctx.get_parameter_source("config_file") not in ( - ParameterSource.DEFAULT, - ParameterSource.DEFAULT_MAP, - ): - ctx.fail(f"File {config_file} does not exist") - - log.info( - "configuration file %s not found, using default configuration", - config_file, - ) - else: - try: - config_text = load_raw_config_file(config_path) - except InvalidConfiguration as exc: - ctx.fail(str(exc)) + logger.debug("global cli options: %s", cli_options) - raw_config = RawConfig.model_validate(config_text) - try: - runtime = RuntimeContext.from_raw_config( - raw_config, repo=repo, global_cli_options=cli_options - ) - except NotAReleaseBranch as exc: - rprint(f"[bold {'red' if strict else 'orange1'}]{exc!s}") - # If not strict, exit 0 so other processes can continue. For example, in - # multibranch CI it might be desirable to run a non-release branch's pipeline - # without specifying conditional execution of PSR based on branch name - ctx.exit(2 if strict else 0) - except InvalidConfiguration as exc: - ctx.fail(str(exc)) - ctx.obj = runtime - - # This allows us to mask secrets in the logging - # by applying it to all the configured handlers - for handler in logging.getLogger().handlers: - handler.addFilter(runtime.masker) + ctx.obj = CliContextObj(ctx, logger, cli_options) diff --git a/semantic_release/cli/commands/publish.py b/semantic_release/cli/commands/publish.py index 380946302..50a9478df 100644 --- a/semantic_release/cli/commands/publish.py +++ b/semantic_release/cli/commands/publish.py @@ -1,10 +1,17 @@ +from __future__ import annotations + import logging +from typing import TYPE_CHECKING import click from semantic_release.cli.util import noop_report from semantic_release.version import tags_and_versions +if TYPE_CHECKING: + from semantic_release.cli.commands.cli_context import CliContextObj + + log = logging.getLogger(__name__) @@ -20,10 +27,11 @@ help="The tag associated with the release to publish to", default="latest", ) -@click.pass_context -def publish(ctx: click.Context, tag: str = "latest") -> None: +@click.pass_obj +def publish(cli_ctx: CliContextObj, tag: str = "latest") -> None: """Build and publish a distribution to a VCS release.""" - runtime = ctx.obj + ctx = click.get_current_context() + runtime = cli_ctx.runtime_ctx repo = runtime.repo hvcs_client = runtime.hvcs_client translator = runtime.version_translator diff --git a/semantic_release/cli/commands/version.py b/semantic_release/cli/commands/version.py index f9bca7acc..2fbaad790 100644 --- a/semantic_release/cli/commands/version.py +++ b/semantic_release/cli/commands/version.py @@ -5,10 +5,12 @@ import subprocess from contextlib import nullcontext from datetime import datetime -from typing import TYPE_CHECKING, ContextManager, Iterable +from typing import TYPE_CHECKING import click import shellingham # type: ignore[import] +from click_option_group import MutuallyExclusiveOptionGroup, optgroup +from git.exc import GitCommandError from semantic_release.changelog import ReleaseHistory, environment, recursive_render from semantic_release.changelog.context import make_changelog_context @@ -25,10 +27,13 @@ log = logging.getLogger(__name__) -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover + from typing import ContextManager, Iterable + from git import Repo + from git.refs.tag import Tag - from semantic_release.cli.config import RuntimeContext + from semantic_release.cli.commands.cli_context import CliContextObj from semantic_release.version import VersionTranslator from semantic_release.version.declaration import VersionDeclarationABC @@ -47,6 +52,13 @@ def is_forced_prerelease( return force_prerelease or ((force_level is None) and prerelease) +def last_released( + repo: Repo, translator: VersionTranslator +) -> tuple[Tag, Version] | None: + ts_and_vs = tags_and_versions(repo.tags, translator) + return ts_and_vs[0] if ts_and_vs else None + + def version_from_forced_level( repo: Repo, level_bump: LevelBump, translator: VersionTranslator ) -> Version: @@ -110,9 +122,26 @@ def shell(cmd: str, *, check: bool = True) -> subprocess.CompletedProcess: "help_option_names": ["-h", "--help"], }, ) -@click.option( +@optgroup.group("Print flags", cls=MutuallyExclusiveOptionGroup) +@optgroup.option( "--print", "print_only", is_flag=True, help="Print the next version and exit" ) +@optgroup.option( + "--print-tag", + "print_only_tag", + is_flag=True, + help="Print the next version tag and exit", +) +@optgroup.option( + "--print-last-released", + is_flag=True, + help="Print the last released version and exit", +) +@optgroup.option( + "--print-last-released-tag", + is_flag=True, + help="Print the last released version tag and exit", +) @click.option( "--prerelease", "force_prerelease", @@ -149,6 +178,12 @@ def shell(cmd: str, *, check: bool = True) -> subprocess.CompletedProcess: default=True, help="Whether or not to commit changes locally", ) +@click.option( + "--tag/--no-tag", + "create_tag", + default=True, + help="Whether or not to create a tag for the new version", +) @click.option( "--changelog/--no-changelog", "update_changelog", @@ -180,39 +215,60 @@ def shell(cmd: str, *, check: bool = True) -> subprocess.CompletedProcess: is_flag=True, help="Skip building the current project", ) -@click.pass_context +@click.pass_obj def version( # noqa: C901 - ctx: click.Context, + cli_ctx: CliContextObj, print_only: bool = False, + print_only_tag: bool = False, + print_last_released: bool = False, + print_last_released_tag: bool = False, force_prerelease: bool = False, prerelease_token: str | None = None, force_level: str | None = None, commit_changes: bool = True, + create_tag: bool = True, update_changelog: bool = True, push_changes: bool = True, make_vcs_release: bool = True, build_metadata: str | None = None, skip_build: bool = False, ) -> str: - r""" + """ Detect the semantically correct next version that should be applied to your project. - \b By default: - * Write this new version to the project metadata locations - specified in the configuration file - * Create a new commit with these locations and any other assets configured - to be included in a release - * Tag this commit according the configured format, with a tag that uniquely - identifies the version being released. - * Push the new tag and commit to the remote for the repository - * Create a release (if supported) in the remote VCS for this tag + + * Write this new version to the project metadata locations specified + in the configuration file + + * Create a new commit with these locations and any other assets configured + to be included in a release + + * Tag this commit according the configured format, with a tag that uniquely + identifies the version being released. + + * Push the new tag and commit to the remote for the repository + + * Create a release (if supported) in the remote VCS for this tag """ - runtime: RuntimeContext = ctx.obj + ctx = click.get_current_context() + runtime = cli_ctx.runtime_ctx repo = runtime.repo - parser = runtime.commit_parser translator = runtime.version_translator + + # We can short circuit updating the release if we are only printing the last released version + if print_last_released or print_last_released_tag: + if last_release := last_released(repo, translator): + if print_last_released: + click.echo(last_release[1]) + if print_last_released_tag: + click.echo(last_release[0]) + else: + log.warning("No release tags found.") + ctx.exit(0) + + parser = runtime.commit_parser prerelease = is_forced_prerelease( force_prerelease=force_prerelease, force_level=force_level, @@ -236,9 +292,13 @@ def version( # noqa: C901 translator.prerelease_token = prerelease_token # Only push if we're committing changes - if push_changes and not commit_changes: + if push_changes and not commit_changes and not create_tag: log.info("changes will not be pushed because --no-commit disables pushing") push_changes &= commit_changes + # Only push if we're creating a tag + if push_changes and not create_tag and not commit_changes: + log.info("new tag will not be pushed because --no-tag disables pushing") + push_changes &= create_tag # Only make a release if we're pushing the changes if make_vcs_release and not push_changes: log.info("No vcs release will be created because pushing changes is disabled") @@ -279,6 +339,7 @@ def version( # noqa: C901 commit_parser=parser, prerelease=prerelease, major_on_zero=major_on_zero, + allow_zero_version=runtime.allow_zero_version, ) if build_metadata: @@ -289,7 +350,10 @@ def version( # noqa: C901 ctx.call_on_close(gha_output.write_if_possible) # Print the new version so that command-line output capture will work - click.echo(str(new_version)) + if print_only_tag: + click.echo(translator.str_to_tag(str(new_version))) + else: + click.echo(str(new_version)) # If the new version has already been released, we fail and abort if strict; # otherwise we exit with 0. @@ -306,60 +370,11 @@ def version( # noqa: C901 ) ctx.exit(0) - if print_only: + if print_only or print_only_tag: ctx.exit(0) rprint(f"[bold green]The next version is: [white]{new_version!s}[/white]! :rocket:") - files_with_new_version_written = apply_version_to_source_files( - repo=repo, - version_declarations=runtime.version_declarations, - version=new_version, - noop=opts.noop, - ) - all_paths_to_add = files_with_new_version_written + (assets or []) - - # Build distributions before committing any changes - this way if the - # build fails, modifications to the source code won't be committed - if skip_build: - rprint("[bold orange1]Skipping build due to --skip-build flag") - elif not build_command: - rprint("[green]No build command specified, skipping") - elif runtime.global_cli_options.noop: - noop_report(f"would have run the build_command {build_command}") - else: - try: - log.info("Running build command %s", build_command) - rprint( - "[bold green]:hammer_and_wrench: Running build command: " - + build_command - ) - shell(build_command, check=True) - except subprocess.CalledProcessError as exc: - ctx.fail(str(exc)) - - # Commit changes - if commit_changes and opts.noop: - # Indents the newlines so that terminal formatting is happy - note the - # git commit line of the output is 24 spaces indented too - # Only this message needs such special handling because of the newlines - # that might be in a commit message between the subject and body - indented_commit_message = commit_message.format(version=new_version).replace( - "\n\n", "\n\n" + " " * 24 - ) - noop_report( - indented( - f""" - would have run: - git add {" ".join(all_paths_to_add)} - git commit -m "{indented_commit_message}" - """ - ) - ) - - elif commit_changes: - repo.git.add(all_paths_to_add) - rh = ReleaseHistory.from_git_history( repo=repo, translator=translator, @@ -378,13 +393,14 @@ def version( # noqa: C901 except ValueError as ve: ctx.fail(str(ve)) - changelog_context = make_changelog_context( - hvcs_client=hvcs_client, release_history=rh - ) - changelog_context.bind_to_environment(env) + all_paths_to_add: list[str] = [] - updated_paths: list[str] = [] if update_changelog: + changelog_context = make_changelog_context( + hvcs_client=hvcs_client, release_history=rh + ) + changelog_context.bind_to_environment(env) + if template_dir.is_dir(): if opts.noop: noop_report( @@ -394,8 +410,10 @@ def version( # noqa: C901 "determined in no-op mode." ) else: - updated_paths = recursive_render( - template_dir, environment=env, _root_dir=repo.working_dir + all_paths_to_add.extend( + recursive_render( + template_dir, environment=env, _root_dir=repo.working_dir + ) ) else: @@ -409,22 +427,60 @@ def version( # noqa: C901 ) else: changelog_text = render_default_changelog_file(env) - changelog_file.write_text(changelog_text, encoding="utf-8") + changelog_file.write_text(f"{changelog_text}\n", encoding="utf-8") - updated_paths = [str(changelog_file.relative_to(repo.working_dir))] + all_paths_to_add.append(str(changelog_file.relative_to(repo.working_dir))) - if commit_changes and opts.noop: - noop_report( - indented( - f""" - would have run: - git add {" ".join(updated_paths)} - """ - ) + # Apply the new version to the source files + files_with_new_version_written = apply_version_to_source_files( + repo=repo, + version_declarations=runtime.version_declarations, + version=new_version, + noop=opts.noop, + ) + all_paths_to_add.extend(files_with_new_version_written) + all_paths_to_add.extend(assets or []) + + # Build distributions before committing any changes - this way if the + # build fails, modifications to the source code won't be committed + if skip_build: + rprint("[bold orange1]Skipping build due to --skip-build flag") + elif not build_command: + rprint("[green]No build command specified, skipping") + elif runtime.global_cli_options.noop: + noop_report(f"would have run the build_command {build_command}") + else: + try: + log.info("Running build command %s", build_command) + rprint( + "[bold green]:hammer_and_wrench: Running build command: " + + build_command ) - elif commit_changes: - # Anything changed here should be staged. - repo.git.add(updated_paths) + shell(build_command, check=True) + except subprocess.CalledProcessError as exc: + ctx.fail(str(exc)) + + # Commit changes + if commit_changes and opts.noop: + noop_report( + indented( + f""" + would have run: + git add {" ".join(all_paths_to_add)} + """ + ) + ) + + elif commit_changes: + # TODO: in future this loop should be 1 line: + # repo.index.add(all_paths_to_add, force=False) # noqa: ERA001 + # but since 'force' is deliberally ineffective (as in docstring) in gitpython 3.1.18 + # we have to do manually add each filepath, and catch the exception if it is an ignored file + for updated_path in all_paths_to_add: + try: + repo.git.add(updated_path) + except GitCommandError: # noqa: PERF203 + log.warning("Failed to add path (%s) to index", updated_path) def custom_git_environment() -> ContextManager[None]: """ @@ -461,7 +517,16 @@ def custom_git_environment() -> ContextManager[None]: if commit_author else "" ) - command += "git commit -m '{commit_message.format(version=v)}'" + + # Indents the newlines so that terminal formatting is happy - note the + # git commit line of the output is 24 spaces indented too + # Only this message needs such special handling because of the newlines + # that might be in a commit message between the subject and body + indented_commit_message = commit_message.format(version=new_version).replace( + "\n\n", "\n\n" + " " * 24 + ) + + command += f"git commit -m '{indented_commit_message}'" noop_report( indented( @@ -484,37 +549,53 @@ def custom_git_environment() -> ContextManager[None]: # are disabled, and the changelog generation is disabled or it's not # modified, then the HEAD commit will be tagged as a release commit # despite not being made by PSR - if commit_changes and opts.noop: - noop_report( - indented( - f""" - would have run: - git tag -a {new_version.as_tag()} -m "{new_version.as_tag()}" - """ + if commit_changes or create_tag: + if opts.noop: + noop_report( + indented( + f""" + would have run: + git tag -a {new_version.as_tag()} -m "{new_version.as_tag()}" + """ + ) ) - ) - elif commit_changes: - with custom_git_environment(): - repo.git.tag("-a", new_version.as_tag(), m=new_version.as_tag()) + else: + with custom_git_environment(): + repo.git.tag("-a", new_version.as_tag(), m=new_version.as_tag()) if push_changes: remote_url = runtime.hvcs_client.remote_url( use_token=not runtime.ignore_token_for_push ) active_branch = repo.active_branch.name - if opts.noop: + if commit_changes and opts.noop: noop_report( indented( f""" would have run: git push {runtime.masker.mask(remote_url)} {active_branch} - git push --tags {runtime.masker.mask(remote_url)} {active_branch} """ # noqa: E501 ) ) - else: + elif commit_changes: repo.git.push(remote_url, active_branch) - repo.git.push("--tags", remote_url, active_branch) + + if create_tag and opts.noop: + noop_report( + indented( + f""" + would have run: + git push {runtime.masker.mask(remote_url)} tag {new_version.as_tag()} + """ # noqa: E501 + ) + ) + elif create_tag: + # push specific tag refspec (that we made) to remote + # --------------- + # Resolves issue #803 where a tag that already existed was pushed and caused + # a failure. Its not clear why there was an incorrect tag (likely user error change) + # but we will avoid possibly pushing an separate tag that we didn't create. + repo.git.push(remote_url, "tag", new_version.as_tag()) gha_output.released = True @@ -528,6 +609,9 @@ def custom_git_environment() -> ContextManager[None]: # Use a new, non-configurable environment for release notes - # not user-configurable at the moment release_note_environment = environment(template_dir=runtime.template_dir) + changelog_context = make_changelog_context( + hvcs_client=hvcs_client, release_history=rh + ) changelog_context.bind_to_environment(release_note_environment) template = get_release_notes_template(template_dir) diff --git a/semantic_release/cli/common.py b/semantic_release/cli/common.py index 10b285ba9..d6dbc98ee 100644 --- a/semantic_release/cli/common.py +++ b/semantic_release/cli/common.py @@ -34,7 +34,7 @@ def render_default_changelog_file(template_environment: Environment) -> str: .read_text(encoding="utf-8") ) tmpl = template_environment.from_string(changelog_text) - return tmpl.render() + return tmpl.render().rstrip() def render_release_notes( @@ -43,6 +43,8 @@ def render_release_notes( version: Version, release: Release, ) -> str: - return template_environment.from_string(release_notes_template).render( - version=version, release=release + return ( + template_environment.from_string(release_notes_template) + .render(version=version, release=release) + .rstrip() ) diff --git a/semantic_release/cli/config.py b/semantic_release/cli/config.py index 9fb34c8d3..890467e61 100644 --- a/semantic_release/cli/config.py +++ b/semantic_release/cli/config.py @@ -3,17 +3,21 @@ import logging import os import re -from dataclasses import dataclass +from collections.abc import Mapping +from dataclasses import dataclass, is_dataclass from enum import Enum from pathlib import Path -from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union +from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Type, Union -from git import Actor +from git import Actor, InvalidGitRepositoryError from git.repo.base import Repo from jinja2 import Environment -from pydantic import BaseModel -from typing_extensions import Literal +from pydantic import BaseModel, Field, RootModel, ValidationError, model_validator +# For Python 3.8, 3.9, 3.10 compatibility +from typing_extensions import Annotated, Self + +from semantic_release import hvcs from semantic_release.changelog import environment from semantic_release.cli.const import DEFAULT_CONFIG_FILE from semantic_release.cli.masking_filter import MaskingFilter @@ -29,7 +33,6 @@ from semantic_release.const import COMMIT_MESSAGE, DEFAULT_COMMIT_AUTHOR, SEMVER_REGEX from semantic_release.errors import InvalidConfiguration, NotAReleaseBranch from semantic_release.helpers import dynamic_import -from semantic_release.hvcs import Gitea, Github, Gitlab, HvcsBase from semantic_release.version import VersionTranslator from semantic_release.version.declaration import ( PatternVersionDeclaration, @@ -38,14 +41,32 @@ ) log = logging.getLogger(__name__) +NonEmptyString = Annotated[str, Field(..., min_length=1)] class HvcsClient(str, Enum): + BITBUCKET = "bitbucket" GITHUB = "github" GITLAB = "gitlab" GITEA = "gitea" +_known_commit_parsers: Dict[str, type[CommitParser]] = { + "angular": AngularCommitParser, + "emoji": EmojiCommitParser, + "scipy": ScipyCommitParser, + "tag": TagCommitParser, +} + + +_known_hvcs: Dict[HvcsClient, Type[hvcs.HvcsBase]] = { + HvcsClient.BITBUCKET: hvcs.Bitbucket, + HvcsClient.GITHUB: hvcs.Github, + HvcsClient.GITLAB: hvcs.Gitlab, + HvcsClient.GITEA: hvcs.Gitea, +} + + class EnvConfigVar(BaseModel): env: str default: Optional[str] = None @@ -90,13 +111,22 @@ class BranchConfig(BaseModel): class RemoteConfig(BaseModel): name: str = "origin" - token: MaybeFromEnv = EnvConfigVar(env="GH_TOKEN") + token: MaybeFromEnv = "" url: Optional[MaybeFromEnv] = None type: HvcsClient = HvcsClient.GITHUB domain: Optional[str] = None api_domain: Optional[str] = None ignore_token_for_push: bool = False + @model_validator(mode="after") + def set_default_token(self) -> Self: + # Set the default token name for the given VCS when no user input is given + if not self.token and self.type in _known_hvcs: + default_token_name = _known_hvcs[self.type].DEFAULT_ENV_TOKEN_NAME + if default_token_name: + self.token = EnvConfigVar(env=default_token_name) + return self + class PublishConfig(BaseModel): dist_glob_patterns: Tuple[str, ...] = ("dist/*",) @@ -112,32 +142,54 @@ class RawConfig(BaseModel): env="GIT_COMMIT_AUTHOR", default=DEFAULT_COMMIT_AUTHOR ) commit_message: str = COMMIT_MESSAGE - commit_parser: str = "angular" + commit_parser: NonEmptyString = "angular" # It's up to the parser_options() method to validate these - commit_parser_options: Dict[str, Any] = { - "allowed_tags": [ - "build", - "chore", - "ci", - "docs", - "feat", - "fix", - "perf", - "style", - "refactor", - "test", - ], - "minor_tags": ["feat"], - "patch_tags": ["fix", "perf"], - } + commit_parser_options: Dict[str, Any] = {} logging_use_named_masks: bool = False major_on_zero: bool = True + allow_zero_version: bool = True remote: RemoteConfig = RemoteConfig() tag_format: str = "v{version}" publish: PublishConfig = PublishConfig() version_toml: Optional[Tuple[str, ...]] = None version_variables: Optional[Tuple[str, ...]] = None + @model_validator(mode="after") + def set_default_opts(self) -> Self: + # Set the default parser options for the given commit parser when no user input is given + if not self.commit_parser_options and self.commit_parser: + parser_opts_type = None + # If the commit parser is a known one, pull the default options object from it + if self.commit_parser in _known_commit_parsers: + parser_opts_type = _known_commit_parsers[ + self.commit_parser + ].parser_options + else: + # if its a custom parser, try to import it and pull the default options object type + custom_class = dynamic_import(self.commit_parser) + if hasattr(custom_class, "parser_options"): + parser_opts_type = custom_class.parser_options + + # from either the custom opts class or the known parser opts class, create an instance + if callable(parser_opts_type): + opts_obj = parser_opts_type() + # if the opts object is a dataclass, wrap it in a RootModel so it can be transformed to a Mapping + opts_obj = ( + opts_obj if not is_dataclass(opts_obj) else RootModel(opts_obj) + ) + # Must be a mapping, so if it's a BaseModel, dump the model to a dict + self.commit_parser_options = ( + opts_obj.model_dump() + if isinstance(opts_obj, (BaseModel, RootModel)) + else opts_obj + ) + if not isinstance(self.commit_parser_options, Mapping): + raise ValidationError( + f"Invalid parser options: {opts_obj}. Must be a mapping." + ) + + return self + @dataclass class GlobalCommandLineOptions: @@ -172,20 +224,6 @@ def _recursive_getattr(obj: Any, path: str) -> Any: return out -_known_commit_parsers = { - "angular": AngularCommitParser, - "emoji": EmojiCommitParser, - "scipy": ScipyCommitParser, - "tag": TagCommitParser, -} - -_known_hvcs = { - HvcsClient.GITHUB: Github, - HvcsClient.GITLAB: Gitlab, - HvcsClient.GITEA: Gitea, -} - - @dataclass class RuntimeContext: _mask_attrs_: ClassVar[List[str]] = ["hvcs_client.token"] @@ -194,13 +232,14 @@ class RuntimeContext: commit_parser: CommitParser[ParseResult, ParserOptions] version_translator: VersionTranslator major_on_zero: bool + allow_zero_version: bool prerelease: bool assets: List[str] commit_author: Actor commit_message: str changelog_excluded_commit_patterns: Tuple[re.Pattern[str], ...] version_declarations: Tuple[VersionDeclarationABC, ...] - hvcs_client: HvcsBase + hvcs_client: hvcs.HvcsBase changelog_file: Path ignore_token_for_push: bool template_environment: Environment @@ -252,13 +291,26 @@ def apply_log_masking(self, masker: MaskingFilter) -> MaskingFilter: @classmethod def from_raw_config( - cls, raw: RawConfig, repo: Repo, global_cli_options: GlobalCommandLineOptions + cls, raw: RawConfig, global_cli_options: GlobalCommandLineOptions ) -> RuntimeContext: ## # credentials masking for logging masker = MaskingFilter(_use_named_masks=raw.logging_use_named_masks) + + try: + repo = Repo(".", search_parent_directories=True) + active_branch = repo.active_branch.name + except InvalidGitRepositoryError as err: + raise InvalidGitRepositoryError("No valid git repository found!") from err + except TypeError as err: + raise NotAReleaseBranch( + "Detached HEAD state cannot match any release groups; " + "no release will be made" + ) from err + # branch-specific configuration - branch_config = cls.select_branch_options(raw.branches, repo.active_branch.name) + branch_config = cls.select_branch_options(raw.branches, active_branch) + # commit_parser commit_parser_cls = ( _known_commit_parsers[raw.commit_parser] @@ -369,6 +421,7 @@ def from_raw_config( commit_parser=commit_parser, version_translator=version_translator, major_on_zero=raw.major_on_zero, + allow_zero_version=raw.allow_zero_version, build_command=raw.build_command, version_declarations=tuple(version_declarations), hvcs_client=hvcs_client, diff --git a/semantic_release/cli/util.py b/semantic_release/cli/util.py index 71acdef6e..c1c01b79c 100644 --- a/semantic_release/cli/util.py +++ b/semantic_release/cli/util.py @@ -1,4 +1,5 @@ """Utilities for command-line functionality""" + from __future__ import annotations import json diff --git a/semantic_release/commit_parser/_base.py b/semantic_release/commit_parser/_base.py index 9c08ff26c..c104ee579 100644 --- a/semantic_release/commit_parser/_base.py +++ b/semantic_release/commit_parser/_base.py @@ -9,7 +9,7 @@ from git.objects.commit import Commit -class ParserOptions: +class ParserOptions(dict): """ ParserOptions should accept the keyword arguments they are interested in from configuration and process them as desired, ultimately creating attributes @@ -71,5 +71,4 @@ def __init__(self, options: _OPTS) -> None: self.options = options @abstractmethod - def parse(self, commit: Commit) -> _TT: - ... + def parse(self, commit: Commit) -> _TT: ... diff --git a/semantic_release/commit_parser/angular.py b/semantic_release/commit_parser/angular.py index e4433ac9c..92a391d15 100644 --- a/semantic_release/commit_parser/angular.py +++ b/semantic_release/commit_parser/angular.py @@ -2,6 +2,7 @@ Angular commit style parser https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines """ + from __future__ import annotations import logging diff --git a/semantic_release/commit_parser/emoji.py b/semantic_release/commit_parser/emoji.py index 25d43f54b..f0ab4f218 100644 --- a/semantic_release/commit_parser/emoji.py +++ b/semantic_release/commit_parser/emoji.py @@ -1,4 +1,5 @@ """Commit parser which looks for emojis to determine the type of commit""" + import logging from typing import Tuple diff --git a/semantic_release/commit_parser/scipy.py b/semantic_release/commit_parser/scipy.py index 9cb51953e..0503cfa2b 100644 --- a/semantic_release/commit_parser/scipy.py +++ b/semantic_release/commit_parser/scipy.py @@ -34,6 +34,7 @@ .. _`scipy-style`: https://docs.scipy.org/doc/scipy/reference/dev/contributor/development_workflow.html#writing-the-commit-message """ + from __future__ import annotations import logging diff --git a/semantic_release/commit_parser/tag.py b/semantic_release/commit_parser/tag.py index b6e592539..dc210c5b5 100644 --- a/semantic_release/commit_parser/tag.py +++ b/semantic_release/commit_parser/tag.py @@ -1,4 +1,5 @@ """Legacy commit parser from Python Semantic Release 1.0""" + import logging import re diff --git a/semantic_release/commit_parser/util.py b/semantic_release/commit_parser/util.py index f93179bef..6ad7660aa 100644 --- a/semantic_release/commit_parser/util.py +++ b/semantic_release/commit_parser/util.py @@ -6,14 +6,22 @@ def parse_paragraphs(text: str) -> list[str]: - """ + r""" This will take a text block and return a list containing each paragraph with single line breaks collapsed into spaces. + + To handle Windows line endings, carriage returns '\r' are removed before + separating into paragraphs. + :param text: The text string to be divided. :return: A list of condensed paragraphs, as strings. """ - return [ - paragraph.replace("\n", " ") - for paragraph in text.split("\n\n") - if len(paragraph) > 0 - ] + return list( + filter( + None, + [ + paragraph.replace("\n", " ").strip() + for paragraph in text.replace("\r", "").split("\n\n") + ], + ) + ) diff --git a/semantic_release/errors.py b/semantic_release/errors.py index 7a02a17c5..36712ab55 100644 --- a/semantic_release/errors.py +++ b/semantic_release/errors.py @@ -31,3 +31,10 @@ class CommitParseError(SemanticReleaseBaseError): Raised when a commit cannot be parsed by a commit parser. Custom commit parsers should also raise this Exception """ + + +class MissingMergeBaseError(SemanticReleaseBaseError): + """ + Raised when the merge base cannot be found with the current history. Generally + because of a shallow git clone. + """ diff --git a/semantic_release/helpers.py b/semantic_release/helpers.py index 2780ffbb0..3725ddfa5 100644 --- a/semantic_release/helpers.py +++ b/semantic_release/helpers.py @@ -3,6 +3,7 @@ import re import string from functools import lru_cache, wraps +from pathlib import PurePosixPath from typing import Any, Callable, NamedTuple, TypeVar from urllib.parse import urlsplit @@ -73,7 +74,7 @@ def dynamic_import(import_path: str) -> Any: class ParsedGitUrl(NamedTuple): - """Container for the elements parsed from a git URL using GIT_URL_REGEX""" + """Container for the elements parsed from a git URL""" scheme: str netloc: str @@ -81,59 +82,85 @@ class ParsedGitUrl(NamedTuple): repo_name: str -GIT_URL_REGEX = re.compile( - r""" - ^ - (?P[\w\d\-.]+)@ - (?P[^:]+) - : - (?P[\w\.@\:/\-~]+) - / - (?P[\w\.\_\-]+) # Note this also catches the ".git" at the end if present - /? - $ - """, # noqa: E501 - flags=re.VERBOSE, -) - - @lru_cache(maxsize=512) def parse_git_url(url: str) -> ParsedGitUrl: """ - Attempt to parse a string as a git url, either https or ssh format, into a + Attempt to parse a string as a git url http[s]://, git://, file://, or ssh format, into a ParsedGitUrl. + + supported examples: + http://git.mycompany.com/username/myproject.git + https://github.com/username/myproject.git + https://gitlab.com/group/subgroup/myproject.git + https://git.mycompany.com:4443/username/myproject.git + git://host.xz/path/to/repo.git/ + git://host.xz:9418/path/to/repo.git/ + git@github.com:username/myproject.git <-- assumes ssh:// + ssh://git@github.com:3759/myproject.git <-- non-standard, but assume user 3759 + ssh://git@github.com:username/myproject.git + ssh://git@bitbucket.org:7999/username/myproject.git + git+ssh://git@github.com:username/myproject.git + /Users/username/dev/remote/myproject.git <-- Posix File paths + file:///Users/username/dev/remote/myproject.git + C:/Users/username/dev/remote/myproject.git <-- Windows File paths + file:///C:/Users/username/dev/remote/myproject.git + + REFERENCE: https://stackoverflow.com/questions/31801271/what-are-the-supported-git-url-formats + Raises ValueError if the url can't be parsed. """ log.debug("Parsing git url %r", url) + + # Normalizers are a list of tuples of (pattern, replacement) + normalizers = [ + # normalize implicit ssh urls to explicit ssh:// + (r"^([\w._-]+@)", r"ssh://\1"), + # normalize git+ssh:// urls to ssh:// + (r"^git\+ssh://", "ssh://"), + # normalize an scp like syntax to URL compatible syntax + # excluding port definitions (:#####) & including numeric usernames + (r"(ssh://(?:[\w._-]+@)?[\w.-]+):(?!\d{1,5}/\w+/)(.*)$", r"\1/\2"), + # normalize implicit file (windows || posix) urls to explicit file:// urls + (r"^([C-Z]:/)|^/(\w)", r"file:///\1\2"), + ] + + for pattern, replacement in normalizers: + url = re.compile(pattern).sub(replacement, url) + + # run the url through urlsplit to separate out the parts urllib_split = urlsplit(url) - if urllib_split.scheme: - # We have been able to parse the url with urlsplit, - # so it's a (git|ssh|https?)://... structure - namespace, _, name = urllib_split.path.lstrip("/").rpartition("/") - name.rstrip("/") - name = name[:-4] if name.endswith(".git") else name - if not all((urllib_split.scheme, urllib_split.netloc, namespace, name)): - raise ValueError(f"Bad url: {url!r}") - - return ParsedGitUrl( - scheme=urllib_split.scheme, - netloc=urllib_split.netloc, - namespace=namespace, - repo_name=name, - ) - m = GIT_URL_REGEX.match(url) - if not m: + # Fail if url scheme not found + if not urllib_split.scheme: raise ValueError(f"Cannot parse {url!r}") - repo_name = m.group("repo_name") - repo_name = repo_name[:-4] if repo_name.endswith(".git") else repo_name + # We have been able to parse the url with urlsplit, + # so it's a (file|git|ssh|https?)://... structure + # but we aren't validating the protocol scheme as its not our business - if not all((*m.group("netloc", "namespace"), repo_name)): + # use PosixPath to normalize the path & then separate out the namespace & repo_name + namespace, _, name = ( + str(PurePosixPath(urllib_split.path)).lstrip("/").rpartition("/") + ) + + # strip out the .git at the end of the repo_name if present + name = name[:-4] if name.endswith(".git") else name + + # check that we have all the required parts of the url + required_parts = [ + urllib_split.scheme, + # Allow empty net location for file:// urls + True if urllib_split.scheme == "file" else urllib_split.netloc, + namespace, + name, + ] + + if not all(required_parts): raise ValueError(f"Bad url: {url!r}") + return ParsedGitUrl( - scheme="ssh", - netloc=m.group("netloc"), - namespace=m.group("namespace"), - repo_name=repo_name, + scheme=urllib_split.scheme, + netloc=urllib_split.netloc, + namespace=namespace, + repo_name=name, ) diff --git a/semantic_release/hvcs/__init__.py b/semantic_release/hvcs/__init__.py index 067e7eb00..449d92e9f 100644 --- a/semantic_release/hvcs/__init__.py +++ b/semantic_release/hvcs/__init__.py @@ -1,5 +1,8 @@ from semantic_release.hvcs._base import HvcsBase +from semantic_release.hvcs.bitbucket import Bitbucket from semantic_release.hvcs.gitea import Gitea from semantic_release.hvcs.github import Github from semantic_release.hvcs.gitlab import Gitlab from semantic_release.hvcs.token_auth import TokenAuth + +__all__ = ["Bitbucket", "Gitea", "Github", "Gitlab", "HvcsBase", "TokenAuth"] diff --git a/semantic_release/hvcs/_base.py b/semantic_release/hvcs/_base.py index 206b463e9..c773e3570 100644 --- a/semantic_release/hvcs/_base.py +++ b/semantic_release/hvcs/_base.py @@ -1,4 +1,5 @@ """Common functionality and interface for interacting with Git remote VCS""" + from __future__ import annotations import logging @@ -33,6 +34,8 @@ class HvcsBase: checking for NotImplemented around every method call. """ + DEFAULT_ENV_TOKEN_NAME = "HVCS_TOKEN" # noqa: S105 + def __init__( self, remote_url: str, @@ -96,7 +99,7 @@ def create_release( def get_release_id_by_tag(self, tag: str) -> int | None: """ Given a Git tag, return the ID (as the remote VCS defines it) of a corresponding - release in the remove VCS, if supported + release in the remote VCS, if supported """ _not_supported(self, "get_release_id_by_tag") return None diff --git a/semantic_release/hvcs/bitbucket.py b/semantic_release/hvcs/bitbucket.py new file mode 100644 index 000000000..8b68fdcfc --- /dev/null +++ b/semantic_release/hvcs/bitbucket.py @@ -0,0 +1,114 @@ +"""Helper code for interacting with a Bitbucket remote VCS""" + +# Note: Bitbucket doesn't support releases. But it allows users to use +# `semantic-release version` without having to specify `--no-vcs-release`. + +from __future__ import annotations + +import logging +import mimetypes +import os +from functools import lru_cache + +from semantic_release.hvcs._base import HvcsBase +from semantic_release.hvcs.token_auth import TokenAuth +from semantic_release.hvcs.util import build_requests_session + +log = logging.getLogger(__name__) + +# Add a mime type for wheels +# Fix incorrect entries in the `mimetypes` registry. +# On Windows, the Python standard library's `mimetypes` reads in +# mappings from file extension to MIME type from the Windows +# registry. Other applications can and do write incorrect values +# to this registry, which causes `mimetypes.guess_type` to return +# incorrect values, which causes TensorBoard to fail to render on +# the frontend. +# This method hard-codes the correct mappings for certain MIME +# types that are known to be either used by python-semantic-release or +# problematic in general. +mimetypes.add_type("application/octet-stream", ".whl") +mimetypes.add_type("text/markdown", ".md") + + +class Bitbucket(HvcsBase): + """Bitbucket helper class""" + + API_VERSION = "2.0" + DEFAULT_DOMAIN = "bitbucket.org" + DEFAULT_API_DOMAIN = "api.bitbucket.org" + DEFAULT_ENV_TOKEN_NAME = "BITBUCKET_TOKEN" # noqa: S105 + + def __init__( + self, + remote_url: str, + hvcs_domain: str | None = None, + hvcs_api_domain: str | None = None, + token: str | None = None, + ) -> None: + self._remote_url = remote_url + self.hvcs_domain = hvcs_domain or self.DEFAULT_DOMAIN.replace("https://", "") + # ref: https://developer.atlassian.com/cloud/bitbucket/rest/intro/#uri-uuid + self.hvcs_api_domain = hvcs_api_domain or self.DEFAULT_API_DOMAIN.replace( + "https://", "" + ) + self.api_url = f"https://{self.hvcs_api_domain}/{self.API_VERSION}" + self.token = token + auth = None if not self.token else TokenAuth(self.token) + self.session = build_requests_session(auth=auth) + + @lru_cache(maxsize=1) + def _get_repository_owner_and_name(self) -> tuple[str, str]: + # ref: https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/ + if "BITBUCKET_REPO_FULL_NAME" in os.environ: + log.info("Getting repository owner and name from environment variables.") + owner, name = os.environ["BITBUCKET_REPO_FULL_NAME"].rsplit("/", 1) + return owner, name + return super()._get_repository_owner_and_name() + + def compare_url(self, from_rev: str, to_rev: str) -> str: + """ + Get the Bitbucket comparison link between two version tags. + :param from_rev: The older version to compare. + :param to_rev: The newer version to compare. + :return: Link to view a comparison between the two versions. + """ + return ( + f"https://{self.hvcs_domain}/{self.owner}/{self.repo_name}/" + f"branches/compare/{from_rev}%0D{to_rev}" + ) + + def remote_url(self, use_token: bool = True) -> str: + if not use_token: + # Note: Assume the user is using SSH. + return self._remote_url + if not self.token: + raise ValueError("Requested to use token but no token set.") + user = os.environ.get("BITBUCKET_USER") + if user: + # Note: If the user is set, assume the token is an app secret. This will work + # on any repository the user has access to. + # https://support.atlassian.com/bitbucket-cloud/docs/push-back-to-your-repository + return ( + f"https://{user}:{self.token}@" + f"{self.hvcs_domain}/{self.owner}/{self.repo_name}.git" + ) + # Note: Assume the token is a repository token which will only work on the + # repository it was created for. + # https://support.atlassian.com/bitbucket-cloud/docs/using-access-tokens + return ( + f"https://x-token-auth:{self.token}@" + f"{self.hvcs_domain}/{self.owner}/{self.repo_name}.git" + ) + + def commit_hash_url(self, commit_hash: str) -> str: + return ( + f"https://{self.hvcs_domain}/{self.owner}/{self.repo_name}/" + f"commits/{commit_hash}" + ) + + def pull_request_url(self, pr_number: str | int) -> str: + return ( + f"https://{self.hvcs_domain}/{self.owner}/{self.repo_name}/" + f"pull-requests/{pr_number}" + ) diff --git a/semantic_release/hvcs/gitea.py b/semantic_release/hvcs/gitea.py index 1d80a90d7..23adc3594 100644 --- a/semantic_release/hvcs/gitea.py +++ b/semantic_release/hvcs/gitea.py @@ -1,4 +1,5 @@ """Helper code for interacting with a Gitea remote VCS""" + from __future__ import annotations import glob @@ -36,6 +37,7 @@ class Gitea(HvcsBase): DEFAULT_DOMAIN = "gitea.com" DEFAULT_API_PATH = "/api/v1" DEFAULT_API_DOMAIN = f"{DEFAULT_DOMAIN}{DEFAULT_API_PATH}" + DEFAULT_ENV_TOKEN_NAME = "GITEA_TOKEN" # noqa: S105 # pylint: disable=super-init-not-called def __init__( @@ -154,7 +156,10 @@ def asset_upload_url(self, release_id: str) -> str: @logged_function(log) def upload_asset( - self, release_id: int, file: str, label: str | None = None # noqa: ARG002 + self, + release_id: int, + file: str, + label: str | None = None, # noqa: ARG002 ) -> bool: """ Upload an asset to an existing release diff --git a/semantic_release/hvcs/github.py b/semantic_release/hvcs/github.py index f888595f2..a29a606ee 100644 --- a/semantic_release/hvcs/github.py +++ b/semantic_release/hvcs/github.py @@ -1,4 +1,5 @@ """Helper code for interacting with a GitHub remote VCS""" + from __future__ import annotations import glob @@ -37,6 +38,7 @@ class Github(HvcsBase): DEFAULT_DOMAIN = "github.com" DEFAULT_API_DOMAIN = "api.github.com" DEFAULT_UPLOAD_DOMAIN = "uploads.github.com" + DEFAULT_ENV_TOKEN_NAME = "GH_TOKEN" # noqa: S105 def __init__( self, diff --git a/semantic_release/hvcs/gitlab.py b/semantic_release/hvcs/gitlab.py index 378901780..060e3934d 100644 --- a/semantic_release/hvcs/gitlab.py +++ b/semantic_release/hvcs/gitlab.py @@ -1,4 +1,5 @@ """Helper code for interacting with a Gitlab remote VCS""" + from __future__ import annotations import logging @@ -38,6 +39,10 @@ class Gitlab(HvcsBase): API domain """ + DEFAULT_ENV_TOKEN_NAME = "GITLAB_TOKEN" # noqa: S105 + # purposefully not CI_JOB_TOKEN as it is not a personal access token, + # It is missing the permission to push to the repository, but has all others (releases, packages, etc.) + DEFAULT_DOMAIN = "gitlab.com" def __init__( @@ -81,7 +86,10 @@ def _get_repository_owner_and_name(self) -> tuple[str, str]: @logged_function(log) def create_release( - self, tag: str, release_notes: str, prerelease: bool = False # noqa: ARG002 + self, + tag: str, + release_notes: str, + prerelease: bool = False, # noqa: ARG002 ) -> str: """ Post release changelog diff --git a/semantic_release/hvcs/util.py b/semantic_release/hvcs/util.py index c42f7a74f..fd0d66ebc 100644 --- a/semantic_release/hvcs/util.py +++ b/semantic_release/hvcs/util.py @@ -64,7 +64,7 @@ def suppress_http_error_for_codes( """ def _suppress_http_error_for_codes( - func: Callable[..., _R] + func: Callable[..., _R], ) -> Callable[..., _R | None]: @wraps(func) def _wrapper(*a: Any, **kw: Any) -> _R | None: diff --git a/semantic_release/version/algorithm.py b/semantic_release/version/algorithm.py index d6807ab9f..47d6caee5 100644 --- a/semantic_release/version/algorithm.py +++ b/semantic_release/version/algorithm.py @@ -4,12 +4,10 @@ from queue import Queue from typing import TYPE_CHECKING, Iterable -from semantic_release.commit_parser import ( - ParsedCommit, -) +from semantic_release.commit_parser import ParsedCommit from semantic_release.const import DEFAULT_VERSION from semantic_release.enums import LevelBump -from semantic_release.errors import InvalidVersion +from semantic_release.errors import InvalidVersion, MissingMergeBaseError from semantic_release.version.version import Version if TYPE_CHECKING: @@ -71,48 +69,67 @@ def _bfs_for_latest_version_in_history( `merge_base`'s parents' history. If no commits in the history correspond to a released version, return None """ + tag_sha_2_version_lookup = { + tag.commit.hexsha: version for tag, version in full_release_tags_and_versions + } # Step 3. Latest full release version within the history of the current branch # Breadth-first search the merge-base and its parent commits for one which matches # the tag of the latest full release tag in history - def bfs(visited: set[Commit], q: Queue[Commit]) -> Version | None: - if q.empty(): - log.debug("queue is empty, returning none") - return None + def bfs(start_commit: Commit | TagObject | Blob | Tree) -> Version | None: + # Derived from Geeks for Geeks + # https://www.geeksforgeeks.org/python-program-for-breadth-first-search-or-bfs-for-a-graph/?ref=lbp - node = q.get() - if node in visited: - log.debug("commit %s already visited, returning none", node.hexsha) - return None + # Create a queue for BFS + q: Queue[Commit | TagObject | Blob | Tree] = Queue() - for tag, version in full_release_tags_and_versions: - log.debug( - "checking if tag %r (%s) matches commit %s", - tag.name, - tag.commit.hexsha, - node.hexsha, - ) - if tag.commit == node: + # Create a set to store visited graph nodes (commit objects in this case) + visited: set[Commit | TagObject | Blob | Tree] = set() + + # Add the source node in the queue & mark as visited to start the search + q.put(start_commit) + visited.add(start_commit) + + # Initialize the result to None + result = None + + # Traverse the git history until it finds a version tag if one exists + while not q.empty(): + node = q.get() + visited.add(node) + + log.debug("checking if commit %s matches any tags", node.hexsha) + version = tag_sha_2_version_lookup.get(node.hexsha, None) + + if version is not None: log.info( "found latest version in branch history: %r (%s)", str(version), node.hexsha[:7], ) - return version + result = version + break + log.debug("commit %s doesn't match any tags", node.hexsha) - visited.add(node) - for parent in node.parents: - log.debug("queuing parent commit %s", parent.hexsha) - q.put(parent) + # Add all parent commits to the queue if they haven't been visited + for parent in node.parents: + if parent in visited: + log.debug("parent commit %s already visited", node.hexsha) + continue + + log.debug("queuing parent commit %s", parent.hexsha) + q.put(parent) - return bfs(visited, q) + return result - q: Queue[Commit] = Queue() - q.put(merge_base) - latest_version = bfs(set(), q) + # Run a Breadth First Search to find the latest version in the history + latest_version = bfs(merge_base) + if latest_version is not None: + log.info("the latest version in this branch's history is %s", latest_version) + else: + log.info("no version tags found in this branch's history") - log.info("the latest version in this branch's history is %s", latest_version) return latest_version @@ -124,6 +141,7 @@ def _increment_version( prerelease: bool, prerelease_token: str, major_on_zero: bool, + allow_zero_version: bool, ) -> Version: """ Using the given versions, along with a given `level_bump`, increment to @@ -139,19 +157,27 @@ def _increment_version( `latest_full_version_in_history`, correspondingly, is the latest full release which is in this branch's history. """ - log.debug( - "_increment_version: %s", ", ".join(f"{k} = {v}" for k, v in locals().items()) - ) - if not major_on_zero and latest_version.major == 0: - # if we are a 0.x.y release and have set `major_on_zero`, - # breaking changes should increment the minor digit - # Correspondingly, we reduce the level that we increment the - # version by. - log.debug( - "reducing version increment due to 0. version and major_on_zero=False" - ) + local_vars = list(locals().items()) + log.debug("_increment_version: %s", ", ".join(f"{k} = {v}" for k, v in local_vars)) + if latest_version.major == 0: + if not allow_zero_version: + # Set up default version to be 1.0.0 if currently 0.x.x which means a commented + # breaking change is not required to bump to 1.0.0 + log.debug( + "Bumping major version as 0.x.x versions are disabled because of allow_zero_version=False" + ) + level_bump = LevelBump.MAJOR - level_bump = min(level_bump, LevelBump.MINOR) + elif not major_on_zero: + # if we are a 0.x.y release and have set `major_on_zero`, + # breaking changes should increment the minor digit + # Correspondingly, we reduce the level that we increment the + # version by. + log.debug( + "reducing version increment due to 0. version and major_on_zero=False" + ) + + level_bump = min(level_bump, LevelBump.MINOR) if prerelease: log.debug("prerelease=true") @@ -242,6 +268,7 @@ def next_version( commit_parser: CommitParser[ParseResult, ParserOptions], prerelease: bool = False, major_on_zero: bool = True, + allow_zero_version: bool = True, ) -> Version: """ Evaluate the history within `repo`, and based on the tags and commits in the repo @@ -249,9 +276,9 @@ def next_version( """ # Step 1. All tags, sorted descending by semver ordering rules all_git_tags_as_versions = tags_and_versions(repo.tags, translator) - all_full_release_tags_and_versions = [ - (t, v) for t, v in all_git_tags_as_versions if not v.is_prerelease - ] + all_full_release_tags_and_versions = list( + filter(lambda t_v: not t_v[1].is_prerelease, all_git_tags_as_versions) + ) log.info( "Found %s full releases (excluding prereleases)", len(all_full_release_tags_and_versions), @@ -262,30 +289,59 @@ def next_version( iter(all_full_release_tags_and_versions), (None, translator.from_string(DEFAULT_VERSION)), ) - if latest_full_release_tag is None: - # Workaround - we can safely scan the extra commits on this - # branch if it's never been released, but we have no other - # guarantees that other branches exist - log.info( - "No full releases have been made yet, the default version to use is %s", - latest_full_release_version, + + # we can safely scan the extra commits on this + # branch if it's never been released, but we have no other + # guarantees that other branches exist + # Note the merge_base might be on our current branch, it's not + # necessarily the merge base of the current branch with `main` + other_ref = ( + repo.active_branch + if latest_full_release_tag is None + else latest_full_release_tag.name + ) + + # Conditional log message to inform what was chosen as the comparison point + # to find the merge base of the current branch with the latest full release + log_msg = ( + str.join( + ", ", + [ + "No full releases have been made yet", + f"the default version to use is {latest_full_release_version}", + ], ) - merge_bases = repo.merge_base(repo.active_branch, repo.active_branch) - else: - # Note the merge_base might be on our current branch, it's not - # necessarily the merge base of the current branch with `main` - log.info( - "The last full release was %s, tagged as %r", - latest_full_release_version, - latest_full_release_tag, + if latest_full_release_tag is None + else str.join( + ", ", + [ + f"The last full release was {latest_full_release_version}", + f"tagged as {latest_full_release_tag!r}", + ], ) - merge_bases = repo.merge_base(latest_full_release_tag.name, repo.active_branch) + ) + + log.info(log_msg) + merge_bases = repo.merge_base(other_ref, repo.active_branch) + + if len(merge_bases) < 1: + raise MissingMergeBaseError( + f"Unable to find merge-base between {other_ref} and {repo.active_branch.name}" + ) + if len(merge_bases) > 1: raise NotImplementedError( - "This branch has more than one merge-base with the " - "latest version, which is not yet supported" + str.join( + " ", + [ + "This branch has more than one merge-base with the", + "latest version, which is not yet supported", + ], + ) ) + merge_base = merge_bases[0] + if merge_base is None: str_tag_name = ( "None" if latest_full_release_tag is None else latest_full_release_tag.name @@ -321,6 +377,23 @@ def next_version( tag_format=translator.tag_format, ) + # We only include pre-releases here if doing a prerelease. + # If it's not a prerelease, we need to include commits back + # to the last full version in consideration for what kind of + # bump to produce. However if we're doing a prerelease, we can + # include prereleases here to potentially consider a smaller portion + # of history (from a prerelease since the last full release, onwards) + + # Note that a side-effect of this is, if at some point the configuration + # for a particular branch pattern changes w.r.t. prerelease=True/False, + # the new kind of version will be produced from the commits already + # included in a prerelease since the last full release on the branch + tag_sha_2_version_lookup = { + tag.commit.hexsha: (tag, version) + for tag, version in all_git_tags_as_versions + if prerelease or not version.is_prerelease + } + # N.B. these should be sorted so long as we iterate the commits in reverse order for commit in commits_since_last_full_release: parse_result = commit_parser.parse(commit) @@ -331,44 +404,24 @@ def next_version( ) parsed_levels.add(parse_result.bump) - # We only include pre-releases here if doing a prerelease. - # If it's not a prerelease, we need to include commits back - # to the last full version in consideration for what kind of - # bump to produce. However if we're doing a prerelease, we can - # include prereleases here to potentially consider a smaller portion - # of history (from a prerelease since the last full release, onwards) - - # Note that a side-effect of this is, if at some point the configuration - # for a particular branch pattern changes w.r.t. prerelease=True/False, - # the new kind of version will be produced from the commits already - # included in a prerelease since the last full release on the branch - for tag, version in ( - (tag, version) - for tag, version in all_git_tags_as_versions - if prerelease or not version.is_prerelease - ): - log.debug( - "testing if tag %r (%s) matches commit %s", - tag.name, - tag.commit.hexsha, - commit.hexsha, - ) - if tag.commit == commit: - latest_version = version - log.debug( - "tag %r (%s) matches commit %s. the latest version is %s", - tag.name, - tag.commit.hexsha, - commit.hexsha, - latest_version, - ) - break - else: + log.debug("checking if commit %s matches any tags", commit.hexsha) + t_v = tag_sha_2_version_lookup.get(commit.hexsha, None) + + if t_v is None: # If we haven't found the latest prerelease on the branch, - # keep the outer loop going to look for it + # keep the loop going to look for it log.debug("no tags correspond to commit %s", commit.hexsha) continue - # If we found it in the inner loop, break the outer loop too + + # Unpack the tuple + tag, latest_version = t_v + log.debug( + "tag %r (%s) matches commit %s. the latest version is %s", + tag.name, + tag.commit.hexsha, + commit.hexsha, + latest_version, + ) break log.debug( @@ -379,8 +432,9 @@ def next_version( level_bump = max(parsed_levels, default=LevelBump.NO_RELEASE) log.info("The type of the next release release is: %s", level_bump) if level_bump is LevelBump.NO_RELEASE: - log.info("No release will be made") - return latest_version + if latest_version.major != 0 or allow_zero_version: + log.info("No release will be made") + return latest_version return _increment_version( latest_version=latest_version, @@ -399,4 +453,5 @@ def next_version( prerelease=prerelease, prerelease_token=translator.prerelease_token, major_on_zero=major_on_zero, + allow_zero_version=allow_zero_version, ) diff --git a/semantic_release/version/translator.py b/semantic_release/version/translator.py index 24b295cfc..7a4ce275f 100644 --- a/semantic_release/version/translator.py +++ b/semantic_release/version/translator.py @@ -41,7 +41,9 @@ def _invert_tag_format_to_re(cls, tag_format: str) -> re.Pattern[str]: return pat def __init__( - self, tag_format: str = "v{version}", prerelease_token: str = "rc" # noqa: S107 + self, + tag_format: str = "v{version}", + prerelease_token: str = "rc", # noqa: S107 ) -> None: check_tag_format(tag_format) self.tag_format = tag_format diff --git a/semantic_release/version/version.py b/semantic_release/version/version.py index 529e70cc0..41ec5e107 100644 --- a/semantic_release/version/version.py +++ b/semantic_release/version/version.py @@ -23,16 +23,15 @@ @overload def _comparator( - *, type_guard: bool -) -> Callable[[VersionComparator], VersionComparator]: - ... + *, + type_guard: bool, +) -> Callable[[VersionComparator], VersionComparator]: ... @overload def _comparator( method: VersionComparator, *, type_guard: bool = True -) -> VersionComparator: - ... +) -> VersionComparator: ... def _comparator( diff --git a/tests/command_line/conftest.py b/tests/command_line/conftest.py index b9c666469..3ac92e6e0 100644 --- a/tests/command_line/conftest.py +++ b/tests/command_line/conftest.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import MagicMock @@ -7,7 +9,7 @@ from click.testing import CliRunner from requests_mock import ANY -from semantic_release.cli.commands import main +from semantic_release.cli import config as CliConfigModule from semantic_release.cli.config import ( GlobalCommandLineOptions, RawConfig, @@ -16,19 +18,26 @@ from semantic_release.cli.const import DEFAULT_CONFIG_FILE from semantic_release.cli.util import load_raw_config_file -from tests.util import ( - get_release_history_from_context, - prepare_mocked_git_command_wrapper_type, -) +from tests.util import prepare_mocked_git_command_wrapper_type if TYPE_CHECKING: - from pathlib import Path + from typing import Protocol from git.repo import Repo from pytest import MonkeyPatch from requests_mock.mocker import Mocker - from semantic_release.changelog.release_history import ReleaseHistory + from tests.fixtures.example_project import ExProjectDir + + class ReadConfigFileFn(Protocol): + """Read the raw config file from `config_path`.""" + + def __call__(self, file: Path | str) -> RawConfig: ... + + class RetrieveRuntimeContextFn(Protocol): + """Retrieve the runtime context for a repo.""" + + def __call__(self, repo: Repo) -> RuntimeContext: ... @pytest.fixture @@ -48,25 +57,22 @@ def mocked_git_push(monkeypatch: MonkeyPatch) -> MagicMock: """Mock the `Repo.git.push()` method in `semantic_release.cli.main`.""" mocked_push = MagicMock() cls = prepare_mocked_git_command_wrapper_type(push=mocked_push) - monkeypatch.setattr(main.Repo, "GitCommandWrapperType", cls) + monkeypatch.setattr(CliConfigModule.Repo, "GitCommandWrapperType", cls) return mocked_push @pytest.fixture -def config_path(example_project: Path) -> Path: - return example_project / DEFAULT_CONFIG_FILE +def config_path(example_project_dir: ExProjectDir) -> Path: + return example_project_dir / DEFAULT_CONFIG_FILE @pytest.fixture -def raw_config(config_path: Path) -> RawConfig: - """ - Read the raw config file from `config_path`. +def read_config_file() -> ReadConfigFileFn: + def _read_config_file(file: Path | str) -> RawConfig: + config_text = load_raw_config_file(file) + return RawConfig.model_validate(config_text) - note: a `tests.fixtures.example_project` fixture must precede this one - otherwise, the `config_path` file will not exist, and this fixture will fail - """ - config_text = load_raw_config_file(config_path) - return RawConfig.model_validate(config_text) + return _read_config_file @pytest.fixture @@ -80,31 +86,19 @@ def cli_options(config_path: Path) -> GlobalCommandLineOptions: @pytest.fixture -def runtime_context_with_tags( - repo_with_single_branch_and_prereleases_angular_commits: Repo, - raw_config: RawConfig, - cli_options: GlobalCommandLineOptions, -) -> RuntimeContext: - return RuntimeContext.from_raw_config( - raw_config, - repo_with_single_branch_and_prereleases_angular_commits, - cli_options, - ) - - -@pytest.fixture -def release_history(runtime_context_with_tags: RuntimeContext) -> ReleaseHistory: - return get_release_history_from_context(runtime_context_with_tags) - - -@pytest.fixture -def runtime_context_with_no_tags( - repo_with_no_tags_angular_commits: Repo, - raw_config: RawConfig, +def retrieve_runtime_context( + read_config_file: ReadConfigFileFn, cli_options: GlobalCommandLineOptions, -) -> RuntimeContext: - return RuntimeContext.from_raw_config( - raw_config, - repo_with_no_tags_angular_commits, - cli_options, - ) +) -> RetrieveRuntimeContextFn: + def _retrieve_runtime_context(repo: Repo) -> RuntimeContext: + cwd = os.getcwd() + repo_dir = str(Path(repo.working_dir).resolve()) + + os.chdir(repo_dir) + try: + raw_config = read_config_file(cli_options.config_file) + return RuntimeContext.from_raw_config(raw_config, cli_options) + finally: + os.chdir(cwd) + + return _retrieve_runtime_context diff --git a/tests/command_line/test_changelog.py b/tests/command_line/test_changelog.py index 19e1fc214..35acd3cd2 100644 --- a/tests/command_line/test_changelog.py +++ b/tests/command_line/test_changelog.py @@ -18,42 +18,90 @@ EXAMPLE_RELEASE_NOTES_TEMPLATE, EXAMPLE_REPO_NAME, EXAMPLE_REPO_OWNER, + SUCCESS_EXIT_CODE, ) -from tests.util import flatten_dircmp +from tests.fixtures.repos import ( + repo_w_github_flow_w_feature_release_channel_angular_commits, + repo_w_github_flow_w_feature_release_channel_emoji_commits, + repo_w_github_flow_w_feature_release_channel_scipy_commits, + repo_w_github_flow_w_feature_release_channel_tag_commits, + repo_with_git_flow_and_release_channels_angular_commits, + repo_with_git_flow_and_release_channels_angular_commits_using_tag_format, + repo_with_git_flow_and_release_channels_emoji_commits, + repo_with_git_flow_and_release_channels_scipy_commits, + repo_with_git_flow_and_release_channels_tag_commits, + repo_with_git_flow_angular_commits, + repo_with_git_flow_emoji_commits, + repo_with_git_flow_scipy_commits, + repo_with_git_flow_tag_commits, + repo_with_no_tags_angular_commits, + repo_with_no_tags_emoji_commits, + repo_with_no_tags_scipy_commits, + repo_with_no_tags_tag_commits, + repo_with_single_branch_and_prereleases_angular_commits, + repo_with_single_branch_and_prereleases_emoji_commits, + repo_with_single_branch_and_prereleases_scipy_commits, + repo_with_single_branch_and_prereleases_tag_commits, + repo_with_single_branch_angular_commits, + repo_with_single_branch_emoji_commits, + repo_with_single_branch_scipy_commits, + repo_with_single_branch_tag_commits, +) +from tests.util import flatten_dircmp, get_release_history_from_context, remove_dir_tree if TYPE_CHECKING: + from pathlib import Path + from click.testing import CliRunner + from git import Repo from requests_mock import Mocker - from semantic_release.changelog.release_history import ReleaseHistory - from semantic_release.cli.config import RuntimeContext + from tests.command_line.conftest import RetrieveRuntimeContextFn + from tests.fixtures.example_project import ExProjectDir, UseReleaseNotesTemplateFn + + +changelog_subcmd = changelog.name or changelog.__name__ # type: ignore @pytest.mark.parametrize( "repo,tag", [ - (lazy_fixture("repo_with_no_tags_angular_commits"), None), - (lazy_fixture("repo_with_single_branch_angular_commits"), "v0.1.1"), + (lazy_fixture(repo_with_no_tags_angular_commits.__name__), None), + (lazy_fixture(repo_with_single_branch_angular_commits.__name__), "v0.1.1"), ( - lazy_fixture("repo_with_single_branch_and_prereleases_angular_commits"), + lazy_fixture( + repo_with_single_branch_and_prereleases_angular_commits.__name__ + ), "v0.2.0", ), - (lazy_fixture("repo_with_main_and_feature_branches_angular_commits"), "v0.2.0"), - (lazy_fixture("repo_with_git_flow_angular_commits"), "v1.0.0"), ( - lazy_fixture("repo_with_git_flow_and_release_channels_angular_commits"), + lazy_fixture( + repo_w_github_flow_w_feature_release_channel_angular_commits.__name__ + ), + "v0.2.0", + ), + (lazy_fixture(repo_with_git_flow_angular_commits.__name__), "v1.0.0"), + ( + lazy_fixture( + repo_with_git_flow_and_release_channels_angular_commits.__name__ + ), "v1.1.0-alpha.3", ), ], ) @pytest.mark.parametrize("arg0", [None, "--post-to-release-tag"]) def test_changelog_noop_is_noop( - repo, tag, arg0, tmp_path_factory, example_project, cli_runner + repo: Repo, + tag: str | None, + arg0: str | None, + tmp_path_factory: pytest.TempPathFactory, + example_project_dir: ExProjectDir, + cli_runner: CliRunner, ): args = [arg0, tag] if tag and arg0 else [] tempdir = tmp_path_factory.mktemp("test_noop") - shutil.rmtree(str(tempdir.resolve())) - shutil.copytree(src=str(example_project.resolve()), dst=tempdir) + remove_dir_tree(tempdir.resolve(), force=True) + shutil.copytree(src=str(example_project_dir.resolve()), dst=tempdir) # Set up a requests HTTP session so we can catch the HTTP calls and ensure # they're made @@ -72,11 +120,11 @@ def test_changelog_noop_is_noop( "semantic_release.hvcs.github.build_requests_session", return_value=session, ), requests_mock.Mocker(session=session) as mocker: - result = cli_runner.invoke(main, ["--noop", changelog.name, *args]) + result = cli_runner.invoke(main, ["--noop", changelog_subcmd, *args]) - assert result.exit_code == 0 + assert SUCCESS_EXIT_CODE == result.exit_code # noqa: SIM300 - dcmp = filecmp.dircmp(str(example_project.resolve()), tempdir) + dcmp = filecmp.dircmp(str(example_project_dir.resolve()), tempdir) differing_files = flatten_dircmp(dcmp) assert not differing_files @@ -89,58 +137,96 @@ def test_changelog_noop_is_noop( @pytest.mark.parametrize( "repo", [ - lazy_fixture("repo_with_no_tags_angular_commits"), - lazy_fixture("repo_with_single_branch_angular_commits"), - lazy_fixture("repo_with_single_branch_and_prereleases_angular_commits"), - lazy_fixture("repo_with_main_and_feature_branches_angular_commits"), - lazy_fixture("repo_with_git_flow_angular_commits"), - lazy_fixture("repo_with_git_flow_and_release_channels_angular_commits"), + lazy_fixture(repo_fixture) + for repo_fixture in [ + repo_with_no_tags_angular_commits.__name__, + repo_with_no_tags_emoji_commits.__name__, + repo_with_no_tags_scipy_commits.__name__, + repo_with_no_tags_tag_commits.__name__, + repo_with_single_branch_angular_commits.__name__, + repo_with_single_branch_emoji_commits.__name__, + repo_with_single_branch_scipy_commits.__name__, + repo_with_single_branch_tag_commits.__name__, + repo_with_single_branch_and_prereleases_angular_commits.__name__, + repo_with_single_branch_and_prereleases_emoji_commits.__name__, + repo_with_single_branch_and_prereleases_scipy_commits.__name__, + repo_with_single_branch_and_prereleases_tag_commits.__name__, + repo_w_github_flow_w_feature_release_channel_angular_commits.__name__, + repo_w_github_flow_w_feature_release_channel_emoji_commits.__name__, + repo_w_github_flow_w_feature_release_channel_scipy_commits.__name__, + repo_w_github_flow_w_feature_release_channel_tag_commits.__name__, + repo_with_git_flow_angular_commits.__name__, + repo_with_git_flow_emoji_commits.__name__, + repo_with_git_flow_scipy_commits.__name__, + repo_with_git_flow_tag_commits.__name__, + repo_with_git_flow_and_release_channels_angular_commits.__name__, + repo_with_git_flow_and_release_channels_emoji_commits.__name__, + repo_with_git_flow_and_release_channels_scipy_commits.__name__, + repo_with_git_flow_and_release_channels_tag_commits.__name__, + repo_with_git_flow_and_release_channels_angular_commits_using_tag_format.__name__, + ] ], ) def test_changelog_content_regenerated( - repo, tmp_path_factory, example_project, example_changelog_md, cli_runner + repo: Repo, + example_changelog_md: Path, + cli_runner: CliRunner, ): - tempdir = tmp_path_factory.mktemp("test_changelog") - shutil.rmtree(str(tempdir.resolve())) - shutil.copytree(src=str(example_project.resolve()), dst=tempdir) + expected_changelog_content = example_changelog_md.read_text() # Remove the changelog and then check that we can regenerate it os.remove(str(example_changelog_md.resolve())) - result = cli_runner.invoke(main, [changelog.name]) - assert result.exit_code == 0 + result = cli_runner.invoke(main, [changelog_subcmd]) + assert SUCCESS_EXIT_CODE == result.exit_code # noqa: SIM300 - dcmp = filecmp.dircmp(str(example_project.resolve()), tempdir) + # Check that the changelog file was re-created + assert example_changelog_md.exists() - differing_files = flatten_dircmp(dcmp) - assert not differing_files + actual_content = example_changelog_md.read_text() + + # Check that the changelog content is the same as before + assert expected_changelog_content == actual_content # Just need to test that it works for "a" project, not all -@pytest.mark.usefixtures("repo_with_single_branch_and_prereleases_angular_commits") +@pytest.mark.usefixtures( + repo_with_single_branch_and_prereleases_angular_commits.__name__ +) @pytest.mark.parametrize( "args", [("--post-to-release-tag", "v1.99.91910000000000000000000000000")] ) def test_changelog_release_tag_not_in_history( - args, tmp_path_factory, example_project, cli_runner + args: list[str], + tmp_path_factory: pytest.TempPathFactory, + example_project_dir: ExProjectDir, + cli_runner: CliRunner, ): + expected_err_code = 2 tempdir = tmp_path_factory.mktemp("test_changelog") - shutil.rmtree(str(tempdir.resolve())) - shutil.copytree(src=str(example_project.resolve()), dst=tempdir) + remove_dir_tree(tempdir.resolve(), force=True) + shutil.copytree(src=str(example_project_dir.resolve()), dst=tempdir) + + result = cli_runner.invoke(main, [changelog_subcmd, *args]) - result = cli_runner.invoke(main, [changelog.name, *args]) - assert result.exit_code == 2 + assert expected_err_code == result.exit_code assert "not in release history" in result.stderr.lower() -@pytest.mark.usefixtures("repo_with_single_branch_and_prereleases_angular_commits") +@pytest.mark.usefixtures( + repo_with_single_branch_and_prereleases_angular_commits.__name__ +) @pytest.mark.parametrize("args", [("--post-to-release-tag", "v0.1.0")]) def test_changelog_post_to_release( - args, monkeypatch, tmp_path_factory, example_project, cli_runner + args: list[str], + monkeypatch: pytest.MonkeyPatch, + tmp_path_factory: pytest.TempPathFactory, + example_project_dir: ExProjectDir, + cli_runner: CliRunner, ): tempdir = tmp_path_factory.mktemp("test_changelog") - shutil.rmtree(str(tempdir.resolve())) - shutil.copytree(src=str(example_project.resolve()), dst=tempdir) + remove_dir_tree(tempdir.resolve(), force=True) + shutil.copytree(src=str(example_project_dir.resolve()), dst=tempdir) # Set up a requests HTTP session so we can catch the HTTP calls and ensure they're # made @@ -155,6 +241,14 @@ def test_changelog_post_to_release( session.mount("http://", mock_adapter) session.mount("https://", mock_adapter) + expected_request_url = ( + "https://{api_url}/repos/{owner}/{repo_name}/releases".format( + api_url=Github.DEFAULT_API_DOMAIN, + 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( @@ -163,45 +257,56 @@ def test_changelog_post_to_release( ) as mocker, monkeypatch.context() as m: m.delenv("GITHUB_REPOSITORY", raising=False) m.delenv("CI_PROJECT_NAMESPACE", raising=False) - result = cli_runner.invoke(main, [changelog.name, *args]) + result = cli_runner.invoke(main, [changelog_subcmd, *args]) - assert result.exit_code == 0 + assert SUCCESS_EXIT_CODE == result.exit_code # noqa: SIM300 assert mocker.called assert mock_adapter.called - assert mock_adapter.last_request.url == ( - "https://{api_url}/repos/{owner}/{repo_name}/releases".format( - api_url=Github.DEFAULT_API_DOMAIN, - owner=EXAMPLE_REPO_OWNER, - repo_name=EXAMPLE_REPO_NAME, - ) - ) + assert mock_adapter.last_request is not None + assert expected_request_url == mock_adapter.last_request.url -@pytest.mark.usefixtures("example_project_with_release_notes_template") def test_custom_release_notes_template( - release_history: ReleaseHistory, - runtime_context_with_tags: RuntimeContext, + repo_with_single_branch_and_prereleases_angular_commits: Repo, + use_release_notes_template: UseReleaseNotesTemplateFn, + retrieve_runtime_context: RetrieveRuntimeContextFn, post_mocker: Mocker, cli_runner: CliRunner, ) -> None: """Verify the template `.release_notes.md.j2` from `template_dir` is used.""" + # Setup + use_release_notes_template() + runtime_context_with_tags = retrieve_runtime_context( + repo_with_single_branch_and_prereleases_angular_commits + ) + expected_call_count = 1 + # Arrange + release_history = get_release_history_from_context(runtime_context_with_tags) tag = runtime_context_with_tags.repo.tags[-1].name + version = runtime_context_with_tags.version_translator.from_tag(tag) + if version is None: + raise ValueError(f"Tag {tag} not in release history") + release = release_history.released[version] # Act - resp = cli_runner.invoke(main, [changelog.name, "--post-to-release-tag", tag]) - expected_release_notes = runtime_context_with_tags.template_environment.from_string( - EXAMPLE_RELEASE_NOTES_TEMPLATE - ).render(version=version, release=release) + resp = cli_runner.invoke(main, [changelog_subcmd, "--post-to-release-tag", tag]) + expected_release_notes = ( + runtime_context_with_tags.template_environment.from_string( + EXAMPLE_RELEASE_NOTES_TEMPLATE + ).render(version=version, release=release) + + "\n" + ) # Assert - assert resp.exit_code == 0, ( + assert SUCCESS_EXIT_CODE == resp.exit_code, ( # noqa: SIM300 "Unexpected failure in command " - f"'semantic-release {changelog.name} --post-to-release-tag {tag}': " + f"'semantic-release {changelog_subcmd} --post-to-release-tag {tag}': " + resp.stderr ) - assert post_mocker.call_count == 1 - assert post_mocker.last_request.json()["body"] == expected_release_notes + assert expected_call_count == post_mocker.call_count + assert post_mocker.last_request is not None + assert expected_release_notes == post_mocker.last_request.json()["body"] diff --git a/tests/command_line/test_generate_config.py b/tests/command_line/test_generate_config.py index 196714980..0ff0bf99a 100644 --- a/tests/command_line/test_generate_config.py +++ b/tests/command_line/test_generate_config.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from typing import TYPE_CHECKING import pytest import tomlkit @@ -8,37 +9,53 @@ from semantic_release.cli.commands.generate_config import generate_config from semantic_release.cli.config import RawConfig +if TYPE_CHECKING: + from typing import Any + + from tests.command_line.conftest import CliRunner + + +@pytest.fixture +def raw_config_dict() -> dict[str, Any]: + return RawConfig().model_dump(mode="json", exclude_none=True) + @pytest.mark.parametrize("args", [(), ("--format", "toml"), ("--format", "TOML")]) -def test_generate_config_toml(cli_runner, args): +def test_generate_config_toml( + cli_runner: CliRunner, args: tuple[str], raw_config_dict: dict[str, Any] +): + expected_config_as_str = tomlkit.dumps( + {"semantic_release": raw_config_dict} + ).strip() + result = cli_runner.invoke(generate_config, args) + assert result.exit_code == 0 - assert ( - result.output.strip() - == tomlkit.dumps( - {"semantic_release": RawConfig().model_dump(exclude_none=True)} - ).strip() - ) + assert expected_config_as_str == result.output.strip() @pytest.mark.parametrize("args", [("--format", "json"), ("--format", "JSON")]) -def test_generate_config_json(cli_runner, args): +def test_generate_config_json( + cli_runner: CliRunner, args: tuple[str], raw_config_dict: dict[str, Any] +): + expected_config_as_str = json.dumps( + {"semantic_release": raw_config_dict}, indent=4 + ).strip() + result = cli_runner.invoke(generate_config, args) + assert result.exit_code == 0 - assert ( - result.output.strip() - == json.dumps( - {"semantic_release": RawConfig().model_dump(exclude_none=True)}, indent=4 - ).strip() - ) + assert expected_config_as_str == result.output.strip() -def test_generate_config_pyproject_toml(cli_runner): +def test_generate_config_pyproject_toml( + cli_runner: CliRunner, raw_config_dict: dict[str, Any] +): + expected_config_as_str = tomlkit.dumps( + {"tool": {"semantic_release": raw_config_dict}} + ).strip() + result = cli_runner.invoke(generate_config, ["--format", "toml", "--pyproject"]) + assert result.exit_code == 0 - assert ( - result.output.strip() - == tomlkit.dumps( - {"tool": {"semantic_release": RawConfig().model_dump(exclude_none=True)}} - ).strip() - ) + assert expected_config_as_str == result.output.strip() diff --git a/tests/command_line/test_help.py b/tests/command_line/test_help.py index ff351615e..a80a7aee2 100644 --- a/tests/command_line/test_help.py +++ b/tests/command_line/test_help.py @@ -1,21 +1,222 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + import pytest +import semantic_release from semantic_release.cli import changelog, generate_config, main, publish, version +if TYPE_CHECKING: + from click import Command + from click.testing import CliRunner + from git import Repo + + from tests.fixtures import UpdatePyprojectTomlFn + + +# Define the expected exit code for the help command +help_exit_code = 0 + + +@pytest.mark.parametrize( + "help_option", ("-h", "--help"), ids=lambda opt: opt.lstrip("-") +) +@pytest.mark.parametrize( + "command", + (main, changelog, generate_config, publish, version), + ids=lambda cmd: cmd.name, +) +def test_help_no_repo( + help_option: str, + command: Command, + cli_runner: CliRunner, + change_to_ex_proj_dir: None, +): + """ + Test that the help message is displayed even when the current directory is not a git repository + and there is not a configuration file available. + Documented issue #840 + """ + # Generate some expected output that should be specific per command + cmd_usage = str.join( + " ", + list( + filter( + None, + [ + "Usage:", + semantic_release.__name__, + command.name if command.name != "main" else "", + "[OPTIONS]", + "" if command.name != main.name else "COMMAND [ARGS]...", + ], + ) + ), + ) + + # Create the arguments list for subcommands unless its main + args = list( + filter(None, [command.name if command.name != main.name else "", help_option]) + ) + + # Run the command with the help option + result = cli_runner.invoke(main, args, prog_name=semantic_release.__name__) + + # Evaluate result + assert help_exit_code == result.exit_code + assert cmd_usage in result.output + -@pytest.mark.parametrize("help_option", ("-h", "--help")) +@pytest.mark.parametrize( + "help_option", ("-h", "--help"), ids=lambda opt: opt.lstrip("-") +) @pytest.mark.parametrize( "command", (main, changelog, generate_config, publish, version), ids=lambda cmd: cmd.name, ) -def test_help(help_option, command, cli_runner): - result = cli_runner.invoke(command, [help_option]) +def test_help_valid_config( + help_option: str, + command: Command, + cli_runner: CliRunner, + repo_with_single_branch_angular_commits: Repo, +): + """ + Test that the help message is displayed when the current directory is a git repository + and there is a valid configuration file available. + Documented issue #840 + """ + cmd_usage = str.join( + " ", + list( + filter( + None, + [ + "Usage:", + semantic_release.__name__, + command.name if command.name != main.name else "", + "[OPTIONS]", + "" if command.name != main.name else "COMMAND [ARGS]...", + ], + ) + ), + ) + + # Create the arguments list for subcommands unless its main + args = list( + filter(None, [command.name if command.name != main.name else "", help_option]) + ) + + # Run the command with the help option + result = cli_runner.invoke(main, args, prog_name=semantic_release.__name__) + + # Evaluate result + assert help_exit_code == result.exit_code + assert cmd_usage in result.output + + +@pytest.mark.parametrize( + "help_option", ("-h", "--help"), ids=lambda opt: opt.lstrip("-") +) +@pytest.mark.parametrize( + "command", + (main, changelog, generate_config, publish, version), + ids=lambda cmd: cmd.name, +) +def test_help_invalid_config( + help_option: str, + command: Command, + cli_runner: CliRunner, + repo_with_single_branch_angular_commits: Repo, + update_pyproject_toml: UpdatePyprojectTomlFn, +): + """ + Test that the help message is displayed when the current directory is a git repository + and there is an invalid configuration file available. + Documented issue #840 + """ + # Update the configuration file to have an invalid value + update_pyproject_toml("tool.semantic_release.remote.type", "invalidhvcs") + + # Generate some expected output that should be specific per command + cmd_usage = str.join( + " ", + list( + filter( + None, + [ + "Usage:", + semantic_release.__name__, + command.name if command.name != "main" else "", + "[OPTIONS]", + "" if command.name != main.name else "COMMAND [ARGS]...", + ], + ) + ), + ) + + # Create the arguments list for subcommands unless its main + args = list( + filter(None, [command.name if command.name != main.name else "", help_option]) + ) + + # Run the command with the help option + result = cli_runner.invoke(main, args, prog_name=semantic_release.__name__) + + # Evaluate result + assert help_exit_code == result.exit_code + assert cmd_usage in result.output + + +@pytest.mark.parametrize( + "help_option", ("-h", "--help"), ids=lambda opt: opt.lstrip("-") +) +@pytest.mark.parametrize( + "command", + (main, changelog, generate_config, publish, version), + ids=lambda cmd: cmd.name, +) +def test_help_non_release_branch( + help_option: str, + command: Command, + cli_runner: CliRunner, + repo_with_single_branch_angular_commits: Repo, +): + """ + Test that the help message is displayed even when the current branch is not a release branch. + Documented issue #840 + """ + # Create & checkout a non-release branch + repo = repo_with_single_branch_angular_commits + non_release_branch = repo.create_head("feature-branch") + non_release_branch.checkout() + + # Generate some expected output that should be specific per command + cmd_usage = str.join( + " ", + list( + filter( + None, + [ + "Usage:", + semantic_release.__name__, + command.name if command.name != "main" else "", + "[OPTIONS]", + "" if command.name != main.name else "COMMAND [ARGS]...", + ], + ) + ), + ) + + # Create the arguments list for subcommands unless its main + args = list( + filter(None, [command.name if command.name != main.name else "", help_option]) + ) - assert result.exit_code == 0 - # commands have help text - assert result.output + # Run the command with the help option + result = cli_runner.invoke(main, args, prog_name=semantic_release.__name__) - if command is not main: - # commands have their own unique help text - assert result.output != cli_runner.invoke(main, [help_option]).output + # Evaluate result + assert help_exit_code == result.exit_code + assert cmd_usage in result.output diff --git a/tests/command_line/test_main.py b/tests/command_line/test_main.py index 34b618113..4b47facd8 100644 --- a/tests/command_line/test_main.py +++ b/tests/command_line/test_main.py @@ -1,6 +1,10 @@ +from __future__ import annotations + import json import os +from pathlib import Path from textwrap import dedent +from typing import TYPE_CHECKING import git import pytest @@ -8,20 +12,30 @@ from semantic_release import __version__ from semantic_release.cli import main +if TYPE_CHECKING: + from pathlib import Path + + from click.testing import CliRunner + from git import Repo + + from tests.fixtures.example_project import UpdatePyprojectTomlFn -def test_main_prints_version_and_exits(cli_runner): + +def test_main_prints_version_and_exits(cli_runner: CliRunner): result = cli_runner.invoke(main, ["--version"]) assert result.exit_code == 0 assert result.output == f"semantic-release, version {__version__}\n" @pytest.mark.parametrize("args", [[], ["--help"]]) -def test_main_prints_help_text(cli_runner, args): +def test_main_prints_help_text(cli_runner: CliRunner, args: list[str]): result = cli_runner.invoke(main, args) assert result.exit_code == 0 -def test_not_a_release_branch_exit_code(repo_with_git_flow_angular_commits, cli_runner): +def test_not_a_release_branch_exit_code( + repo_with_git_flow_angular_commits: Repo, cli_runner: CliRunner +): # Run anything that doesn't trigger the help text repo_with_git_flow_angular_commits.git.checkout("-b", "branch-does-not-exist") result = cli_runner.invoke(main, ["version", "--no-commit"]) @@ -29,7 +43,7 @@ def test_not_a_release_branch_exit_code(repo_with_git_flow_angular_commits, cli_ def test_not_a_release_branch_exit_code_with_strict( - repo_with_git_flow_angular_commits, cli_runner + repo_with_git_flow_angular_commits: Repo, cli_runner: CliRunner ): # Run anything that doesn't trigger the help text repo_with_git_flow_angular_commits.git.checkout("-b", "branch-does-not-exist") @@ -37,8 +51,24 @@ def test_not_a_release_branch_exit_code_with_strict( assert result.exit_code != 0 +def test_not_a_release_branch_detached_head_exit_code( + repo_with_git_flow_angular_commits: Repo, cli_runner: CliRunner +): + expected_err_msg = ( + "Detached HEAD state cannot match any release groups; no release will be made" + ) + + # cause repo to be in detached head state without file changes + repo_with_git_flow_angular_commits.git.checkout("HEAD", "--detach") + result = cli_runner.invoke(main, ["version", "--no-commit"]) + + # as non-strict, this will return success exit code + assert result.exit_code == 0 + assert expected_err_msg in result.stderr + + @pytest.fixture -def toml_file_with_no_configuration_for_psr(tmp_path): +def toml_file_with_no_configuration_for_psr(tmp_path: Path) -> Path: path = tmp_path / "config.toml" path.write_text( dedent( @@ -54,7 +84,7 @@ def toml_file_with_no_configuration_for_psr(tmp_path): @pytest.fixture -def json_file_with_no_configuration_for_psr(tmp_path): +def json_file_with_no_configuration_for_psr(tmp_path: Path) -> Path: path = tmp_path / "config.json" path.write_text(json.dumps({"foo": [1, 2, 3]})) @@ -63,8 +93,8 @@ def json_file_with_no_configuration_for_psr(tmp_path): @pytest.mark.usefixtures("repo_with_git_flow_angular_commits") def test_default_config_is_used_when_none_in_toml_config_file( - cli_runner, - toml_file_with_no_configuration_for_psr, + cli_runner: CliRunner, + toml_file_with_no_configuration_for_psr: Path, ): result = cli_runner.invoke( main, @@ -76,8 +106,8 @@ def test_default_config_is_used_when_none_in_toml_config_file( @pytest.mark.usefixtures("repo_with_git_flow_angular_commits") def test_default_config_is_used_when_none_in_json_config_file( - cli_runner, - json_file_with_no_configuration_for_psr, + cli_runner: CliRunner, + json_file_with_no_configuration_for_psr: Path, ): result = cli_runner.invoke( main, @@ -89,7 +119,7 @@ def test_default_config_is_used_when_none_in_json_config_file( @pytest.mark.usefixtures("repo_with_git_flow_angular_commits") def test_errors_when_config_file_does_not_exist_and_passed_explicitly( - cli_runner, + cli_runner: CliRunner, ): result = cli_runner.invoke( main, @@ -100,9 +130,22 @@ def test_errors_when_config_file_does_not_exist_and_passed_explicitly( assert "does not exist" in result.stderr +@pytest.mark.usefixtures("repo_with_no_tags_angular_commits") +def test_errors_when_config_file_invalid_configuration( + cli_runner: CliRunner, update_pyproject_toml: UpdatePyprojectTomlFn +): + update_pyproject_toml("tool.semantic_release.remote.type", "invalidType") + result = cli_runner.invoke(main, ["--config", "pyproject.toml", "version"]) + + stderr_lines = result.stderr.splitlines() + assert result.exit_code == 1 + assert "1 validation error for RawConfig" in stderr_lines[0] + assert "remote.type" in stderr_lines[1] + + def test_uses_default_config_when_no_config_file_found( - tmp_path, - cli_runner, + tmp_path: Path, + cli_runner: CliRunner, ): # We have to initialise an empty git repository, as the example projects # all have pyproject.toml configs which would be used by default @@ -111,6 +154,7 @@ def test_uses_default_config_when_no_config_file_found( with repo.config_writer("repository") as config: config.set_value("user", "name", "semantic release testing") config.set_value("user", "email", "not_a_real@email.com") + config.set_value("commit", "gpgsign", False) repo.create_remote(name="origin", url="foo@barvcs.com:user/repo.git") repo.git.commit("-m", "feat: initial commit", "--allow-empty") diff --git a/tests/command_line/test_version.py b/tests/command_line/test_version.py index 5856b7129..fc648128d 100644 --- a/tests/command_line/test_version.py +++ b/tests/command_line/test_version.py @@ -19,15 +19,26 @@ actions_output_to_dict, flatten_dircmp, get_release_history_from_context, + remove_dir_tree, ) if TYPE_CHECKING: + from pathlib import Path from unittest.mock import MagicMock from click.testing import CliRunner + from git import Repo from requests_mock import Mocker - from semantic_release.cli.config import RuntimeContext + from tests.command_line.conftest import RetrieveRuntimeContextFn + from tests.fixtures.example_project import ( + ExProjectDir, + UpdatePyprojectTomlFn, + UseReleaseNotesTemplateFn, + ) + + +version_subcmd = version.name or version.__name__ @pytest.mark.parametrize( @@ -36,34 +47,39 @@ lazy_fixture("repo_with_no_tags_angular_commits"), lazy_fixture("repo_with_single_branch_angular_commits"), lazy_fixture("repo_with_single_branch_and_prereleases_angular_commits"), - lazy_fixture("repo_with_main_and_feature_branches_angular_commits"), + lazy_fixture("repo_w_github_flow_w_feature_release_channel_angular_commits"), lazy_fixture("repo_with_git_flow_angular_commits"), lazy_fixture("repo_with_git_flow_and_release_channels_angular_commits"), ], ) -def test_version_noop_is_noop(tmp_path_factory, example_project, repo, cli_runner): +def test_version_noop_is_noop( + tmp_path_factory: pytest.TempPathFactory, + example_project_dir: ExProjectDir, + repo: Repo, + cli_runner: CliRunner, +): # Make a commit to ensure we have something to release # otherwise the "no release will be made" logic will kick in first - new_file = example_project / "temp.txt" + new_file = example_project_dir / "temp.txt" new_file.write_text("noop version test") repo.git.add(str(new_file.resolve())) repo.git.commit(m="feat: temp new file") tempdir = tmp_path_factory.mktemp("test_noop") - shutil.rmtree(str(tempdir.resolve())) - shutil.copytree(src=str(example_project.resolve()), dst=tempdir) + remove_dir_tree(tempdir.resolve(), force=True) + shutil.copytree(src=str(example_project_dir.resolve()), dst=tempdir) head_before = repo.head.commit tags_before = sorted(repo.tags, key=lambda tag: tag.name) - result = cli_runner.invoke(main, ["--noop", version.name]) + result = cli_runner.invoke(main, ["--noop", version_subcmd]) tags_after = sorted(repo.tags, key=lambda tag: tag.name) head_after = repo.head.commit assert result.exit_code == 0 - dcmp = filecmp.dircmp(str(example_project.resolve()), tempdir) + dcmp = filecmp.dircmp(str(example_project_dir.resolve()), tempdir) differing_files = flatten_dircmp(dcmp) assert not differing_files @@ -142,7 +158,9 @@ def test_version_noop_is_noop(tmp_path_factory, example_project, repo, cli_runne ], *[ ( - lazy_fixture("repo_with_main_and_feature_branches_angular_commits"), + lazy_fixture( + "repo_w_github_flow_w_feature_release_channel_angular_commits" + ), cli_args, expected_stdout, ) @@ -206,23 +224,28 @@ def test_version_noop_is_noop(tmp_path_factory, example_project, repo, cli_runne ], ) def test_version_print( - repo, cli_args, expected_stdout, example_project, tmp_path_factory, cli_runner + repo: Repo, + cli_args: list[str], + expected_stdout: str, + example_project_dir: ExProjectDir, + tmp_path_factory: pytest.TempPathFactory, + cli_runner: CliRunner, ): # Make a commit to ensure we have something to release # otherwise the "no release will be made" logic will kick in first - new_file = example_project / "temp.txt" + new_file = example_project_dir / "temp.txt" new_file.write_text("noop version test") repo.git.add(str(new_file.resolve())) repo.git.commit(m="fix: temp new file") tempdir = tmp_path_factory.mktemp("test_version_print") - shutil.rmtree(str(tempdir.resolve())) - shutil.copytree(src=str(example_project.resolve()), dst=tempdir) + remove_dir_tree(tempdir.resolve(), force=True) + shutil.copytree(src=str(example_project_dir.resolve()), dst=tempdir) head_before = repo.head.commit tags_before = sorted(repo.tags, key=lambda tag: tag.name) - result = cli_runner.invoke(main, [version.name, *cli_args, "--print"]) + result = cli_runner.invoke(main, [version_subcmd, *cli_args, "--print"]) tags_after = sorted(repo.tags, key=lambda tag: tag.name) head_after = repo.head.commit @@ -231,7 +254,7 @@ def test_version_print( assert tags_before == tags_after assert head_before == head_after assert result.stdout.rstrip("\n") == expected_stdout - dcmp = filecmp.dircmp(str(example_project.resolve()), tempdir) + dcmp = filecmp.dircmp(str(example_project_dir.resolve()), tempdir) differing_files = flatten_dircmp(dcmp) assert not differing_files @@ -243,16 +266,16 @@ def test_version_print( # so excluding lazy_fixture("repo_with_no_tags_angular_commits"), lazy_fixture("repo_with_single_branch_angular_commits"), lazy_fixture("repo_with_single_branch_and_prereleases_angular_commits"), - lazy_fixture("repo_with_main_and_feature_branches_angular_commits"), + lazy_fixture("repo_w_github_flow_w_feature_release_channel_angular_commits"), lazy_fixture("repo_with_git_flow_angular_commits"), lazy_fixture("repo_with_git_flow_and_release_channels_angular_commits"), ], ) -def test_version_already_released_no_push(repo, cli_runner): +def test_version_already_released_no_push(repo: Repo, cli_runner: CliRunner): # In these tests, unless arguments are supplied then the latest version # has already been released, so we expect an exit code of 2 with the message # to indicate that no release will be made - result = cli_runner.invoke(main, ["--strict", version.name, "--no-push"]) + result = cli_runner.invoke(main, ["--strict", version_subcmd, "--no-push"]) assert result.exit_code == 2 assert "no release will be made" in result.stderr.lower() @@ -322,7 +345,9 @@ def test_version_already_released_no_push(repo, cli_runner): ], *[ ( - lazy_fixture("repo_with_main_and_feature_branches_angular_commits"), + lazy_fixture( + "repo_w_github_flow_w_feature_release_channel_angular_commits" + ), cli_args, expected_stdout, ) @@ -383,21 +408,23 @@ def test_version_already_released_no_push(repo, cli_runner): ], ) def test_version_no_push_force_level( - repo, - cli_args, - expected_new_version, - example_project, - example_pyproject_toml, - tmp_path_factory, - cli_runner, + repo: Repo, + cli_args: list[str], + expected_new_version: str, + example_project_dir: ExProjectDir, + example_pyproject_toml: Path, + tmp_path_factory: pytest.TempPathFactory, + cli_runner: CliRunner, ): tempdir = tmp_path_factory.mktemp("test_version") - shutil.rmtree(str(tempdir.resolve())) - shutil.copytree(src=str(example_project.resolve()), dst=tempdir) + remove_dir_tree(tempdir.resolve(), force=True) + shutil.copytree(src=str(example_project_dir.resolve()), dst=tempdir) head_before = repo.head.commit tags_before = sorted(repo.tags, key=lambda tag: tag.name) - result = cli_runner.invoke(main, [version.name, *cli_args, "--no-push"]) + result = cli_runner.invoke( + main, [version_subcmd or "version", *cli_args, "--no-push"] + ) tags_after = sorted(repo.tags, key=lambda tag: tag.name) head_after = repo.head.commit @@ -408,14 +435,17 @@ def test_version_no_push_force_level( assert head_before != head_after # A commit has been made assert head_before in repo.head.commit.parents - dcmp = filecmp.dircmp(str(example_project.resolve()), tempdir) - differing_files = flatten_dircmp(dcmp) + dcmp = filecmp.dircmp(str(example_project_dir.resolve()), tempdir) + differing_files = sorted(flatten_dircmp(dcmp)) # Changelog already reflects changes this should introduce - assert differing_files == [ - "pyproject.toml", - f"src/{EXAMPLE_PROJECT_NAME}/__init__.py", - ] + assert differing_files == sorted( + [ + "CHANGELOG.md", + "pyproject.toml", + f"src/{EXAMPLE_PROJECT_NAME}/_version.py", + ] + ) # Compare pyproject.toml new_pyproject_toml = tomlkit.loads( @@ -433,14 +463,14 @@ def test_version_no_push_force_level( assert old_pyproject_toml == new_pyproject_toml assert new_version == expected_new_version - # Compare __init__.py + # Compare _version.py new_init_py = ( - (example_project / "src" / EXAMPLE_PROJECT_NAME / "__init__.py") + (example_project_dir / "src" / EXAMPLE_PROJECT_NAME / "_version.py") .read_text(encoding="utf-8") .splitlines(keepends=True) ) old_init_py = ( - (tempdir / "src" / EXAMPLE_PROJECT_NAME / "__init__.py") + (tempdir / "src" / EXAMPLE_PROJECT_NAME / "_version.py") .read_text(encoding="utf-8") .splitlines(keepends=True) ) @@ -460,31 +490,31 @@ def test_version_no_push_force_level( [ lazy_fixture("repo_with_single_branch_angular_commits"), lazy_fixture("repo_with_single_branch_and_prereleases_angular_commits"), - lazy_fixture("repo_with_main_and_feature_branches_angular_commits"), + lazy_fixture("repo_w_github_flow_w_feature_release_channel_angular_commits"), lazy_fixture("repo_with_git_flow_angular_commits"), lazy_fixture("repo_with_git_flow_and_release_channels_angular_commits"), ], ) -def test_version_build_metadata_triggers_new_version(repo, cli_runner): +def test_version_build_metadata_triggers_new_version(repo: Repo, cli_runner: CliRunner): # Verify we get "no version to release" without build metadata no_metadata_result = cli_runner.invoke( - main, ["--strict", version.name, "--no-push"] + main, ["--strict", version_subcmd, "--no-push"] ) assert no_metadata_result.exit_code == 2 assert "no release will be made" in no_metadata_result.stderr.lower() metadata_suffix = "build.abc-12345" result = cli_runner.invoke( - main, [version.name, "--no-push", "--build-metadata", metadata_suffix] + main, [version_subcmd, "--no-push", "--build-metadata", metadata_suffix] ) assert result.exit_code == 0 assert repo.git.tag(l=f"*{metadata_suffix}") def test_version_prints_current_version_if_no_new_version( - repo_with_git_flow_angular_commits, cli_runner + repo_with_git_flow_angular_commits: Repo, cli_runner: CliRunner ): - result = cli_runner.invoke(main, [version.name, "--no-push"]) + result = cli_runner.invoke(main, [version_subcmd or "version", "--no-push"]) assert result.exit_code == 0 assert "no release will be made" in result.stderr.lower() assert result.stdout == "1.2.0-alpha.2\n" @@ -492,19 +522,27 @@ def test_version_prints_current_version_if_no_new_version( @pytest.mark.parametrize("shell", ("/usr/bin/bash", "/usr/bin/zsh", "powershell")) def test_version_runs_build_command( - repo_with_git_flow_angular_commits, cli_runner, example_pyproject_toml, shell + repo_with_git_flow_angular_commits: Repo, + cli_runner: CliRunner, + example_pyproject_toml: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, + shell: str, ): - config = tomlkit.loads(example_pyproject_toml.read_text(encoding="utf-8")) - build_command = config["tool"]["semantic_release"]["build_command"] # type: ignore[attr-defined] + # Setup + build_command = "bash -c \"echo 'hello world'\"" + update_pyproject_toml("tool.semantic_release.build_command", build_command) exe = shell.split("/")[-1] + + # Mock out subprocess.run with mock.patch( "subprocess.run", return_value=CompletedProcess(args=(), returncode=0) ) as patched_subprocess_run, mock.patch( "shellingham.detect_shell", return_value=(exe, shell) ): + # ACT: run & force a new version that will trigger the build command result = cli_runner.invoke( - main, [version.name, "--patch", "--no-push"] - ) # force a new version + main, [version_subcmd or "version", "--patch", "--no-push"] + ) assert result.exit_code == 0 patched_subprocess_run.assert_called_once_with( @@ -519,7 +557,7 @@ def test_version_skips_build_command_with_skip_build( "subprocess.run", return_value=CompletedProcess(args=(), returncode=0) ) as patched_subprocess_run: result = cli_runner.invoke( - main, [version.name, "--patch", "--no-push", "--skip-build"] + main, [version_subcmd, "--patch", "--no-push", "--skip-build"] ) # force a new version assert result.exit_code == 0 @@ -531,7 +569,7 @@ def test_version_writes_github_actions_output( ): mock_output_file = tmp_path / "action.out" monkeypatch.setenv("GITHUB_OUTPUT", str(mock_output_file.resolve())) - result = cli_runner.invoke(main, [version.name, "--patch", "--no-push"]) + result = cli_runner.invoke(main, [version_subcmd, "--patch", "--no-push"]) assert result.exit_code == 0 action_outputs = actions_output_to_dict( @@ -546,7 +584,7 @@ def test_version_writes_github_actions_output( def test_version_exit_code_when_strict(repo_with_git_flow_angular_commits, cli_runner): - result = cli_runner.invoke(main, ["--strict", version.name, "--no-push"]) + result = cli_runner.invoke(main, ["--strict", version_subcmd, "--no-push"]) assert result.exit_code != 0 @@ -554,26 +592,34 @@ def test_version_exit_code_when_not_strict( repo_with_git_flow_angular_commits, cli_runner ): # Testing "no release will be made" - result = cli_runner.invoke(main, [version.name, "--no-push"]) + result = cli_runner.invoke(main, [version_subcmd, "--no-push"]) assert result.exit_code == 0 -@pytest.mark.usefixtures("example_project_with_release_notes_template") def test_custom_release_notes_template( mocked_git_push: MagicMock, - runtime_context_with_no_tags: RuntimeContext, + repo_with_no_tags_angular_commits: Repo, + use_release_notes_template: UseReleaseNotesTemplateFn, + retrieve_runtime_context: RetrieveRuntimeContextFn, post_mocker: Mocker, cli_runner: CliRunner, ) -> None: """Verify the template `.release_notes.md.j2` from `template_dir` is used.""" - # Arrange - # (see fixtures) + # Setup + use_release_notes_template() + runtime_context_with_no_tags = retrieve_runtime_context( + repo_with_no_tags_angular_commits + ) # Act - resp = cli_runner.invoke(main, [version.name, "--skip-build", "--vcs-release"]) + resp = cli_runner.invoke(main, [version_subcmd, "--skip-build", "--vcs-release"]) release_history = get_release_history_from_context(runtime_context_with_no_tags) tag = runtime_context_with_no_tags.repo.tags[-1].name + release_version = runtime_context_with_no_tags.version_translator.from_tag(tag) + if release_version is None: + raise ValueError(f"Could not translate tag '{tag}' to version") + release = release_history.released[release_version] expected_release_notes = ( @@ -586,7 +632,166 @@ def test_custom_release_notes_template( assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert resp.exit_code == 0, ( "Unexpected failure in command " - f"'semantic-release {version.name} --skip-build --vcs-release': " + resp.stderr + f"'semantic-release {version_subcmd} --skip-build --vcs-release': " + + resp.stderr ) assert post_mocker.call_count == 1 + assert post_mocker.last_request is not None assert post_mocker.last_request.json()["body"] == expected_release_notes + + +def test_version_tag_only_push( + mocked_git_push: MagicMock, + repo_with_no_tags_angular_commits: Repo, + retrieve_runtime_context: RetrieveRuntimeContextFn, + cli_runner: CliRunner, +) -> None: + # Setup + runtime_context_with_no_tags = retrieve_runtime_context( + repo_with_no_tags_angular_commits + ) + head_before = runtime_context_with_no_tags.repo.head.commit + + # Act + args = [version_subcmd, "--tag", "--no-commit", "--skip-build", "--no-vcs-release"] + resp = cli_runner.invoke(main, args) + + tag_after = runtime_context_with_no_tags.repo.tags[-1].name + head_after = runtime_context_with_no_tags.repo.head.commit + + # Assert + assert tag_after == "v0.1.0" + assert head_before == head_after + assert mocked_git_push.call_count == 1 # 0 for commit, 1 for tag + assert resp.exit_code == 0, ( + "Unexpected failure in command " + f"'semantic-release {str.join(' ', args)}': " + resp.stderr + ) + + +def test_version_only_update_files_no_git_actions( + mocked_git_push: MagicMock, + repo_with_single_branch_and_prereleases_angular_commits: Repo, + retrieve_runtime_context: RetrieveRuntimeContextFn, + cli_runner: CliRunner, + tmp_path_factory: pytest.TempPathFactory, + example_pyproject_toml: Path, + example_project_dir: ExProjectDir, + example_changelog_md: Path, +) -> None: + # Setup + runtime_context_with_tags = retrieve_runtime_context( + repo_with_single_branch_and_prereleases_angular_commits + ) + # Remove the previously created changelog to allow for it to be generated + example_changelog_md.unlink() + + # Arrange + expected_new_version = "0.3.0" + tempdir = tmp_path_factory.mktemp("test_version") + remove_dir_tree(tempdir.resolve(), force=True) + shutil.copytree(src=str(example_project_dir), dst=tempdir) + + head_before = runtime_context_with_tags.repo.head.commit + tags_before = runtime_context_with_tags.repo.tags + + # Act + args = [version_subcmd, "--minor", "--no-tag", "--no-commit", "--skip-build"] + resp = cli_runner.invoke(main, args) + + tags_after = runtime_context_with_tags.repo.tags + head_after = runtime_context_with_tags.repo.head.commit + + # Assert + assert tags_before == tags_after + assert head_before == head_after + assert ( + mocked_git_push.call_count == 0 + ) # no push as it should be turned off automatically + assert resp.exit_code == 0, ( + "Unexpected failure in command " + f"'semantic-release {str.join(' ', args)}': " + resp.stderr + ) + + dcmp = filecmp.dircmp(str(example_project_dir.resolve()), tempdir) + differing_files = sorted(flatten_dircmp(dcmp)) + + # Files that should receive version change + expected_changed_files = sorted( + [ + "CHANGELOG.md", + "pyproject.toml", + f"src/{EXAMPLE_PROJECT_NAME}/_version.py", + ] + ) + assert expected_changed_files == differing_files + + # Compare pyproject.toml + new_pyproject_toml = tomlkit.loads( + example_pyproject_toml.read_text(encoding="utf-8") + ) + old_pyproject_toml = tomlkit.loads( + (tempdir / "pyproject.toml").read_text(encoding="utf-8") + ) + + old_pyproject_toml["tool"]["poetry"].pop("version") # type: ignore[attr-defined] + new_version = new_pyproject_toml["tool"]["poetry"].pop( # type: ignore[attr-defined] # type: ignore[attr-defined] + "version" + ) + + assert old_pyproject_toml == new_pyproject_toml + assert new_version == expected_new_version + + # Compare _version.py + new_version_py = ( + (example_project_dir / "src" / EXAMPLE_PROJECT_NAME / "_version.py") + .read_text(encoding="utf-8") + .splitlines(keepends=True) + ) + old_version_py = ( + (tempdir / "src" / EXAMPLE_PROJECT_NAME / "_version.py") + .read_text(encoding="utf-8") + .splitlines(keepends=True) + ) + + d = difflib.Differ() + diff = list(d.compare(old_version_py, new_version_py)) + added = [line[2:] for line in diff if line.startswith("+ ")] + removed = [line[2:] for line in diff if line.startswith("- ")] + + assert len(removed) == 1 + assert re.match('__version__ = ".*"', removed[0]) + assert added == [f'__version__ = "{expected_new_version}"\n'] + + +def test_version_print_last_released_prints_version( + repo_with_single_branch_tag_commits: Repo, cli_runner: CliRunner +): + result = cli_runner.invoke(main, [version.name, "--print-last-released"]) + assert result.exit_code == 0 + assert result.stdout == "0.1.1\n" + + +def test_version_print_last_released_prints_released_if_commits( + repo_with_single_branch_tag_commits: Repo, + example_project_dir: ExProjectDir, + cli_runner: CliRunner, +): + new_file = example_project_dir / "temp.txt" + new_file.write_text("test --print-last-released") + + repo_with_single_branch_tag_commits.git.add(str(new_file.resolve())) + repo_with_single_branch_tag_commits.git.commit(m="fix: temp new file") + + result = cli_runner.invoke(main, [version.name, "--print-last-released"]) + assert result.exit_code == 0 + assert result.stdout == "0.1.1\n" + + +def test_version_print_last_released_prints_nothing_if_no_tags( + caplog, repo_with_no_tags_angular_commits: Repo, cli_runner: CliRunner +): + result = cli_runner.invoke(main, [version.name, "--print-last-released"]) + assert result.exit_code == 0 + assert result.stdout == "" + assert "No release tags found." in caplog.text diff --git a/tests/conftest.py b/tests/conftest.py index 1c7f203b4..bff679153 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,2 +1,39 @@ """Note: fixtures are stored in the tests/fixtures directory for better organisation""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + from tests.fixtures import * +from tests.util import remove_dir_tree + +if TYPE_CHECKING: + from typing import Generator, Protocol + + class TeardownCachedDirFn(Protocol): + def __call__(self, directory: Path) -> Path: ... + + +@pytest.fixture(scope="session") +def cached_files_dir(tmp_path_factory: pytest.TempPathFactory) -> Path: + return tmp_path_factory.mktemp("cached_files_dir") + + +@pytest.fixture(scope="session") +def teardown_cached_dir() -> Generator[TeardownCachedDirFn, None, None]: + directories: list[Path] = [] + + def _teardown_cached_dir(directory: Path | str) -> Path: + directories.append(Path(directory)) + return directories[-1] + + try: + yield _teardown_cached_dir + finally: + # clean up any registered cached directories + for directory in directories: + if directory.exists(): + remove_dir_tree(directory, force=True) diff --git a/tests/const.py b/tests/const.py index 1dcaa0756..9801b13c7 100644 --- a/tests/const.py +++ b/tests/const.py @@ -1,4 +1,4 @@ -from semantic_release.const import DEFAULT_COMMIT_AUTHOR +from datetime import datetime A_FULL_VERSION_STRING = "1.11.567" A_PRERELEASE_VERSION_STRING = "2.3.4-dev.23" @@ -6,6 +6,12 @@ EXAMPLE_REPO_OWNER = "example_owner" EXAMPLE_REPO_NAME = "example_repo" +EXAMPLE_HVCS_DOMAIN = "example.com" + +SUCCESS_EXIT_CODE = 0 + +TODAY_DATE_STR = datetime.now().strftime("%Y-%m-%d") +"""Date formatted as how it would appear in the changelog (Must match local timezone)""" COMMIT_MESSAGE = "{version}\n\nAutomatically generated by python-semantic-release\n" @@ -125,8 +131,11 @@ ) EXAMPLE_PROJECT_NAME = "example" -EXAMPLE_PROJECT_VERSION = "0.2.2" +EXAMPLE_PROJECT_VERSION = "0.0.0" +# Uses the internal defaults of semantic-release unless otherwise needed for testing +# modify the pyproject toml as necessary for the test using update_pyproject_toml() +# and derivative fixtures EXAMPLE_PYPROJECT_TOML_CONTENT = rf""" [tool.poetry] name = "{EXAMPLE_PROJECT_NAME}" @@ -136,164 +145,20 @@ authors = ["semantic-release "] readme = "README.md" classifiers = [ - "Development Status :: 2 - Pre-Alpha", - "Framework :: FastAPI", - "Framework :: Pytest", - "Intended Audience :: Education", "Natural Language :: English", "Operating System :: OS Independent", - "Programming Language :: Python :: 3 :: Only", - "Topic :: Education", - "Topic :: Internet :: WWW/HTTP :: WSGI :: Server", + "Programming Language :: Python :: 3 :: Only" ] -[tool.poetry.urls] -"Repository" = "https://github.com/python-semantic-release/python-semantic-release" -"Bug Tracker" = "https://github.com/python-semantic-release/python-semantic-release" -"Homepage" = "https://github.com/python-semantic-release/python-semantic-release" - -[tool.poetry.scripts] -hello-world = "hello-world:main" - -[tool.poetry.dependencies] -python = "^3.8" -fastapi = "^0.74.0" -uvicorn = "^0.17.5" -PyYAML = "^6.0" -python-dotenv = "^0.19.2" -motor = "^2.5.1" -pymongo = "^3.12.0" - -[tool.poetry.dev-dependencies] -pytest = "^6.2" -bandit = "^1.7.2" -mypy = "0.931" -black = "^22.1.0" -safety = "^1.10.3" -flake8 = "^4.0.1" -types-PyYAML = "^6.0.4" -python-semantic-release = "^7.25.2" - [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.semantic_release] version_variables = [ - "src/{EXAMPLE_PROJECT_NAME}/__init__.py:__version__", + "src/{EXAMPLE_PROJECT_NAME}/_version.py:__version__", ] version_toml = ["pyproject.toml:tool.poetry.version"] -build_command = "bash -c \"echo 'Hello World'\"" -tag_format = "v{{version}}" -commit_parser = "angular" -commit_author = {{ env = "GIT_COMMIT_AUTHOR", default = "{DEFAULT_COMMIT_AUTHOR}" }} -commit_message = "{{version}}\n\nAutomatically generated by python-semantic-release" -major_on_zero = true -assets = [] - -[tool.semantic_release.commit_parser_options] -allowed_tags = [ - "build", - "chore", - "ci", - "docs", - "feat", - "fix", - "perf", - "style", - "refactor", - "test", -] -minor_tags = ["feat"] -patch_tags = ["fix", "perf"] - -[tool.semantic_release.changelog] -template_dir = "templates" -default_output_file = "TEST_CHANGELOG.md" - -[tool.semantic_release.changelog.environment] -block_start_string = "{{%" -block_end_string = "%}}" -variable_start_string = "{{{{" -variable_end_string = "}}}}" -comment_start_string = "{{#" -comment_end_string = "#}}" -trim_blocks = false -lstrip_blocks = false -newline_sequence = "\n" -keep_trailing_newline = false -extensions = [] -autoescape = true - -[tool.semantic_release.branches.main] -match = "(main|master)" -prerelease = false -prerelease_token = "rc" - -[tool.semantic_release.branches.release-candidates] -match = "rc-.*" -prerelease = true -prerelease_token = "rc" - -[tool.semantic_release.branches.features] -match = "feat.*" -prerelease = true -prerelease_token = "alpha" - -[tool.semantic_release.branches.beta-testing] -match = "beta.*" -prerelease = true -prerelease_token = "beta" - -[tool.semantic_release.remote] -name = "origin" -type = "github" -ignore_token_for_push = false - -[tool.semantic_release.publish] -dist_glob_patterns = ["dist/*"] -upload_to_vcs_release = false - -[tool.isort] -profile = "black" -src_paths = ["src"] -known_first_party = "{EXAMPLE_PROJECT_NAME}" -known_third_party = ["fastapi", "pydantic", "motor", "bson", "uvicorn"] -combine_as_imports = true - -[tool.mypy] -python_version=3.7 - -mypy_path="src" - -show_column_numbers=true -show_error_context=true -pretty=true -error_summary=true - -follow_imports="normal" -ignore_missing_imports=true - -disallow_untyped_calls=true -warn_return_any=true -strict_optional=true -warn_no_return=true -warn_redundant_casts=true -warn_unused_ignores=true -warn_unused_configs=true -disallow_any_generics=true - -warn_unreachable=true -disallow_untyped_defs=true -check_untyped_defs=true - -cache_dir="/dev/null" - -[[tool.mypy.overrides]] -module = "tests.*" -allow_untyped_defs = true -allow_incomplete_defs = true -allow_untyped_calls = true """ EXAMPLE_SETUP_CFG_CONTENT = rf""" @@ -375,7 +240,7 @@ def _read_long_description(): return None -with open("{EXAMPLE_PROJECT_NAME}/__init__.py", "r") as fd: +with open("{EXAMPLE_PROJECT_NAME}/_version.py", "r") as fd: version = re.search( r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), re.MULTILINE ).group(1) diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index 43c7abd7d..d9e987f57 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -1,4 +1,5 @@ from tests.fixtures.commit_parsers import * from tests.fixtures.example_project import * from tests.fixtures.git_repo import * +from tests.fixtures.repos import * from tests.fixtures.scipy import * diff --git a/tests/fixtures/example_project.py b/tests/fixtures/example_project.py index 0309b9859..ff7a70d88 100644 --- a/tests/fixtures/example_project.py +++ b/tests/fixtures/example_project.py @@ -1,10 +1,20 @@ +from __future__ import annotations + import os -from contextlib import contextmanager from pathlib import Path from textwrap import dedent -from typing import Generator +from typing import TYPE_CHECKING, Generator import pytest +import tomlkit + +from semantic_release.commit_parser import ( + AngularCommitParser, + EmojiCommitParser, + ScipyCommitParser, + TagCommitParser, +) +from semantic_release.hvcs import Bitbucket, Gitea, Github, Gitlab from tests.const import ( EXAMPLE_CHANGELOG_MD_CONTENT, @@ -15,80 +25,362 @@ EXAMPLE_SETUP_CFG_CONTENT, EXAMPLE_SETUP_PY_CONTENT, ) +from tests.util import copy_dir_tree + +if TYPE_CHECKING: + from typing import Any, Protocol + + from semantic_release.commit_parser import CommitParser + from semantic_release.hvcs import HvcsBase + + from tests.conftest import TeardownCachedDirFn + + ExProjectDir = Path + + class SetFlagFn(Protocol): + def __call__(self, flag: bool) -> None: ... + + class UpdatePyprojectTomlFn(Protocol): + def __call__(self, setting: str, value: Any) -> None: ... + + class UseHvcsFn(Protocol): + def __call__(self, domain: str | None = None) -> type[HvcsBase]: ... + + class UseParserFn(Protocol): + def __call__(self) -> type[CommitParser]: ... + + class UseReleaseNotesTemplateFn(Protocol): + def __call__(self) -> None: ... -@contextmanager -def cd(path: Path) -> Generator[Path, None, None]: +@pytest.fixture(scope="session") +def pyproject_toml_file() -> Path: + return Path("pyproject.toml") + + +@pytest.fixture(scope="session") +def setup_cfg_file() -> Path: + return Path("setup.cfg") + + +@pytest.fixture(scope="session") +def setup_py_file() -> Path: + return Path("setup.py") + + +@pytest.fixture(scope="session") +def changelog_md_file() -> Path: + return Path("CHANGELOG.md") + + +@pytest.fixture(scope="session") +def changelog_template_dir() -> Path: + return Path("templates") + + +@pytest.fixture +def example_project_dir(tmp_path: Path) -> ExProjectDir: + return tmp_path.resolve() + + +@pytest.fixture +def change_to_ex_proj_dir( + example_project_dir: ExProjectDir, +) -> Generator[None, None, None]: cwd = os.getcwd() - os.chdir(str(path.resolve())) - yield path - os.chdir(cwd) + tgt_dir = str(example_project_dir.resolve()) + if cwd == tgt_dir: + return + + os.chdir(tgt_dir) + try: + yield + finally: + os.chdir(cwd) + + +@pytest.fixture(scope="session") +def cached_example_project( + pyproject_toml_file: Path, + setup_cfg_file: Path, + setup_py_file: Path, + changelog_md_file: Path, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + """ + Initializes the example project. DO NOT USE DIRECTLY + + Use the `init_example_project` fixture instead. + """ + cached_project_path = (cached_files_dir / "example_project").resolve() + # purposefully a relative path + example_dir = Path("src", EXAMPLE_PROJECT_NAME) + version_py = example_dir / "_version.py" + gitignore_contents = dedent( + f""" + *.pyc + /src/**/{version_py.name} + """ + ).lstrip() + init_py_contents = dedent( + ''' + """ + An example package with a very informative docstring + """ + from ._version import __version__ + + + def hello_world() -> None: + print("Hello World") + ''' + ).lstrip() + version_py_contents = dedent( + f""" + __version__ = "{EXAMPLE_PROJECT_VERSION}" + """ + ).lstrip() + + for file, contents in [ + (example_dir / "__init__.py", init_py_contents), + (version_py, version_py_contents), + (".gitignore", gitignore_contents), + (pyproject_toml_file, EXAMPLE_PYPROJECT_TOML_CONTENT), + (setup_cfg_file, EXAMPLE_SETUP_CFG_CONTENT), + (setup_py_file, EXAMPLE_SETUP_PY_CONTENT), + (changelog_md_file, EXAMPLE_CHANGELOG_MD_CONTENT), + ]: + abs_filepath = cached_project_path.joinpath(file).resolve() + # make sure the parent directory exists + abs_filepath.parent.mkdir(parents=True, exist_ok=True) + # write file contents + abs_filepath.write_text(contents) + + # trigger automatic cleanup of cache directory during teardown + return teardown_cached_dir(cached_project_path) @pytest.fixture -def example_project(tmp_path): - with cd(tmp_path): - src_dir = tmp_path / "src" - src_dir.mkdir() - example_dir = src_dir / EXAMPLE_PROJECT_NAME - example_dir.mkdir() - init_py = example_dir / "__init__.py" - init_py.write_text( - dedent( - f''' - """ - An example package with a very informative docstring - """ - __version__ = "{EXAMPLE_PROJECT_VERSION}" - - - def hello_world() -> None: - print("Hello World") - ''' - ) +def init_example_project( + example_project_dir: ExProjectDir, + cached_example_project: Path, + change_to_ex_proj_dir: None, +) -> None: + """This fixture initializes the example project in the current test's project directory.""" + if not cached_example_project.exists(): + raise RuntimeError( + f"Unable to find cached project files for {EXAMPLE_PROJECT_NAME}" ) - pyproject_toml = tmp_path / "pyproject.toml" - pyproject_toml.write_text(EXAMPLE_PYPROJECT_TOML_CONTENT) - setup_cfg = tmp_path / "setup.cfg" - setup_cfg.write_text(EXAMPLE_SETUP_CFG_CONTENT) - setup_py = tmp_path / "setup.py" - setup_py.write_text(EXAMPLE_SETUP_PY_CONTENT) - template_dir = tmp_path / "templates" - template_dir.mkdir() - changelog_md = tmp_path / "CHANGELOG.md" - changelog_md.write_text(EXAMPLE_CHANGELOG_MD_CONTENT) - yield tmp_path + + # Copy the cached project files into the current test's project directory + copy_dir_tree(cached_example_project, example_project_dir) + + +@pytest.fixture +def example_project_with_release_notes_template( + init_example_project: None, + use_release_notes_template: UseReleaseNotesTemplateFn, +) -> None: + use_release_notes_template() @pytest.fixture -def example_project_with_release_notes_template(example_project: Path) -> Path: - template_dir = example_project / "templates" - release_notes_j2 = template_dir / ".release_notes.md.j2" - release_notes_j2.write_text(EXAMPLE_RELEASE_NOTES_TEMPLATE) - return example_project +def use_release_notes_template( + example_project_template_dir: Path, + changelog_template_dir: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +) -> UseReleaseNotesTemplateFn: + def _use_release_notes_template() -> None: + update_pyproject_toml( + "tool.semantic_release.changelog.template_dir", + str(changelog_template_dir), + ) + example_project_template_dir.mkdir(parents=True, exist_ok=True) + release_notes_j2 = example_project_template_dir / ".release_notes.md.j2" + release_notes_j2.write_text(EXAMPLE_RELEASE_NOTES_TEMPLATE) + + return _use_release_notes_template @pytest.fixture -def example_pyproject_toml(example_project): - return example_project / "pyproject.toml" +def example_pyproject_toml( + example_project_dir: ExProjectDir, + pyproject_toml_file: Path, +) -> Path: + return example_project_dir / pyproject_toml_file @pytest.fixture -def example_setup_cfg(example_project): - return example_project / "setup.cfg" +def example_setup_cfg( + example_project_dir: ExProjectDir, + setup_cfg_file: Path, +) -> Path: + return example_project_dir / setup_cfg_file @pytest.fixture -def example_setup_py(example_project): - return example_project / "setup.py" +def example_setup_py( + example_project_dir: ExProjectDir, + setup_py_file: Path, +) -> Path: + return example_project_dir / setup_py_file # Note this is just the path and the content may change @pytest.fixture -def example_changelog_md(example_project): - return example_project / "CHANGELOG.md" +def example_changelog_md( + example_project_dir: ExProjectDir, + changelog_md_file: Path, +) -> Path: + return example_project_dir / changelog_md_file @pytest.fixture -def example_project_template_dir(example_project): - return example_project / "templates" +def example_project_template_dir( + example_project_dir: ExProjectDir, + changelog_template_dir: Path, +) -> Path: + return example_project_dir / changelog_template_dir + + +@pytest.fixture(scope="session") +def update_pyproject_toml(pyproject_toml_file: Path) -> UpdatePyprojectTomlFn: + """Update the pyproject.toml file with the given content.""" + + def _update_pyproject_toml(setting: str, value: Any) -> None: + cwd_pyproject_toml = pyproject_toml_file.resolve() + with open(cwd_pyproject_toml) as rfd: + pyproject_toml = tomlkit.load(rfd) + + new_setting = {} + parts = setting.split(".") + new_setting_key = parts.pop(-1) + new_setting[new_setting_key] = value + + pointer = pyproject_toml + for part in parts: + if pointer.get(part, None) is None: + pointer.add(part, tomlkit.table()) + pointer = pointer.get(part, {}) + pointer.update(new_setting) + + with open(cwd_pyproject_toml, "w") as wfd: + tomlkit.dump(pyproject_toml, wfd) + + return _update_pyproject_toml + + +@pytest.fixture +def set_major_on_zero(update_pyproject_toml: UpdatePyprojectTomlFn) -> SetFlagFn: + """Turn on/off the major_on_zero setting.""" + + def _set_major_on_zero(flag: bool) -> None: + update_pyproject_toml("tool.semantic_release.major_on_zero", flag) + + return _set_major_on_zero + + +@pytest.fixture +def set_allow_zero_version(update_pyproject_toml: UpdatePyprojectTomlFn) -> SetFlagFn: + """Turn on/off the allow_zero_version setting.""" + + def _set_allow_zero_version(flag: bool) -> None: + update_pyproject_toml("tool.semantic_release.allow_zero_version", flag) + + return _set_allow_zero_version + + +@pytest.fixture(scope="session") +def use_angular_parser(update_pyproject_toml: UpdatePyprojectTomlFn) -> UseParserFn: + """Modify the configuration file to use the Angular parser.""" + + def _use_angular_parser() -> type[CommitParser]: + update_pyproject_toml("tool.semantic_release.commit_parser", "angular") + return AngularCommitParser + + return _use_angular_parser + + +@pytest.fixture(scope="session") +def use_emoji_parser(update_pyproject_toml: UpdatePyprojectTomlFn) -> UseParserFn: + """Modify the configuration file to use the Emoji parser.""" + + def _use_emoji_parser() -> type[CommitParser]: + update_pyproject_toml("tool.semantic_release.commit_parser", "emoji") + return EmojiCommitParser + + return _use_emoji_parser + + +@pytest.fixture(scope="session") +def use_scipy_parser(update_pyproject_toml: UpdatePyprojectTomlFn) -> UseParserFn: + """Modify the configuration file to use the Scipy parser.""" + + def _use_scipy_parser() -> type[CommitParser]: + update_pyproject_toml("tool.semantic_release.commit_parser", "scipy") + return ScipyCommitParser + + return _use_scipy_parser + + +@pytest.fixture(scope="session") +def use_tag_parser(update_pyproject_toml: UpdatePyprojectTomlFn) -> UseParserFn: + """Modify the configuration file to use the Tag parser.""" + + def _use_tag_parser() -> type[CommitParser]: + update_pyproject_toml("tool.semantic_release.commit_parser", "tag") + return TagCommitParser + + return _use_tag_parser + + +@pytest.fixture(scope="session") +def use_github_hvcs(update_pyproject_toml: UpdatePyprojectTomlFn) -> UseHvcsFn: + """Modify the configuration file to use GitHub as the HVCS.""" + + def _use_github_hvcs(domain: str | None = None) -> type[HvcsBase]: + update_pyproject_toml("tool.semantic_release.remote.type", "github") + if domain is not None: + update_pyproject_toml("tool.semantic_release.remote.domain", domain) + return Github + + return _use_github_hvcs + + +@pytest.fixture(scope="session") +def use_gitlab_hvcs(update_pyproject_toml: UpdatePyprojectTomlFn) -> UseHvcsFn: + """Modify the configuration file to use GitLab as the HVCS.""" + + def _use_gitlab_hvcs(domain: str | None = None) -> type[HvcsBase]: + update_pyproject_toml("tool.semantic_release.remote.type", "gitlab") + if domain is not None: + update_pyproject_toml("tool.semantic_release.remote.domain", domain) + return Gitlab + + return _use_gitlab_hvcs + + +@pytest.fixture(scope="session") +def use_gitea_hvcs(update_pyproject_toml: UpdatePyprojectTomlFn) -> UseHvcsFn: + """Modify the configuration file to use Gitea as the HVCS.""" + + def _use_gitea_hvcs(domain: str | None = None) -> type[HvcsBase]: + update_pyproject_toml("tool.semantic_release.remote.type", "gitea") + if domain is not None: + update_pyproject_toml("tool.semantic_release.remote.domain", domain) + return Gitea + + return _use_gitea_hvcs + + +@pytest.fixture(scope="session") +def use_bitbucket_hvcs(update_pyproject_toml: UpdatePyprojectTomlFn) -> UseHvcsFn: + """Modify the configuration file to use BitBucket as the HVCS.""" + + def _use_bitbucket_hvcs(domain: str | None = None) -> type[HvcsBase]: + update_pyproject_toml("tool.semantic_release.remote.type", "bitbucket") + if domain is not None: + update_pyproject_toml("tool.semantic_release.remote.domain", domain) + return Bitbucket + + return _use_bitbucket_hvcs diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index 67b0e68ca..65ad7ffbd 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -1,1273 +1,387 @@ -import pytest -from git import Actor, Repo -from pytest_lazyfixture import lazy_fixture - -from tests.const import COMMIT_MESSAGE, EXAMPLE_REPO_NAME, EXAMPLE_REPO_OWNER -from tests.util import add_text_to_file, shortuid - - -@pytest.fixture -def commit_author(): - return Actor(name="semantic release testing", email="not_a_real@email.com") - - -@pytest.fixture -def file_in_repo(): - return f"file-{shortuid()}.txt" +from __future__ import annotations +from pathlib import Path +from typing import TYPE_CHECKING -@pytest.fixture -def example_git_ssh_url(): - return f"git@example.com:{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}.git" - - -@pytest.fixture -def example_git_https_url(): - return f"https://example.com/{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}" - +import pytest +from git import Actor, Repo -@pytest.fixture( - # For the moment there's no value in re-running every test that wants a repo - # twice, once with a different URL format - params=[lazy_fixture("example_git_ssh_url")] +from tests.const import ( + COMMIT_MESSAGE, + EXAMPLE_HVCS_DOMAIN, + EXAMPLE_REPO_NAME, + EXAMPLE_REPO_OWNER, + TODAY_DATE_STR, +) +from tests.util import ( + add_text_to_file, + copy_dir_tree, + shortuid, + temporary_working_directory, ) -def git_repo_factory(request, example_project): - """ - !!! WARNING !!! - You must call repo.close() after yield-ing the result of - calling this factory in a test, otherwise the test suite will fail with - OSError: Too Many Open Files - See https://github.com/pytest-dev/pytest/issues/2970#issuecomment-348033023 - """ - - def git_repo(): - repo = Repo.init(example_project.resolve()) - # Without this the global config may set it to "master", we want consistency - repo.git.branch("-M", "main") - with repo.config_writer("repository") as config: - config.set_value("user", "name", "semantic release testing") - config.set_value("user", "email", "not_a_real@email.com") - config.set_value("commit", "gpgsign", False) - repo.create_remote(name="origin", url=request.param) - return repo - - return git_repo - - -@pytest.fixture -def repo_with_no_tags_angular_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="fix: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: add much more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="fix: more text") - - yield git_repo - git_repo.close() - - -@pytest.fixture -def repo_with_no_tags_emoji_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":bug: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: add much more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":bug: more text") - - yield git_repo - git_repo.close() - - -@pytest.fixture -def repo_with_no_tags_scipy_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="MAINT: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="ENH: add much more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="MAINT: more text") - - yield git_repo - git_repo.close() - - -@pytest.fixture -def repo_with_no_tags_tag_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":nut_and_bolt: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: add much more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":nut_and_bolt: more text") - - yield git_repo - git_repo.close() - - -@pytest.fixture -def repo_with_single_branch_angular_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="fix: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1")) - git_repo.git.tag("v0.1.1", m="v0.1.1") - - assert git_repo.commit("v0.1.1").hexsha == git_repo.head.commit.hexsha - yield git_repo - git_repo.close() - - -@pytest.fixture -def repo_with_single_branch_emoji_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":bug: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1")) - git_repo.git.tag("v0.1.1", m="v0.1.1") - - assert git_repo.commit("v0.1.1").hexsha == git_repo.head.commit.hexsha - yield git_repo - git_repo.close() +if TYPE_CHECKING: + from typing import Generator, Literal, Protocol, TypedDict, Union -@pytest.fixture -def repo_with_single_branch_scipy_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") + from semantic_release.hvcs import HvcsBase - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") + from tests.conftest import TeardownCachedDirFn + from tests.fixtures.example_project import ( + ExProjectDir, + UpdatePyprojectTomlFn, + UseHvcsFn, + UseParserFn, + ) - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="MAINT: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1")) - git_repo.git.tag("v0.1.1", m="v0.1.1") + CommitConvention = Literal["angular", "emoji", "scipy", "tag"] + VersionStr = str + CommitMsg = str + ChangelogTypeHeading = str + TomlSerializableTypes = Union[dict, set, list, tuple, int, float, bool, str] - assert git_repo.commit("v0.1.1").hexsha == git_repo.head.commit.hexsha - yield git_repo - git_repo.close() + class RepoVersionDef(TypedDict): + """ + A reduced common repo definition, that is specific to a type of commit conventions + Used for builder functions that only need to know about a single commit convention type + """ -@pytest.fixture -def repo_with_single_branch_tag_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") + changelog_sections: list[ChangelogTypeHeadingDef] + commits: list[CommitMsg] - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") + class ChangelogTypeHeadingDef(TypedDict): + section: ChangelogTypeHeading + i_commits: list[int] + """List of indexes values to match to the commits list in the RepoVersionDef""" - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":nut_and_bolt: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1")) - git_repo.git.tag("v0.1.1", m="v0.1.1") + class BaseRepoVersionDef(TypedDict): + """A Common Repo definition for a get_commits_repo_*() fixture with all commit convention types""" - assert git_repo.commit("v0.1.1").hexsha == git_repo.head.commit.hexsha - yield git_repo - git_repo.close() + changelog_sections: dict[CommitConvention, list[ChangelogTypeHeadingDef]] + commits: list[dict[CommitConvention, CommitMsg]] + class BuildRepoFn(Protocol): + def __call__( + self, + dest_dir: Path | str, + commit_type: CommitConvention = ..., + hvcs_client_name: str = ..., + hvcs_domain: str = ..., + tag_format_str: str | None = None, + extra_configs: dict[str, TomlSerializableTypes] | None = None, + ) -> tuple[Path, HvcsBase]: ... -@pytest.fixture -def repo_with_single_branch_and_prereleases_angular_commits( - git_repo_factory, file_in_repo -): - git_repo = git_repo_factory() - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="fix: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0-rc.1")) - git_repo.git.tag("v0.2.0-rc.1", m="v0.2.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0")) - git_repo.git.tag("v0.2.0", m="v0.2.0") - - assert git_repo.commit("v0.2.0").hexsha == git_repo.head.commit.hexsha - yield git_repo - git_repo.close() + class CommitNReturnChangelogEntryFn(Protocol): + def __call__(self, git_repo: Repo, commit_msg: str, hvcs: HvcsBase) -> str: ... + class SimulateChangeCommitsNReturnChangelogEntryFn(Protocol): + def __call__( + self, git_repo: Repo, commit_msgs: list[CommitMsg], hvcs: HvcsBase + ) -> list[CommitMsg]: ... -@pytest.fixture -def repo_with_single_branch_and_prereleases_emoji_commits( - git_repo_factory, file_in_repo -): - git_repo = git_repo_factory() - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":bug: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0-rc.1")) - git_repo.git.tag("v0.2.0-rc.1", m="v0.2.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0")) - git_repo.git.tag("v0.2.0", m="v0.2.0") - - assert git_repo.commit("v0.2.0").hexsha == git_repo.head.commit.hexsha - yield git_repo - git_repo.close() + class CreateReleaseFn(Protocol): + def __call__( + self, git_repo: Repo, version: str, tag_format: str = ... + ) -> None: ... + class ExProjectGitRepoFn(Protocol): + def __call__(self) -> Repo: ... -@pytest.fixture -def repo_with_single_branch_and_prereleases_scipy_commits( - git_repo_factory, file_in_repo -): - git_repo = git_repo_factory() - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="MAINT: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="ENH: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0-rc.1")) - git_repo.git.tag("v0.2.0-rc.1", m="v0.2.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="ENH: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0")) - git_repo.git.tag("v0.2.0", m="v0.2.0") - - assert git_repo.commit("v0.2.0").hexsha == git_repo.head.commit.hexsha - yield git_repo - git_repo.close() + class GetVersionStringsFn(Protocol): + def __call__(self) -> list[VersionStr]: ... + RepoDefinition = dict[VersionStr, RepoVersionDef] + """ + A Type alias to define a repositories versions, commits, and changelog sections + for a specific commit convention + """ -@pytest.fixture -def repo_with_single_branch_and_prereleases_tag_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":nut_and_bolt: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0-rc.1")) - git_repo.git.tag("v0.2.0-rc.1", m="v0.2.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0")) - git_repo.git.tag("v0.2.0", m="v0.2.0") - - assert git_repo.commit("v0.2.0").hexsha == git_repo.head.commit.hexsha - yield git_repo - git_repo.close() + class GetRepoDefinitionFn(Protocol): + def __call__( + self, commit_type: CommitConvention = "angular" + ) -> RepoDefinition: ... + class SimulateDefaultChangelogCreationFn(Protocol): + def __call__( + self, + repo_definition: RepoDefinition, + dest_file: Path | None = None, + ) -> str: ... -@pytest.fixture -def repo_with_main_and_feature_branches_angular_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="fix: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0-rc.1")) - git_repo.git.tag("v0.2.0-rc.1", m="v0.2.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0")) - git_repo.git.tag("v0.2.0", m="v0.2.0") - - assert git_repo.commit("v0.2.0").hexsha == git_repo.head.commit.hexsha - - git_repo.create_head("beta_testing") - git_repo.heads.beta_testing.checkout() - - # Do a prerelease on the branch - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.3.0-beta.1")) - git_repo.git.tag("v0.3.0-beta.1", m="v0.3.0-beta.1") - - assert git_repo.commit("v0.3.0-beta.1").hexsha == git_repo.head.commit.hexsha - assert git_repo.active_branch.name == "beta_testing" - yield git_repo - git_repo.close() +@pytest.fixture(scope="session") +def commit_author(): + return Actor(name="semantic release testing", email="not_a_real@email.com") -@pytest.fixture -def repo_with_main_and_feature_branches_emoji_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":bug: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0-rc.1")) - git_repo.git.tag("v0.2.0-rc.1", m="v0.2.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0")) - git_repo.git.tag("v0.2.0", m="v0.2.0") - - assert git_repo.commit("v0.2.0").hexsha == git_repo.head.commit.hexsha - - git_repo.create_head("beta_testing") - git_repo.heads.beta_testing.checkout() - - # Do a prerelease on the branch - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.3.0-beta.1")) - git_repo.git.tag("v0.3.0-beta.1", m="v0.3.0-beta.1") - - assert git_repo.commit("v0.3.0-beta.1").hexsha == git_repo.head.commit.hexsha - assert git_repo.active_branch.name == "beta_testing" - yield git_repo - git_repo.close() +@pytest.fixture(scope="session") +def default_tag_format_str() -> str: + return "v{version}" -@pytest.fixture -def repo_with_main_and_feature_branches_scipy_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="MAINT: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="ENH: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0-rc.1")) - git_repo.git.tag("v0.2.0-rc.1", m="v0.2.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="ENH: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0")) - git_repo.git.tag("v0.2.0", m="v0.2.0") - - assert git_repo.commit("v0.2.0").hexsha == git_repo.head.commit.hexsha - - git_repo.create_head("beta_testing") - git_repo.heads.beta_testing.checkout() - - # Do a prerelease on the branch - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="ENH: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.3.0-beta.1")) - git_repo.git.tag("v0.3.0-beta.1", m="v0.3.0-beta.1") - - assert git_repo.commit("v0.3.0-beta.1").hexsha == git_repo.head.commit.hexsha - assert git_repo.active_branch.name == "beta_testing" - yield git_repo - git_repo.close() +@pytest.fixture(scope="session") +def file_in_repo(): + return f"file-{shortuid()}.txt" -@pytest.fixture -def repo_with_main_and_feature_branches_tag_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":nut_and_bolt: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0-rc.1")) - git_repo.git.tag("v0.2.0-rc.1", m="v0.2.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.2.0")) - git_repo.git.tag("v0.2.0", m="v0.2.0") - - assert git_repo.commit("v0.2.0").hexsha == git_repo.head.commit.hexsha - - git_repo.create_head("beta_testing") - git_repo.heads.beta_testing.checkout() - - # Do a prerelease on the branch - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.3.0-beta.1")) - git_repo.git.tag("v0.3.0-beta.1", m="v0.3.0-beta.1") - - assert git_repo.commit("v0.3.0-beta.1").hexsha == git_repo.head.commit.hexsha - assert git_repo.active_branch.name == "beta_testing" - yield git_repo - git_repo.close() +@pytest.fixture(scope="session") +def example_git_ssh_url(): + return f"git@{EXAMPLE_HVCS_DOMAIN}:{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}.git" -@pytest.fixture -def repo_with_git_flow_angular_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="fix: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat!: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0-rc.1")) - git_repo.git.tag("v1.0.0-rc.1", m="v1.0.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0")) - git_repo.git.tag("v1.0.0", m="v1.0.0") - - assert git_repo.commit("v1.0.0").hexsha == git_repo.head.commit.hexsha - - git_repo.create_head("dev") - git_repo.heads.dev.checkout() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0")) - git_repo.git.tag("v1.1.0", m="v1.1.0") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="fix: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.1")) - git_repo.git.tag("v1.1.1", m="v1.1.1") - - assert git_repo.commit("v1.1.1").hexsha == git_repo.head.commit.hexsha - - git_repo.create_head("feature") - git_repo.heads.feature.checkout() - - # Do a prerelease on the branch - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.2.0-alpha.1")) - git_repo.git.tag("v1.2.0-alpha.1", m="v1.2.0-alpha.1") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="fix: (feature) add some missing text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.2.0-alpha.2")) - git_repo.git.tag("v1.2.0-alpha.2", m="v1.2.0-alpha.2") - - assert git_repo.commit("v1.2.0-alpha.2").hexsha == git_repo.head.commit.hexsha - assert git_repo.active_branch.name == "feature" - yield git_repo - git_repo.close() +@pytest.fixture(scope="session") +def example_git_https_url(): + return f"https://{EXAMPLE_HVCS_DOMAIN}/{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}.git" + + +@pytest.fixture(scope="session") +def create_release_tagged_commit( + update_pyproject_toml: UpdatePyprojectTomlFn, + default_tag_format_str: str, +) -> CreateReleaseFn: + def _mimic_semantic_release_commit( + git_repo: Repo, + version: str, + tag_format: str = default_tag_format_str, + ) -> None: + # stamp version into pyproject.toml + update_pyproject_toml("tool.poetry.version", version) + + # commit --all files with version number commit message + git_repo.git.commit(a=True, m=COMMIT_MESSAGE.format(version=version)) + + # tag commit with version number + tag_str = tag_format.format(version=version) + git_repo.git.tag(tag_str, m=tag_str) + + return _mimic_semantic_release_commit + + +@pytest.fixture(scope="session") +def commit_n_rtn_changelog_entry() -> CommitNReturnChangelogEntryFn: + def _commit_n_rtn_changelog_entry( + git_repo: Repo, commit_msg: str, hvcs: HvcsBase + ) -> str: + # make commit with --all files + git_repo.git.commit(a=True, m=commit_msg) + + # log commit in changelog format after commit action + commit_sha = git_repo.head.commit.hexsha + return str.join( + " ", + [ + str(git_repo.head.commit.message).strip(), + f"([`{commit_sha[:7]}`]({hvcs.commit_hash_url(commit_sha)}))", + ], + ) + + return _commit_n_rtn_changelog_entry + + +@pytest.fixture(scope="session") +def simulate_change_commits_n_rtn_changelog_entry( + commit_n_rtn_changelog_entry: CommitNReturnChangelogEntryFn, + file_in_repo: str, +) -> SimulateChangeCommitsNReturnChangelogEntryFn: + def _simulate_change_commits_n_rtn_changelog_entry( + git_repo: Repo, commit_msgs: list[str], hvcs: HvcsBase + ) -> list[str]: + changelog_entries = [] + for commit_msg in commit_msgs: + add_text_to_file(git_repo, file_in_repo) + changelog_entries.append( + commit_n_rtn_changelog_entry(git_repo, commit_msg, hvcs) + ) + return changelog_entries + + return _simulate_change_commits_n_rtn_changelog_entry + + +@pytest.fixture(scope="session") +def cached_example_git_project( + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, + cached_example_project: Path, + example_git_https_url: str, + commit_author: Actor, +) -> Path: + """ + Initializes an example project with git repo. DO NOT USE DIRECTLY. -@pytest.fixture -def repo_with_git_flow_emoji_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":bug: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":boom: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0-rc.1")) - git_repo.git.tag("v1.0.0-rc.1", m="v1.0.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0")) - git_repo.git.tag("v1.0.0", m="v1.0.0") - - assert git_repo.commit("v1.0.0").hexsha == git_repo.head.commit.hexsha - - git_repo.create_head("dev") - git_repo.heads.dev.checkout() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0")) - git_repo.git.tag("v1.1.0", m="v1.1.0") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":bug: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.1")) - git_repo.git.tag("v1.1.1", m="v1.1.1") - - assert git_repo.commit("v1.1.1").hexsha == git_repo.head.commit.hexsha - - git_repo.create_head("feature") - git_repo.heads.feature.checkout() - - # Do a prerelease on the branch - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.2.0-alpha.1")) - git_repo.git.tag("v1.2.0-alpha.1", m="v1.2.0-alpha.1") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":bug: (feature) add some missing text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.2.0-alpha.2")) - git_repo.git.tag("v1.2.0-alpha.2", m="v1.2.0-alpha.2") - - assert git_repo.commit("v1.2.0-alpha.2").hexsha == git_repo.head.commit.hexsha - assert git_repo.active_branch.name == "feature" - yield git_repo - git_repo.close() + Use a `repo_*` fixture instead. This creates a default + base repository, all settings can be changed later through from the + example_project_git_repo fixture's return object and manual adjustment. + """ + if not cached_example_project.exists(): + raise RuntimeError("Unable to find cached project files") + cached_git_proj_path = (cached_files_dir / "example_git_project").resolve() -@pytest.fixture -def repo_with_git_flow_scipy_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="MAINT: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="API: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0-rc.1")) - git_repo.git.tag("v1.0.0-rc.1", m="v1.0.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="ENH: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0")) - git_repo.git.tag("v1.0.0", m="v1.0.0") - - assert git_repo.commit("v1.0.0").hexsha == git_repo.head.commit.hexsha - - git_repo.create_head("dev") - git_repo.heads.dev.checkout() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="ENH: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0")) - git_repo.git.tag("v1.1.0", m="v1.1.0") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="MAINT: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.1")) - git_repo.git.tag("v1.1.1", m="v1.1.1") - - assert git_repo.commit("v1.1.1").hexsha == git_repo.head.commit.hexsha - - git_repo.create_head("feature") - git_repo.heads.feature.checkout() - - # Do a prerelease on the branch - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="ENH: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.2.0-alpha.1")) - git_repo.git.tag("v1.2.0-alpha.1", m="v1.2.0-alpha.1") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="ENH: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="MAINT: (feature) add some missing text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.2.0-alpha.2")) - git_repo.git.tag("v1.2.0-alpha.2", m="v1.2.0-alpha.2") - - assert git_repo.commit("v1.2.0-alpha.2").hexsha == git_repo.head.commit.hexsha - assert git_repo.active_branch.name == "feature" - yield git_repo - git_repo.close() + # make a copy of the example project as a base + copy_dir_tree(cached_example_project, cached_git_proj_path) + # initialize git repo (open and close) + # NOTE: We don't want to hold the repo object open for the entire test session, + # the implementation on Windows holds some file descriptors open until close is called. + with Repo.init(cached_git_proj_path) as repo: + # Without this the global config may set it to "master", we want consistency + repo.git.branch("-M", "main") + with repo.config_writer("repository") as config: + config.set_value("user", "name", commit_author.name) + config.set_value("user", "email", commit_author.email) + config.set_value("commit", "gpgsign", False) -@pytest.fixture -def repo_with_git_flow_tag_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":nut_and_bolt: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit( - m=":sparkles: add some more text\n\nBREAKING CHANGE: add some more text" - ) - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0-rc.1")) - git_repo.git.tag("v1.0.0-rc.1", m="v1.0.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0")) - git_repo.git.tag("v1.0.0", m="v1.0.0") - - assert git_repo.commit("v1.0.0").hexsha == git_repo.head.commit.hexsha - - git_repo.create_head("dev") - git_repo.heads.dev.checkout() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0")) - git_repo.git.tag("v1.1.0", m="v1.1.0") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":nut_and_bolt: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.1")) - git_repo.git.tag("v1.1.1", m="v1.1.1") - - assert git_repo.commit("v1.1.1").hexsha == git_repo.head.commit.hexsha - - git_repo.create_head("feature") - git_repo.heads.feature.checkout() - - # Do a prerelease on the branch - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.2.0-alpha.1")) - git_repo.git.tag("v1.2.0-alpha.1", m="v1.2.0-alpha.1") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":nut_and_bolt: (feature) add some missing text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.2.0-alpha.2")) - git_repo.git.tag("v1.2.0-alpha.2", m="v1.2.0-alpha.2") - - assert git_repo.commit("v1.2.0-alpha.2").hexsha == git_repo.head.commit.hexsha - assert git_repo.active_branch.name == "feature" - yield git_repo - git_repo.close() + repo.create_remote(name="origin", url=example_git_https_url) + # make sure all base files are in index to enable initial commit + repo.index.add(("*", ".gitignore")) -@pytest.fixture -def repo_with_git_flow_and_release_channels_angular_commits( - git_repo_factory, file_in_repo -): - git_repo = git_repo_factory() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="fix: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat!: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0-rc.1")) - git_repo.git.tag("v1.0.0-rc.1", m="v1.0.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0")) - git_repo.git.tag("v1.0.0", m="v1.0.0") - - assert git_repo.commit("v1.0.0").hexsha == git_repo.head.commit.hexsha - - # Suppose branch "dev" has prerelease suffix of "rc" - git_repo.create_head("dev") - git_repo.heads.dev.checkout() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-rc.1")) - git_repo.git.tag("v1.1.0-rc.1", m="v1.1.0-rc.1") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="fix: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-rc.2")) - git_repo.git.tag("v1.1.0-rc.2", m="v1.1.0-rc.2") - - assert git_repo.commit("v1.1.0-rc.2").hexsha == git_repo.head.commit.hexsha - - # Suppose branch "feature" has prerelease suffix of "alpha" - git_repo.create_head("feature") - git_repo.heads.feature.checkout() - - # Do a prerelease on the branch - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.1")) - git_repo.git.tag("v1.1.0-alpha.1", m="v1.1.0-alpha.1") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.2")) - git_repo.git.tag("v1.1.0-alpha.2", m="v1.1.0-alpha.2") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="fix: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.3")) - git_repo.git.tag("v1.1.0-alpha.3", m="v1.1.0-alpha.3") - - assert git_repo.commit("v1.1.0-alpha.3").hexsha == git_repo.head.commit.hexsha - assert git_repo.active_branch.name == "feature" - yield git_repo - git_repo.close() + # TODO: initial commit! + # trigger automatic cleanup of cache directory during teardown + return teardown_cached_dir(cached_git_proj_path) -@pytest.fixture -def repo_with_git_flow_and_release_channels_angular_commits_using_tag_format( - git_repo_factory, file_in_repo -): - git_repo = git_repo_factory() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("vpy0.1.0", m="vpy0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="fix: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("vpy0.1.1-rc.1", m="vpy0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat!: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0-rc.1")) - git_repo.git.tag("vpy1.0.0-rc.1", m="vpy1.0.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0")) - git_repo.git.tag("vpy1.0.0", m="vpy1.0.0") - - assert git_repo.commit("vpy1.0.0").hexsha == git_repo.head.commit.hexsha - - # Suppose branch "dev" has prerelease suffix of "rc" - git_repo.create_head("dev") - git_repo.heads.dev.checkout() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-rc.1")) - git_repo.git.tag("vpy1.1.0-rc.1", m="vpy1.1.0-rc.1") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="fix: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-rc.2")) - git_repo.git.tag("vpy1.1.0-rc.2", m="vpy1.1.0-rc.2") - - assert git_repo.commit("vpy1.1.0-rc.2").hexsha == git_repo.head.commit.hexsha - - # Suppose branch "feature" has prerelease suffix of "alpha" - git_repo.create_head("feature") - git_repo.heads.feature.checkout() - - # Do a prerelease on the branch - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.1")) - git_repo.git.tag("vpy1.1.0-alpha.1", m="vpy1.1.0-alpha.1") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="feat: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.2")) - git_repo.git.tag("vpy1.1.0-alpha.2", m="vpy1.1.0-alpha.2") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="fix: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.3")) - git_repo.git.tag("vpy1.1.0-alpha.3", m="vpy1.1.0-alpha.3") - - assert git_repo.commit("vpy1.1.0-alpha.3").hexsha == git_repo.head.commit.hexsha - assert git_repo.active_branch.name == "feature" - yield git_repo - git_repo.close() +@pytest.fixture(scope="session") +def build_configured_base_repo( # noqa: C901 + cached_example_git_project: Path, + use_github_hvcs: UseHvcsFn, + use_gitlab_hvcs: UseHvcsFn, + use_gitea_hvcs: UseHvcsFn, + use_bitbucket_hvcs: UseHvcsFn, + use_angular_parser: UseParserFn, + use_emoji_parser: UseParserFn, + use_scipy_parser: UseParserFn, + use_tag_parser: UseParserFn, + example_git_https_url: str, + update_pyproject_toml: UpdatePyprojectTomlFn, +) -> BuildRepoFn: + """ + This fixture is intended to simplify repo scenario building by initially + creating the repo but also configuring semantic_release in the pyproject.toml + for when the test executes semantic_release. It returns a function so that + derivative fixtures can call this fixture with individual parameters. + """ -@pytest.fixture -def repo_with_git_flow_and_release_channels_emoji_commits( - git_repo_factory, file_in_repo -): - git_repo = git_repo_factory() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":bug: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":boom: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0-rc.1")) - git_repo.git.tag("v1.0.0-rc.1", m="v1.0.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0")) - git_repo.git.tag("v1.0.0", m="v1.0.0") - - assert git_repo.commit("v1.0.0").hexsha == git_repo.head.commit.hexsha - - # Suppose branch "dev" has prerelease suffix of "rc" - git_repo.create_head("dev") - git_repo.heads.dev.checkout() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-rc.1")) - git_repo.git.tag("v1.1.0-rc.1", m="v1.1.0-rc.1") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":bug: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-rc.2")) - git_repo.git.tag("v1.1.0-rc.2", m="v1.1.0-rc.2") - - assert git_repo.commit("v1.1.0-rc.2").hexsha == git_repo.head.commit.hexsha - - # Suppose branch "feature" has prerelease suffix of "alpha" - git_repo.create_head("feature") - git_repo.heads.feature.checkout() - - # Do a prerelease on the branch - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.1")) - git_repo.git.tag("v1.1.0-alpha.1", m="v1.1.0-alpha.1") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.2")) - git_repo.git.tag("v1.1.0-alpha.2", m="v1.1.0-alpha.2") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":bug: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.3")) - git_repo.git.tag("v1.1.0-alpha.3", m="v1.1.0-alpha.3") - - assert git_repo.commit("v1.1.0-alpha.3").hexsha == git_repo.head.commit.hexsha - assert git_repo.active_branch.name == "feature" - yield git_repo - git_repo.close() + def _build_configured_base_repo( # noqa: C901 + dest_dir: Path | str, + commit_type: str = "angular", + hvcs_client_name: str = "github", + hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, + tag_format_str: str | None = None, + extra_configs: dict[str, TomlSerializableTypes] | None = None, + ) -> tuple[Path, HvcsBase]: + if not cached_example_git_project.exists(): + raise RuntimeError("Unable to find cached git project files!") + + # Copy the cached git project the dest directory + copy_dir_tree(cached_example_git_project, dest_dir) + + # Make sure we are in the dest directory + with temporary_working_directory(dest_dir): + # Set parser configuration + if commit_type == "angular": + use_angular_parser() + elif commit_type == "emoji": + use_emoji_parser() + elif commit_type == "scipy": + use_scipy_parser() + elif commit_type == "tag": + use_tag_parser() + else: + raise ValueError(f"Unknown parser name: {commit_type}") + + # Set HVCS configuration + if hvcs_client_name == "github": + hvcs_class = use_github_hvcs(hvcs_domain) + elif hvcs_client_name == "gitlab": + hvcs_class = use_gitlab_hvcs(hvcs_domain) + elif hvcs_client_name == "gitea": + hvcs_class = use_gitea_hvcs(hvcs_domain) + elif hvcs_client_name == "bitbucket": + hvcs_class = use_bitbucket_hvcs(hvcs_domain) + else: + raise ValueError(f"Unknown HVCS client name: {hvcs_client_name}") + + # Create HVCS Client instance + hvcs = hvcs_class(example_git_https_url, hvcs_domain) + + # Set tag format in configuration + if tag_format_str is not None: + update_pyproject_toml( + "tool.semantic_release.tag_format", tag_format_str + ) + + # Apply configurations to pyproject.toml + if extra_configs is not None: + for key, value in extra_configs.items(): + update_pyproject_toml(key, value) + + return Path(dest_dir), hvcs + + return _build_configured_base_repo + + +@pytest.fixture(scope="session") +def simulate_default_changelog_creation() -> SimulateDefaultChangelogCreationFn: + def build_version_entry(version: VersionStr, version_def: RepoVersionDef) -> str: + version_entry = [] + if version == "Unreleased": + version_entry.append(f"## {version}\n") + else: + version_entry.append( + # TODO: artificial newline in front due to template when no Unreleased changes exist + f"\n## v{version} ({TODAY_DATE_STR})\n" + ) + + for section_def in version_def["changelog_sections"]: + version_entry.append(f"### {section_def['section']}\n") + for i in section_def["i_commits"]: + version_entry.append(f"* {version_def['commits'][i]}\n") + + return str.join("\n", version_entry) + + def _mimic_semantic_release_default_changelog( + repo_definition: RepoDefinition, + dest_file: Path | None = None, + ) -> str: + header = "# CHANGELOG" + version_entries = [] + + for version, version_def in repo_definition.items(): + # prepend entries to force reverse ordering + version_entries.insert(0, build_version_entry(version, version_def)) + + changelog_content = ( + str.join("\n" * 3, [header, str.join("\n", list(version_entries))]).rstrip() + + "\n" + ) + + if dest_file is not None: + dest_file.write_text(changelog_content) + + return changelog_content + + return _mimic_semantic_release_default_changelog @pytest.fixture -def repo_with_git_flow_and_release_channels_scipy_commits( - git_repo_factory, file_in_repo -): - git_repo = git_repo_factory() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="MAINT: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="API: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0-rc.1")) - git_repo.git.tag("v1.0.0-rc.1", m="v1.0.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="ENH: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0")) - git_repo.git.tag("v1.0.0", m="v1.0.0") - - assert git_repo.commit("v1.0.0").hexsha == git_repo.head.commit.hexsha - - # Suppose branch "dev" has prerelease suffix of "rc" - git_repo.create_head("dev") - git_repo.heads.dev.checkout() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="ENH: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-rc.1")) - git_repo.git.tag("v1.1.0-rc.1", m="v1.1.0-rc.1") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="MAINT: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-rc.2")) - git_repo.git.tag("v1.1.0-rc.2", m="v1.1.0-rc.2") - - assert git_repo.commit("v1.1.0-rc.2").hexsha == git_repo.head.commit.hexsha - - # Suppose branch "feature" has prerelease suffix of "alpha" - git_repo.create_head("feature") - git_repo.heads.feature.checkout() - - # Do a prerelease on the branch - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="ENH: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.1")) - git_repo.git.tag("v1.1.0-alpha.1", m="v1.1.0-alpha.1") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="ENH: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.2")) - git_repo.git.tag("v1.1.0-alpha.2", m="v1.1.0-alpha.2") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="MAINT: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.3")) - git_repo.git.tag("v1.1.0-alpha.3", m="v1.1.0-alpha.3") - - assert git_repo.commit("v1.1.0-alpha.3").hexsha == git_repo.head.commit.hexsha - assert git_repo.active_branch.name == "feature" - yield git_repo - git_repo.close() - +def example_project_git_repo( + example_project_dir: ExProjectDir, +) -> Generator[ExProjectGitRepoFn, None, None]: + repos: list[Repo] = [] + + # Must be a callable function to ensure files exist before repo is opened + def _example_project_git_repo() -> Repo: + if not example_project_dir.exists(): + raise RuntimeError("Unable to find example git project!") + + repo = Repo(example_project_dir) + repos.append(repo) + return repo -@pytest.fixture -def repo_with_git_flow_and_release_channels_tag_commits(git_repo_factory, file_in_repo): - git_repo = git_repo_factory() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m="Initial commit") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.0")) - git_repo.git.tag("v0.1.0", m="v0.1.0") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":nut_and_bolt: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="0.1.1-rc.1")) - git_repo.git.tag("v0.1.1-rc.1", m="v0.1.1-rc.1") - - # Do a prerelease - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit( - m=":sparkles: add some more text\n\nBREAKING CHANGE: add some more text" - ) - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0-rc.1")) - git_repo.git.tag("v1.0.0-rc.1", m="v1.0.0-rc.1") - - # Do a full release - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.0.0")) - git_repo.git.tag("v1.0.0", m="v1.0.0") - - assert git_repo.commit("v1.0.0").hexsha == git_repo.head.commit.hexsha - - # Suppose branch "dev" has prerelease suffix of "rc" - git_repo.create_head("dev") - git_repo.heads.dev.checkout() - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-rc.1")) - git_repo.git.tag("v1.1.0-rc.1", m="v1.1.0-rc.1") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":nut_and_bolt: (dev) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-rc.2")) - git_repo.git.tag("v1.1.0-rc.2", m="v1.1.0-rc.2") - - assert git_repo.commit("v1.1.0-rc.2").hexsha == git_repo.head.commit.hexsha - - # Suppose branch "feature" has prerelease suffix of "alpha" - git_repo.create_head("feature") - git_repo.heads.feature.checkout() - - # Do a prerelease on the branch - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.1")) - git_repo.git.tag("v1.1.0-alpha.1", m="v1.1.0-alpha.1") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":sparkles: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.2")) - git_repo.git.tag("v1.1.0-alpha.2", m="v1.1.0-alpha.2") - - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=":nut_and_bolt: (feature) add some more text") - add_text_to_file(git_repo, file_in_repo) - git_repo.git.commit(m=COMMIT_MESSAGE.format(version="1.1.0-alpha.3")) - git_repo.git.tag("v1.1.0-alpha.3", m="v1.1.0-alpha.3") - - assert git_repo.commit("v1.1.0-alpha.3").hexsha == git_repo.head.commit.hexsha - assert git_repo.active_branch.name == "feature" - yield git_repo - git_repo.close() + try: + yield _example_project_git_repo + finally: + for repo in repos: + repo.close() diff --git a/tests/fixtures/repos/__init__.py b/tests/fixtures/repos/__init__.py new file mode 100644 index 000000000..969410237 --- /dev/null +++ b/tests/fixtures/repos/__init__.py @@ -0,0 +1,3 @@ +from tests.fixtures.repos.git_flow import * +from tests.fixtures.repos.github_flow import * +from tests.fixtures.repos.trunk_based_dev import * diff --git a/tests/fixtures/repos/git_flow/__init__.py b/tests/fixtures/repos/git_flow/__init__.py new file mode 100644 index 000000000..7b3e5c78d --- /dev/null +++ b/tests/fixtures/repos/git_flow/__init__.py @@ -0,0 +1,2 @@ +from tests.fixtures.repos.git_flow.repo_w_2_release_channels import * +from tests.fixtures.repos.git_flow.repo_w_3_release_channels import * diff --git a/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py b/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py new file mode 100644 index 000000000..7e47a6955 --- /dev/null +++ b/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py @@ -0,0 +1,521 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING + +import pytest +from git import Repo + +from tests.const import EXAMPLE_HVCS_DOMAIN +from tests.util import copy_dir_tree, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + + from semantic_release.hvcs import HvcsBase + + from tests.conftest import TeardownCachedDirFn + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BaseRepoVersionDef, + BuildRepoFn, + CommitConvention, + CreateReleaseFn, + ExProjectGitRepoFn, + GetRepoDefinitionFn, + GetVersionStringsFn, + RepoDefinition, + SimulateChangeCommitsNReturnChangelogEntryFn, + SimulateDefaultChangelogCreationFn, + TomlSerializableTypes, + VersionStr, + ) + + +@pytest.fixture(scope="session") +def get_commits_for_git_flow_repo_with_2_release_channels() -> GetRepoDefinitionFn: + base_definition: dict[str, BaseRepoVersionDef] = { + "0.1.0": { + "changelog_sections": { + "angular": [{"section": "Unknown", "i_commits": [0]}], + "emoji": [{"section": "Other", "i_commits": [0]}], + "scipy": [{"section": "Unknown", "i_commits": [0]}], + "tag": [{"section": "Unknown", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "Initial commit", + "emoji": "Initial commit", + "scipy": "Initial commit", + "tag": "Initial commit", + } + ], + }, + "0.1.1-rc.1": { + "changelog_sections": { + "angular": [{"section": "Fix", "i_commits": [0]}], + "emoji": [{"section": ":bug:", "i_commits": [0]}], + "scipy": [{"section": "Fix", "i_commits": [0]}], + "tag": [{"section": "Fix", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "fix: add some more text", + "emoji": ":bug: add some more text", + "scipy": "MAINT: add some more text", + "tag": ":nut_and_bolt: add some more text", + } + ], + }, + "1.0.0-rc.1": { + "changelog_sections": { + "angular": [{"section": "Breaking", "i_commits": [0]}], + "emoji": [{"section": ":boom:", "i_commits": [0]}], + "scipy": [{"section": "Breaking", "i_commits": [0]}], + "tag": [{"section": "Breaking", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "feat!: add some more text", + "emoji": ":boom: add some more text", + "scipy": "API: add some more text", + "tag": ":sparkles: add some more text\n\nBREAKING CHANGE: add some more text", + } + ], + }, + "1.0.0": { + "changelog_sections": { + "angular": [{"section": "Feature", "i_commits": [0]}], + "emoji": [{"section": ":sparkles:", "i_commits": [0]}], + "scipy": [{"section": "Feature", "i_commits": [0]}], + "tag": [{"section": "Feature", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "feat: add some more text", + "emoji": ":sparkles: add some more text", + "scipy": "ENH: add some more text", + "tag": ":sparkles: add some more text", + }, + ], + }, + "1.1.0": { + "changelog_sections": { + "angular": [{"section": "Feature", "i_commits": [0]}], + "emoji": [{"section": ":sparkles:", "i_commits": [0]}], + "scipy": [{"section": "Feature", "i_commits": [0]}], + "tag": [{"section": "Feature", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "feat(dev): add some more text", + "emoji": ":sparkles: (dev) add some more text", + "scipy": "ENH: (dev) add some more text", + "tag": ":sparkles: (dev) add some more text", + }, + ], + }, + "1.1.1": { + "changelog_sections": { + "angular": [{"section": "Fix", "i_commits": [0]}], + "emoji": [{"section": ":bug:", "i_commits": [0]}], + "scipy": [{"section": "Fix", "i_commits": [0]}], + "tag": [{"section": "Fix", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "fix(dev): add some more text", + "emoji": ":bug: (dev) add some more text", + "scipy": "MAINT: (dev) add some more text", + "tag": ":nut_and_bolt: (dev) add some more text", + }, + ], + }, + "1.2.0-alpha.1": { + "changelog_sections": { + "angular": [{"section": "Feature", "i_commits": [0]}], + "emoji": [{"section": ":sparkles:", "i_commits": [0]}], + "scipy": [{"section": "Feature", "i_commits": [0]}], + "tag": [{"section": "Feature", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "feat(feature): add some more text", + "emoji": ":sparkles: (feature) add some more text", + "scipy": "ENH: (feature) add some more text", + "tag": ":sparkles: (feature) add some more text", + }, + ], + }, + "1.2.0-alpha.2": { + "changelog_sections": { + # ORDER matters here since greater than 1 commit, changelogs sections are alphabetized + # But value is ultimately defined by the commits, which means the commits are + # referenced by index value + "angular": [ + {"section": "Feature", "i_commits": [0]}, + {"section": "Fix", "i_commits": [1]}, + ], + "emoji": [ + {"section": ":bug:", "i_commits": [1]}, + {"section": ":sparkles:", "i_commits": [0]}, + ], + "scipy": [ + {"section": "Feature", "i_commits": [0]}, + {"section": "Fix", "i_commits": [1]}, + ], + "tag": [ + {"section": "Feature", "i_commits": [0]}, + {"section": "Fix", "i_commits": [1]}, + ], + }, + "commits": [ + { + "angular": "feat(feature): add some more text", + "emoji": ":sparkles: (feature) add some more text", + "scipy": "ENH: (feature) add some more text", + "tag": ":sparkles: (feature) add some more text", + }, + { + "angular": "fix(feature): add some missing text", + "emoji": ":bug: (feature) add some missing text", + "scipy": "MAINT: (feature) add some missing text", + "tag": ":nut_and_bolt: (feature) add some missing text", + }, + ], + }, + } + + def _get_commits_for_git_flow_repo_with_2_release_channels( + commit_type: CommitConvention = "angular", + ) -> RepoDefinition: + definition: RepoDefinition = {} + + for version, version_def in base_definition.items(): + definition[version] = { + # Extract the correct changelog section header for the commit type + "changelog_sections": deepcopy( + version_def["changelog_sections"][commit_type] + ), + "commits": [ + # Extract the correct commit message for the commit type + message_variants[commit_type] + for message_variants in version_def["commits"] + ], + } + + return definition + + return _get_commits_for_git_flow_repo_with_2_release_channels + + +@pytest.fixture(scope="session") +def get_versions_for_git_flow_repo_with_2_release_channels( + get_commits_for_git_flow_repo_with_2_release_channels: GetRepoDefinitionFn, +) -> GetVersionStringsFn: + def _get_versions_for_git_flow_repo_with_2_release_channels() -> list[VersionStr]: + return list(get_commits_for_git_flow_repo_with_2_release_channels().keys()) + + return _get_versions_for_git_flow_repo_with_2_release_channels + + +@pytest.fixture(scope="session") +def build_git_flow_repo_with_2_release_channels( + get_commits_for_git_flow_repo_with_2_release_channels: GetRepoDefinitionFn, + build_configured_base_repo: BuildRepoFn, + default_tag_format_str: str, + changelog_md_file: Path, + simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, + simulate_default_changelog_creation: SimulateDefaultChangelogCreationFn, + create_release_tagged_commit: CreateReleaseFn, +) -> BuildRepoFn: + """ + This fixture returns a function that when called will build a git repo that + uses the git flow branching strategy with 2 release channels + 1. alpha feature releases + 2. release candidate releases + """ + + def _build_git_flow_repo_with_2_release_channels( + dest_dir: Path | str, + commit_type: CommitConvention = "angular", + hvcs_client_name: str = "github", + hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, + tag_format_str: str | None = None, + extra_configs: dict[str, TomlSerializableTypes] | None = None, + ) -> tuple[Path, HvcsBase]: + repo_dir, hvcs = build_configured_base_repo( + dest_dir, + commit_type=commit_type, + hvcs_client_name=hvcs_client_name, + hvcs_domain=hvcs_domain, + tag_format_str=tag_format_str, + extra_configs={ + # branch "feature" has prerelease suffix of "alpha" + "tool.semantic_release.branches.features": { + "match": "feat.*", + "prerelease": True, + "prerelease_token": "alpha", + }, + **(extra_configs or {}), + }, + ) + + # Retrieve/Define project vars that will be used to create the repo below + repo_def = get_commits_for_git_flow_repo_with_2_release_channels(commit_type) + versions = (key for key in repo_def) + next_version = next(versions) + next_version_def = repo_def[next_version] + + # must be after build_configured_base_repo() so we dont set the + # default tag format in the pyproject.toml (we want semantic-release to use its defaults) + # however we need it to manually create the tags it knows how to parse + tag_format = tag_format_str or default_tag_format_str + + # Run Git operations to simulate repo commit & release history + with temporary_working_directory(repo_dir), Repo(".") as git_repo: + # commit initial files & update commit msg with sha & url + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Publish initial feature release (v0.1.0) [updates tool.poetry.version] + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Prepare to do a prerelease (by adding a change) + # modify && commit modification -> update commit msg with sha & url + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make a patch level release candidate (v0.1.1-rc.1) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Prepare for a major feature release + # modify && commit modification -> update commit msg with sha & url + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make a major feature release candidate (v1.0.0-rc.1) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Prepare for a major feature release + # modify && commit modification -> update commit msg with sha & url + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make a major feature release (v1.0.0) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Change to a dev branch + git_repo.create_head("dev") + git_repo.heads.dev.checkout() + + # TODO: FIX this section... its not proper Git Flow + + # Prepare for a minor feature release + # modify && commit modification -> update commit msg with sha & url + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # TODO: ERROR releasing on dev branch + # Make a minor feature release (v1.1.0) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Prepare for a patch level release + # modify && commit modification -> update commit msg with sha & url + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # TODO: ERROR releasing on dev branch + # Make a patch level release (v1.1.1) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Change to a feature branch + git_repo.create_head("feature") + git_repo.heads.feature.checkout() + + # Prepare for an alpha prerelease + # modify && commit modification -> update commit msg with sha & url + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make an alpha prerelease (v1.2.0-alpha.1) on the feature branch + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Prepare for a 2nd prerelease with 2 commits + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # write expected changelog (should match template changelog) + simulate_default_changelog_creation( + repo_def, + repo_dir.joinpath(changelog_md_file), + ) + + # Make a 2nd alpha prerelease (v1.2.0-alpha.2) on the feature branch + create_release_tagged_commit(git_repo, next_version, tag_format) + + return repo_dir, hvcs + + return _build_git_flow_repo_with_2_release_channels + + +# --------------------------------------------------------------------------- # +# Session-level fixtures to use to set up cached repositories on first use # +# --------------------------------------------------------------------------- # + + +@pytest.fixture(scope="session") +def cached_repo_w_git_flow_n_2_release_channels_angular_commits( + build_git_flow_repo_with_2_release_channels: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_w_git_flow_n_2_release_channels_angular_commits.__name__ + ) + build_git_flow_repo_with_2_release_channels(cached_repo_path, "angular") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_w_git_flow_n_2_release_channels_emoji_commits( + build_git_flow_repo_with_2_release_channels: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_w_git_flow_n_2_release_channels_emoji_commits.__name__ + ) + build_git_flow_repo_with_2_release_channels(cached_repo_path, "emoji") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_w_git_flow_n_2_release_channels_scipy_commits( + build_git_flow_repo_with_2_release_channels: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_w_git_flow_n_2_release_channels_scipy_commits.__name__ + ) + build_git_flow_repo_with_2_release_channels(cached_repo_path, "scipy") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_w_git_flow_n_2_release_channels_tag_commits( + build_git_flow_repo_with_2_release_channels: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_w_git_flow_n_2_release_channels_tag_commits.__name__ + ) + build_git_flow_repo_with_2_release_channels(cached_repo_path, "tag") + return teardown_cached_dir(cached_repo_path) + + +# --------------------------------------------------------------------------- # +# Test-level fixtures to use to set up temporary test directory # +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def repo_with_git_flow_angular_commits( + cached_repo_w_git_flow_n_2_release_channels_angular_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_w_git_flow_n_2_release_channels_angular_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_w_git_flow_n_2_release_channels_angular_commits, + example_project_dir, + ) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_git_flow_emoji_commits( + cached_repo_w_git_flow_n_2_release_channels_emoji_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_w_git_flow_n_2_release_channels_emoji_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_w_git_flow_n_2_release_channels_emoji_commits, + example_project_dir, + ) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_git_flow_scipy_commits( + cached_repo_w_git_flow_n_2_release_channels_scipy_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_w_git_flow_n_2_release_channels_scipy_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_w_git_flow_n_2_release_channels_scipy_commits, + example_project_dir, + ) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_git_flow_tag_commits( + cached_repo_w_git_flow_n_2_release_channels_tag_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_w_git_flow_n_2_release_channels_tag_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_w_git_flow_n_2_release_channels_tag_commits, + example_project_dir, + ) + return example_project_git_repo() diff --git a/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py b/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py new file mode 100644 index 000000000..6077450bd --- /dev/null +++ b/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py @@ -0,0 +1,547 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING + +import pytest +from git import Repo + +from tests.const import EXAMPLE_HVCS_DOMAIN +from tests.util import copy_dir_tree, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + + from semantic_release.hvcs import HvcsBase + + from tests.conftest import TeardownCachedDirFn + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BaseRepoVersionDef, + BuildRepoFn, + CommitConvention, + CreateReleaseFn, + ExProjectGitRepoFn, + GetRepoDefinitionFn, + GetVersionStringsFn, + RepoDefinition, + SimulateChangeCommitsNReturnChangelogEntryFn, + SimulateDefaultChangelogCreationFn, + TomlSerializableTypes, + VersionStr, + ) + + +@pytest.fixture(scope="session") +def get_commits_for_git_flow_repo_w_3_release_channels() -> GetRepoDefinitionFn: + base_definition: dict[str, BaseRepoVersionDef] = { + "0.1.0": { + "changelog_sections": { + "angular": [{"section": "Unknown", "i_commits": [0]}], + "emoji": [{"section": "Other", "i_commits": [0]}], + "scipy": [{"section": "Unknown", "i_commits": [0]}], + "tag": [{"section": "Unknown", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "Initial commit", + "emoji": "Initial commit", + "scipy": "Initial commit", + "tag": "Initial commit", + } + ], + }, + "0.1.1-rc.1": { + "changelog_sections": { + "angular": [{"section": "Fix", "i_commits": [0]}], + "emoji": [{"section": ":bug:", "i_commits": [0]}], + "scipy": [{"section": "Fix", "i_commits": [0]}], + "tag": [{"section": "Fix", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "fix: add some more text", + "emoji": ":bug: add some more text", + "scipy": "MAINT: add some more text", + "tag": ":nut_and_bolt: add some more text", + } + ], + }, + "1.0.0-rc.1": { + "changelog_sections": { + "angular": [{"section": "Breaking", "i_commits": [0]}], + "emoji": [{"section": ":boom:", "i_commits": [0]}], + "scipy": [{"section": "Breaking", "i_commits": [0]}], + "tag": [{"section": "Breaking", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "feat!: add some more text", + "emoji": ":boom: add some more text", + "scipy": "API: add some more text", + "tag": ":sparkles: add some more text\n\nBREAKING CHANGE: add some more text", + } + ], + }, + "1.0.0": { + "changelog_sections": { + "angular": [{"section": "Feature", "i_commits": [0]}], + "emoji": [{"section": ":sparkles:", "i_commits": [0]}], + "scipy": [{"section": "Feature", "i_commits": [0]}], + "tag": [{"section": "Feature", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "feat: add some more text", + "emoji": ":sparkles: add some more text", + "scipy": "ENH: add some more text", + "tag": ":sparkles: add some more text", + }, + ], + }, + "1.1.0-rc.1": { + "changelog_sections": { + "angular": [{"section": "Feature", "i_commits": [0]}], + "emoji": [{"section": ":sparkles:", "i_commits": [0]}], + "scipy": [{"section": "Feature", "i_commits": [0]}], + "tag": [{"section": "Feature", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "feat(dev): add some more text", + "emoji": ":sparkles: (dev) add some more text", + "scipy": "ENH: (dev) add some more text", + "tag": ":sparkles: (dev) add some more text", + }, + ], + }, + "1.1.0-rc.2": { + "changelog_sections": { + "angular": [{"section": "Fix", "i_commits": [0]}], + "emoji": [{"section": ":bug:", "i_commits": [0]}], + "scipy": [{"section": "Fix", "i_commits": [0]}], + "tag": [{"section": "Fix", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "fix(dev): add some more text", + "emoji": ":bug: (dev) add some more text", + "scipy": "MAINT: (dev) add some more text", + "tag": ":nut_and_bolt: (dev) add some more text", + }, + ], + }, + "1.1.0-alpha.1": { + "changelog_sections": { + "angular": [{"section": "Feature", "i_commits": [0]}], + "emoji": [{"section": ":sparkles:", "i_commits": [0]}], + "scipy": [{"section": "Feature", "i_commits": [0]}], + "tag": [{"section": "Feature", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "feat(feature): add some more text", + "emoji": ":sparkles: (feature) add some more text", + "scipy": "ENH: (feature) add some more text", + "tag": ":sparkles: (feature) add some more text", + }, + ], + }, + "1.1.0-alpha.2": { + "changelog_sections": { + "angular": [{"section": "Feature", "i_commits": [0]}], + "emoji": [{"section": ":sparkles:", "i_commits": [0]}], + "scipy": [{"section": "Feature", "i_commits": [0]}], + "tag": [{"section": "Feature", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "feat(feature): add some more text", + "emoji": ":sparkles: (feature) add some more text", + "scipy": "ENH: (feature) add some more text", + "tag": ":sparkles: (feature) add some more text", + }, + ], + }, + "1.1.0-alpha.3": { + "changelog_sections": { + "angular": [{"section": "Fix", "i_commits": [0]}], + "emoji": [{"section": ":bug:", "i_commits": [0]}], + "scipy": [{"section": "Fix", "i_commits": [0]}], + "tag": [{"section": "Fix", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "fix(feature): add some missing text", + "emoji": ":bug: (feature) add some missing text", + "scipy": "MAINT: (feature) add some missing text", + "tag": ":nut_and_bolt: (feature) add some missing text", + }, + ], + }, + } + + def _get_commits_for_git_flow_repo_w_3_release_channels( + commit_type: CommitConvention = "angular", + ) -> RepoDefinition: + definition: RepoDefinition = {} + + for version, version_def in base_definition.items(): + definition[version] = { + # Extract the correct changelog section header for the commit type + "changelog_sections": deepcopy( + version_def["changelog_sections"][commit_type] + ), + "commits": [ + # Extract the correct commit message for the commit type + message_variants[commit_type] + for message_variants in version_def["commits"] + ], + } + + return definition + + return _get_commits_for_git_flow_repo_w_3_release_channels + + +@pytest.fixture(scope="session") +def get_versions_for_git_flow_repo_w_3_release_channels( + get_commits_for_git_flow_repo_w_3_release_channels: GetRepoDefinitionFn, +) -> GetVersionStringsFn: + def _get_versions_for_git_flow_repo_w_3_release_channels() -> list[VersionStr]: + return list(get_commits_for_git_flow_repo_w_3_release_channels().keys()) + + return _get_versions_for_git_flow_repo_w_3_release_channels + + +@pytest.fixture(scope="session") +def build_git_flow_repo_w_3_release_channels( + get_commits_for_git_flow_repo_w_3_release_channels: GetRepoDefinitionFn, + build_configured_base_repo: BuildRepoFn, + default_tag_format_str: str, + changelog_md_file: Path, + simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, + simulate_default_changelog_creation: SimulateDefaultChangelogCreationFn, + create_release_tagged_commit: CreateReleaseFn, +) -> BuildRepoFn: + def _build_git_flow_repo_w_3_release_channels( + dest_dir: Path | str, + commit_type: CommitConvention = "angular", + hvcs_client_name: str = "github", + hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, + tag_format_str: str | None = None, + extra_configs: dict[str, TomlSerializableTypes] | None = None, + ) -> tuple[Path, HvcsBase]: + repo_dir, hvcs = build_configured_base_repo( + dest_dir, + commit_type=commit_type, + hvcs_client_name=hvcs_client_name, + hvcs_domain=hvcs_domain, + tag_format_str=tag_format_str, + extra_configs={ + # branch "dev" has prerelease suffix of "rc" + "tool.semantic_release.branches.dev": { + "match": "dev", + "prerelease": True, + "prerelease_token": "rc", + }, + # branch "feature" has prerelease suffix of "alpha" + "tool.semantic_release.branches.features": { + "match": "feat.*", + "prerelease": True, + "prerelease_token": "alpha", + }, + **(extra_configs or {}), + }, + ) + + # Retrieve/Define project vars that will be used to create the repo below + repo_def = get_commits_for_git_flow_repo_w_3_release_channels(commit_type) + versions = (key for key in repo_def) + next_version = next(versions) + next_version_def = repo_def[next_version] + + # must be after build_configured_base_repo() so we dont set the + # default tag format in the pyproject.toml (we want semantic-release to use its defaults) + # however we need it to manually create the tags it knows how to parse + tag_format = tag_format_str or default_tag_format_str + + with temporary_working_directory(repo_dir), Repo(".") as git_repo: + # commit initial files & update commit msg with sha & url + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make initial feature release (v0.1.0) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Prepare for a prerelease + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make a patch level release candidate (v0.1.1-rc.1) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Prepare for a major feature release + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make a major feature release candidate (v1.0.0-rc.1) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Add non-breaking feature commit + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make a major feature release (v1.0.0) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Change to a dev branch + git_repo.create_head("dev") + git_repo.heads.dev.checkout() + + # Prepare for a minor bump release candidate + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make a minor bump release candidate (v1.1.0-rc.1) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Make a patch level commit + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make a 2nd release candidate (v1.1.0-rc.2) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Change to a feature branch + git_repo.create_head("feature") + git_repo.heads.feature.checkout() + + # Make a feature commit + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make an alpha prerelease (v1.1.0-alpha.1) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Make a another feature commit + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make a 2nd alpha prerelease (v1.1.0-alpha.2) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Make a patch level commit + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # write expected changelog (should match template changelog) + simulate_default_changelog_creation( + repo_def, + repo_dir.joinpath(changelog_md_file), + ) + + # Make a 3rd alpha prerelease (v1.1.0-alpha.3) + create_release_tagged_commit(git_repo, next_version, tag_format) + + return repo_dir, hvcs + + return _build_git_flow_repo_w_3_release_channels + + +# --------------------------------------------------------------------------- # +# Session-level fixtures to use to set up cached repositories on first use # +# --------------------------------------------------------------------------- # + + +@pytest.fixture(scope="session") +def cached_repo_w_git_flow_n_3_release_channels_angular_commits_tag_format( + build_git_flow_repo_w_3_release_channels: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_w_git_flow_n_3_release_channels_angular_commits_tag_format.__name__ + ) + build_git_flow_repo_w_3_release_channels( + cached_repo_path, "angular", tag_format_str="vpy{version}" + ) + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_w_git_flow_n_3_release_channels_angular_commits( + build_git_flow_repo_w_3_release_channels: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_w_git_flow_n_3_release_channels_angular_commits.__name__ + ) + build_git_flow_repo_w_3_release_channels(cached_repo_path, "angular") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_w_git_flow_n_3_release_channels_emoji_commits( + build_git_flow_repo_w_3_release_channels: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_w_git_flow_n_3_release_channels_emoji_commits.__name__ + ) + build_git_flow_repo_w_3_release_channels(cached_repo_path, "emoji") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_w_git_flow_n_3_release_channels_scipy_commits( + build_git_flow_repo_w_3_release_channels: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_w_git_flow_n_3_release_channels_scipy_commits.__name__ + ) + build_git_flow_repo_w_3_release_channels(cached_repo_path, "scipy") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_w_git_flow_n_3_release_channels_tag_commits( + build_git_flow_repo_w_3_release_channels: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_w_git_flow_n_3_release_channels_tag_commits.__name__ + ) + build_git_flow_repo_w_3_release_channels(cached_repo_path, "tag") + return teardown_cached_dir(cached_repo_path) + + +# --------------------------------------------------------------------------- # +# Test-level fixtures to use to set up temporary test directory # +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def repo_with_git_flow_and_release_channels_angular_commits_using_tag_format( + cached_repo_w_git_flow_n_3_release_channels_angular_commits_tag_format: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_w_git_flow_n_3_release_channels_angular_commits_tag_format.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_w_git_flow_n_3_release_channels_angular_commits_tag_format, + example_project_dir, + ) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_git_flow_and_release_channels_angular_commits( + cached_repo_w_git_flow_n_3_release_channels_angular_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_w_git_flow_n_3_release_channels_angular_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_w_git_flow_n_3_release_channels_angular_commits, + example_project_dir, + ) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_git_flow_and_release_channels_emoji_commits( + cached_repo_w_git_flow_n_3_release_channels_emoji_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_w_git_flow_n_3_release_channels_emoji_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_w_git_flow_n_3_release_channels_emoji_commits, + example_project_dir, + ) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_git_flow_and_release_channels_scipy_commits( + cached_repo_w_git_flow_n_3_release_channels_scipy_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_w_git_flow_n_3_release_channels_scipy_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_w_git_flow_n_3_release_channels_scipy_commits, + example_project_dir, + ) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_git_flow_and_release_channels_tag_commits( + cached_repo_w_git_flow_n_3_release_channels_tag_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_w_git_flow_n_3_release_channels_tag_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_w_git_flow_n_3_release_channels_tag_commits, + example_project_dir, + ) + return example_project_git_repo() diff --git a/tests/fixtures/repos/github_flow/__init__.py b/tests/fixtures/repos/github_flow/__init__.py new file mode 100644 index 000000000..de2cbc336 --- /dev/null +++ b/tests/fixtures/repos/github_flow/__init__.py @@ -0,0 +1 @@ +from tests.fixtures.repos.github_flow.repo_w_release_channels import * diff --git a/tests/fixtures/repos/github_flow/repo_w_release_channels.py b/tests/fixtures/repos/github_flow/repo_w_release_channels.py new file mode 100644 index 000000000..5e5e7c38e --- /dev/null +++ b/tests/fixtures/repos/github_flow/repo_w_release_channels.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING + +import pytest +from git import Repo + +from tests.const import EXAMPLE_HVCS_DOMAIN +from tests.util import copy_dir_tree, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + + from semantic_release.hvcs import HvcsBase + + from tests.conftest import TeardownCachedDirFn + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BaseRepoVersionDef, + BuildRepoFn, + CommitConvention, + CreateReleaseFn, + ExProjectGitRepoFn, + GetRepoDefinitionFn, + GetVersionStringsFn, + RepoDefinition, + SimulateChangeCommitsNReturnChangelogEntryFn, + SimulateDefaultChangelogCreationFn, + TomlSerializableTypes, + VersionStr, + ) + + +@pytest.fixture(scope="session") +def get_commits_for_github_flow_repo_w_feature_release_channel() -> GetRepoDefinitionFn: + base_definition: dict[str, BaseRepoVersionDef] = { + "0.1.0": { + "changelog_sections": { + "angular": [{"section": "Unknown", "i_commits": [0]}], + "emoji": [{"section": "Other", "i_commits": [0]}], + "scipy": [{"section": "Unknown", "i_commits": [0]}], + "tag": [{"section": "Unknown", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "Initial commit", + "emoji": "Initial commit", + "scipy": "Initial commit", + "tag": "Initial commit", + } + ], + }, + "0.1.1-rc.1": { + "changelog_sections": { + "angular": [{"section": "Fix", "i_commits": [0]}], + "emoji": [{"section": ":bug:", "i_commits": [0]}], + "scipy": [{"section": "Fix", "i_commits": [0]}], + "tag": [{"section": "Fix", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "fix: add some more text", + "emoji": ":bug: add some more text", + "scipy": "MAINT: add some more text", + "tag": ":nut_and_bolt: add some more text", + } + ], + }, + "0.2.0-rc.1": { + "changelog_sections": { + "angular": [{"section": "Feature", "i_commits": [0]}], + "emoji": [{"section": ":sparkles:", "i_commits": [0]}], + "scipy": [{"section": "Feature", "i_commits": [0]}], + "tag": [{"section": "Feature", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "feat: add some more text", + "emoji": ":sparkles: add some more text", + "scipy": "ENH: add some more text", + "tag": ":sparkles: add some more text", + }, + ], + }, + "0.2.0": { + "changelog_sections": { + "angular": [{"section": "Feature", "i_commits": [0]}], + "emoji": [{"section": ":sparkles:", "i_commits": [0]}], + "scipy": [{"section": "Feature", "i_commits": [0]}], + "tag": [{"section": "Feature", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "feat: add some more text", + "emoji": ":sparkles: add some more text", + "scipy": "ENH: add some more text", + "tag": ":sparkles: add some more text", + }, + ], + }, + "0.3.0-beta.1": { + "changelog_sections": { + "angular": [{"section": "Feature", "i_commits": [0]}], + "emoji": [{"section": ":sparkles:", "i_commits": [0]}], + "scipy": [{"section": "Feature", "i_commits": [0]}], + "tag": [{"section": "Feature", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "feat(feature): add some more text", + "emoji": ":sparkles: (feature) add some more text", + "scipy": "ENH: (feature) add some more text", + "tag": ":sparkles: (feature) add some more text", + }, + ], + }, + } + + def _get_commits_for_github_flow_repo_w_feature_release_channel( + commit_type: CommitConvention = "angular", + ) -> RepoDefinition: + definition: RepoDefinition = {} + + for version, version_def in base_definition.items(): + definition[version] = { + # Extract the correct changelog section header for the commit type + "changelog_sections": deepcopy( + version_def["changelog_sections"][commit_type] + ), + "commits": [ + # Extract the correct commit message for the commit type + message_variants[commit_type] + for message_variants in version_def["commits"] + ], + } + + return definition + + return _get_commits_for_github_flow_repo_w_feature_release_channel + + +@pytest.fixture(scope="session") +def get_versions_for_github_flow_repo_w_feature_release_channel( + get_commits_for_github_flow_repo_w_feature_release_channel: GetRepoDefinitionFn, +) -> GetVersionStringsFn: + def _get_versions_for_github_flow_repo_w_feature_release_channel() -> ( + list[VersionStr] + ): + return list(get_commits_for_github_flow_repo_w_feature_release_channel().keys()) + + return _get_versions_for_github_flow_repo_w_feature_release_channel + + +@pytest.fixture(scope="session") +def build_github_flow_repo_w_feature_release_channel( + get_commits_for_github_flow_repo_w_feature_release_channel: GetRepoDefinitionFn, + build_configured_base_repo: BuildRepoFn, + default_tag_format_str: str, + changelog_md_file: Path, + simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, + simulate_default_changelog_creation: SimulateDefaultChangelogCreationFn, + create_release_tagged_commit: CreateReleaseFn, +) -> BuildRepoFn: + def _build_github_flow_repo_w_feature_release_channel( + dest_dir: Path | str, + commit_type: CommitConvention = "angular", + hvcs_client_name: str = "github", + hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, + tag_format_str: str | None = None, + extra_configs: dict[str, TomlSerializableTypes] | None = None, + ) -> tuple[Path, HvcsBase]: + repo_dir, hvcs = build_configured_base_repo( + dest_dir, + commit_type=commit_type, + hvcs_client_name=hvcs_client_name, + hvcs_domain=hvcs_domain, + tag_format_str=tag_format_str, + extra_configs={ + # branch "beta-testing" has prerelease suffix of "beta" + "tool.semantic_release.branches.beta-testing": { + "match": "beta.*", + "prerelease": True, + "prerelease_token": "beta", + }, + **(extra_configs or {}), + }, + ) + + # Retrieve/Define project vars that will be used to create the repo below + repo_def = get_commits_for_github_flow_repo_w_feature_release_channel( + commit_type + ) + versions = (key for key in repo_def) + next_version = next(versions) + next_version_def = repo_def[next_version] + + # must be after build_configured_base_repo() so we dont set the + # default tag format in the pyproject.toml (we want semantic-release to use its defaults) + # however we need it to manually create the tags it knows how to parse + tag_format = tag_format_str or default_tag_format_str + + with temporary_working_directory(repo_dir), Repo(".") as git_repo: + # commit initial files & update commit msg with sha & url + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make initial feature release (v0.1.0) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Make a patch level commit + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make a patch level release candidate (v0.1.1-rc.1) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Make a minor level commit + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make a minor level release candidate (v0.2.0-rc.1) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Make a minor level commit + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make a minor level release (v0.2.0) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Checkout beta_testing branch + git_repo.create_head("beta_testing") + git_repo.heads.beta_testing.checkout() + + # Make a feature level commit + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Write the expected changelog (should match template changelog) + simulate_default_changelog_creation( + repo_def, + repo_dir.joinpath(changelog_md_file), + ) + + # Make a feature level beta release (v0.3.0-beta.1) + create_release_tagged_commit(git_repo, next_version, tag_format) + + return repo_dir, hvcs + + return _build_github_flow_repo_w_feature_release_channel + + +# --------------------------------------------------------------------------- # +# Session-level fixtures to use to set up cached repositories on first use # +# --------------------------------------------------------------------------- # + + +@pytest.fixture(scope="session") +def cached_repo_w_github_flow_w_feature_release_channel_angular_commits( + build_github_flow_repo_w_feature_release_channel: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_w_github_flow_w_feature_release_channel_angular_commits.__name__ + ) + build_github_flow_repo_w_feature_release_channel(cached_repo_path, "angular") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_w_github_flow_w_feature_release_channel_emoji_commits( + build_github_flow_repo_w_feature_release_channel: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_w_github_flow_w_feature_release_channel_emoji_commits.__name__ + ) + build_github_flow_repo_w_feature_release_channel(cached_repo_path, "emoji") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_w_github_flow_w_feature_release_channel_scipy_commits( + build_github_flow_repo_w_feature_release_channel: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_w_github_flow_w_feature_release_channel_scipy_commits.__name__ + ) + build_github_flow_repo_w_feature_release_channel(cached_repo_path, "scipy") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_w_github_flow_w_feature_release_channel_tag_commits( + build_github_flow_repo_w_feature_release_channel: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_w_github_flow_w_feature_release_channel_tag_commits.__name__ + ) + build_github_flow_repo_w_feature_release_channel(cached_repo_path, "tag") + return teardown_cached_dir(cached_repo_path) + + +# --------------------------------------------------------------------------- # +# Test-level fixtures to use to set up temporary test directory # +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def repo_w_github_flow_w_feature_release_channel_angular_commits( + cached_repo_w_github_flow_w_feature_release_channel_angular_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_w_github_flow_w_feature_release_channel_angular_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_w_github_flow_w_feature_release_channel_angular_commits, + example_project_dir, + ) + return example_project_git_repo() + + +@pytest.fixture +def repo_w_github_flow_w_feature_release_channel_emoji_commits( + cached_repo_w_github_flow_w_feature_release_channel_emoji_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: Path, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_w_github_flow_w_feature_release_channel_emoji_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_w_github_flow_w_feature_release_channel_emoji_commits, + example_project_dir, + ) + return example_project_git_repo() + + +@pytest.fixture +def repo_w_github_flow_w_feature_release_channel_scipy_commits( + cached_repo_w_github_flow_w_feature_release_channel_scipy_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: Path, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_w_github_flow_w_feature_release_channel_scipy_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_w_github_flow_w_feature_release_channel_scipy_commits, + example_project_dir, + ) + return example_project_git_repo() + + +@pytest.fixture +def repo_w_github_flow_w_feature_release_channel_tag_commits( + cached_repo_w_github_flow_w_feature_release_channel_tag_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: Path, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_w_github_flow_w_feature_release_channel_tag_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_w_github_flow_w_feature_release_channel_tag_commits, + example_project_dir, + ) + return example_project_git_repo() diff --git a/tests/fixtures/repos/trunk_based_dev/__init__.py b/tests/fixtures/repos/trunk_based_dev/__init__.py new file mode 100644 index 000000000..9e40a0157 --- /dev/null +++ b/tests/fixtures/repos/trunk_based_dev/__init__.py @@ -0,0 +1,3 @@ +from tests.fixtures.repos.trunk_based_dev.repo_w_no_tags import * +from tests.fixtures.repos.trunk_based_dev.repo_w_prereleases import * +from tests.fixtures.repos.trunk_based_dev.repo_w_tags import * diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py b/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py new file mode 100644 index 000000000..06ff258ae --- /dev/null +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING + +import pytest +from git import Repo + +from tests.const import EXAMPLE_HVCS_DOMAIN +from tests.util import copy_dir_tree, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + + from semantic_release.hvcs import HvcsBase + + from tests.conftest import TeardownCachedDirFn + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BaseRepoVersionDef, + BuildRepoFn, + CommitConvention, + ExProjectGitRepoFn, + GetRepoDefinitionFn, + GetVersionStringsFn, + RepoDefinition, + SimulateChangeCommitsNReturnChangelogEntryFn, + SimulateDefaultChangelogCreationFn, + TomlSerializableTypes, + VersionStr, + ) + + +@pytest.fixture(scope="session") +def get_commits_for_trunk_only_repo_w_no_tags() -> GetRepoDefinitionFn: + base_definition: dict[str, BaseRepoVersionDef] = { + "Unreleased": { + "changelog_sections": { + # ORDER matters here since greater than 1 commit, changelogs sections are alphabetized + # But value is ultimately defined by the commits, which means the commits are + # referenced by index value + "angular": [ + {"section": "Feature", "i_commits": [2]}, + {"section": "Fix", "i_commits": [3, 1]}, + {"section": "Unknown", "i_commits": [0]}, + ], + "emoji": [ + {"section": ":bug:", "i_commits": [3, 1]}, + {"section": ":sparkles:", "i_commits": [2]}, + {"section": "Other", "i_commits": [0]}, + ], + "scipy": [ + {"section": "Feature", "i_commits": [2]}, + {"section": "Fix", "i_commits": [3, 1]}, + {"section": "Unknown", "i_commits": [0]}, + ], + "tag": [ + {"section": "Feature", "i_commits": [2]}, + {"section": "Fix", "i_commits": [3, 1]}, + {"section": "Unknown", "i_commits": [0]}, + ], + }, + "commits": [ + { + "angular": "Initial commit", + "emoji": "Initial commit", + "scipy": "Initial commit", + "tag": "Initial commit", + }, + { + "angular": "fix: add some more text", + "emoji": ":bug: add some more text", + "scipy": "MAINT: add some more text", + "tag": ":nut_and_bolt: add some more text", + }, + { + "angular": "feat: add much more text", + "emoji": ":sparkles: add much more text", + "scipy": "ENH: add much more text", + "tag": ":sparkles: add much more text", + }, + { + "angular": "fix: more text", + "emoji": ":bug: more text", + "scipy": "MAINT: more text", + "tag": ":nut_and_bolt: more text", + }, + ], + }, + } + + def _get_commits_for_trunk_only_repo_w_no_tags( + commit_type: CommitConvention = "angular", + ) -> RepoDefinition: + definition: RepoDefinition = {} + + for version, version_def in base_definition.items(): + definition[version] = { + # Extract the correct changelog section header for the commit type + "changelog_sections": deepcopy( + version_def["changelog_sections"][commit_type] + ), + "commits": [ + # Extract the correct commit message for the commit type + message_variants[commit_type] + for message_variants in version_def["commits"] + ], + } + + return definition + + return _get_commits_for_trunk_only_repo_w_no_tags + + +@pytest.fixture(scope="session") +def get_versions_for_trunk_only_repo_w_no_tags( + get_commits_for_trunk_only_repo_w_no_tags: GetRepoDefinitionFn, +) -> GetVersionStringsFn: + def _get_versions_for_trunk_only_repo_w_no_tags() -> list[VersionStr]: + return list(get_commits_for_trunk_only_repo_w_no_tags().keys()) + + return _get_versions_for_trunk_only_repo_w_no_tags + + +@pytest.fixture(scope="session") +def build_trunk_only_repo_w_no_tags( + get_commits_for_trunk_only_repo_w_no_tags: GetRepoDefinitionFn, + build_configured_base_repo: BuildRepoFn, + changelog_md_file: Path, + simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, + simulate_default_changelog_creation: SimulateDefaultChangelogCreationFn, +) -> BuildRepoFn: + def _build_trunk_only_repo_w_no_tags( + dest_dir: Path | str, + commit_type: CommitConvention = "angular", + hvcs_client_name: str = "github", + hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, + tag_format_str: str | None = None, + extra_configs: dict[str, TomlSerializableTypes] | None = None, + ) -> tuple[Path, HvcsBase]: + repo_dir, hvcs = build_configured_base_repo( + dest_dir, + commit_type=commit_type, + hvcs_client_name=hvcs_client_name, + hvcs_domain=hvcs_domain, + tag_format_str=tag_format_str, + extra_configs=extra_configs, + ) + + repo_def = get_commits_for_trunk_only_repo_w_no_tags(commit_type) + versions = (key for key in repo_def) + next_version = next(versions) + next_version_def = repo_def[next_version] + + with temporary_working_directory(repo_dir), Repo(".") as git_repo: + # Run set up commits + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # write expected changelog (should match template changelog) + simulate_default_changelog_creation( + repo_def, + repo_dir.joinpath(changelog_md_file), + ) + + return repo_dir, hvcs + + return _build_trunk_only_repo_w_no_tags + + +# --------------------------------------------------------------------------- # +# Session-level fixtures to use to set up cached repositories on first use # +# --------------------------------------------------------------------------- # + + +@pytest.fixture(scope="session") +def cached_repo_with_no_tags_angular_commits( + build_trunk_only_repo_w_no_tags: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_with_no_tags_angular_commits.__name__ + ) + build_trunk_only_repo_w_no_tags(cached_repo_path, "angular") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_with_no_tags_emoji_commits( + build_trunk_only_repo_w_no_tags: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_with_no_tags_emoji_commits.__name__ + ) + build_trunk_only_repo_w_no_tags(cached_repo_path, "emoji") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_with_no_tags_scipy_commits( + build_trunk_only_repo_w_no_tags: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_with_no_tags_scipy_commits.__name__ + ) + build_trunk_only_repo_w_no_tags(cached_repo_path, "scipy") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_with_no_tags_tag_commits( + build_trunk_only_repo_w_no_tags: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_with_no_tags_tag_commits.__name__ + ) + build_trunk_only_repo_w_no_tags(cached_repo_path, "tag") + return teardown_cached_dir(cached_repo_path) + + +# --------------------------------------------------------------------------- # +# Test-level fixtures to use to set up temporary test directory # +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def repo_with_no_tags_angular_commits( + cached_repo_with_no_tags_angular_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_with_no_tags_angular_commits.exists(): + raise RuntimeError("Unable to find cached repo!") + copy_dir_tree(cached_repo_with_no_tags_angular_commits, example_project_dir) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_no_tags_emoji_commits( + cached_repo_with_no_tags_emoji_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_with_no_tags_emoji_commits.exists(): + raise RuntimeError("Unable to find cached repo!") + copy_dir_tree(cached_repo_with_no_tags_emoji_commits, example_project_dir) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_no_tags_scipy_commits( + cached_repo_with_no_tags_scipy_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_with_no_tags_scipy_commits.exists(): + raise RuntimeError("Unable to find cached repo!") + copy_dir_tree(cached_repo_with_no_tags_scipy_commits, example_project_dir) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_no_tags_tag_commits( + cached_repo_with_no_tags_tag_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_with_no_tags_tag_commits.exists(): + raise RuntimeError("Unable to find cached repo!") + copy_dir_tree(cached_repo_with_no_tags_tag_commits, example_project_dir) + return example_project_git_repo() diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_prereleases.py b/tests/fixtures/repos/trunk_based_dev/repo_w_prereleases.py new file mode 100644 index 000000000..b013dcee5 --- /dev/null +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_prereleases.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING + +import pytest +from git import Repo + +from tests.const import EXAMPLE_HVCS_DOMAIN +from tests.util import copy_dir_tree, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + + from semantic_release.hvcs import HvcsBase + + from tests.conftest import TeardownCachedDirFn + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BaseRepoVersionDef, + BuildRepoFn, + CommitConvention, + CreateReleaseFn, + ExProjectGitRepoFn, + GetRepoDefinitionFn, + GetVersionStringsFn, + RepoDefinition, + SimulateChangeCommitsNReturnChangelogEntryFn, + SimulateDefaultChangelogCreationFn, + TomlSerializableTypes, + VersionStr, + ) + + +@pytest.fixture(scope="session") +def get_commits_for_trunk_only_repo_w_prerelease_tags() -> GetRepoDefinitionFn: + base_definition: dict[str, BaseRepoVersionDef] = { + "0.1.0": { + "changelog_sections": { + "angular": [{"section": "Unknown", "i_commits": [0]}], + "emoji": [{"section": "Other", "i_commits": [0]}], + "scipy": [{"section": "Unknown", "i_commits": [0]}], + "tag": [{"section": "Unknown", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "Initial commit", + "emoji": "Initial commit", + "scipy": "Initial commit", + "tag": "Initial commit", + } + ], + }, + "0.1.1-rc.1": { + "changelog_sections": { + "angular": [{"section": "Fix", "i_commits": [0]}], + "emoji": [{"section": ":bug:", "i_commits": [0]}], + "scipy": [{"section": "Fix", "i_commits": [0]}], + "tag": [{"section": "Fix", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "fix: add some more text", + "emoji": ":bug: add some more text", + "scipy": "MAINT: add some more text", + "tag": ":nut_and_bolt: add some more text", + } + ], + }, + "0.2.0-rc.1": { + "changelog_sections": { + "angular": [{"section": "Feature", "i_commits": [0]}], + "emoji": [{"section": ":sparkles:", "i_commits": [0]}], + "scipy": [{"section": "Feature", "i_commits": [0]}], + "tag": [{"section": "Feature", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "feat: add some more text", + "emoji": ":sparkles: add some more text", + "scipy": "ENH: add some more text", + "tag": ":sparkles: add some more text", + }, + ], + }, + "0.2.0": { + "changelog_sections": { + "angular": [{"section": "Feature", "i_commits": [0]}], + "emoji": [{"section": ":sparkles:", "i_commits": [0]}], + "scipy": [{"section": "Feature", "i_commits": [0]}], + "tag": [{"section": "Feature", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "feat: add some more text", + "emoji": ":sparkles: add some more text", + "scipy": "ENH: add some more text", + "tag": ":sparkles: add some more text", + }, + ], + }, + } + + def _get_commits_for_trunk_only_repo_w_prerelease_tags( + commit_type: CommitConvention = "angular", + ) -> RepoDefinition: + definition: RepoDefinition = {} + + for version, version_def in base_definition.items(): + definition[version] = { + # Extract the correct changelog section header for the commit type + "changelog_sections": deepcopy( + version_def["changelog_sections"][commit_type] + ), + "commits": [ + # Extract the correct commit message for the commit type + message_variants[commit_type] + for message_variants in version_def["commits"] + ], + } + + return definition + + return _get_commits_for_trunk_only_repo_w_prerelease_tags + + +@pytest.fixture(scope="session") +def get_versions_for_trunk_only_repo_w_prerelease_tags( + get_commits_for_trunk_only_repo_w_prerelease_tags: GetRepoDefinitionFn, +) -> GetVersionStringsFn: + def _get_versions_for_trunk_only_repo_w_prerelease_tags() -> list[VersionStr]: + return list(get_commits_for_trunk_only_repo_w_prerelease_tags().keys()) + + return _get_versions_for_trunk_only_repo_w_prerelease_tags + + +@pytest.fixture(scope="session") +def build_trunk_only_repo_w_prerelease_tags( + get_commits_for_trunk_only_repo_w_prerelease_tags: GetRepoDefinitionFn, + build_configured_base_repo: BuildRepoFn, + default_tag_format_str: str, + changelog_md_file: Path, + simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, + simulate_default_changelog_creation: SimulateDefaultChangelogCreationFn, + create_release_tagged_commit: CreateReleaseFn, +) -> BuildRepoFn: + def _build_trunk_only_repo_w_prerelease_tags( + dest_dir: Path | str, + commit_type: CommitConvention = "angular", + hvcs_client_name: str = "github", + hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, + tag_format_str: str | None = None, + extra_configs: dict[str, TomlSerializableTypes] | None = None, + ) -> tuple[Path, HvcsBase]: + repo_dir, hvcs = build_configured_base_repo( + dest_dir, + commit_type=commit_type, + hvcs_client_name=hvcs_client_name, + hvcs_domain=hvcs_domain, + tag_format_str=tag_format_str, + extra_configs=extra_configs, + ) + + repo_def = get_commits_for_trunk_only_repo_w_prerelease_tags(commit_type) + versions = (key for key in repo_def) + next_version = next(versions) + next_version_def = repo_def[next_version] + + # must be after build_configured_base_repo() so we dont set the + # default tag format in the pyproject.toml (we want semantic-release to use its defaults) + # however we need it to manually create the tags it knows how to parse + tag_format = tag_format_str or default_tag_format_str + + with temporary_working_directory(repo_dir), Repo(".") as git_repo: + # commit initial files & update commit msg with sha & url + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make initial feature release (v0.1.0) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Add a patch level change + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make a patch level release candidate (v0.1.1-rc.1) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Make a minor level change + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Make the next feature level prerelease (v0.2.0-rc.1) + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Make a minor level change + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # write expected changelog (should match template changelog) + simulate_default_changelog_creation( + repo_def, + repo_dir.joinpath(changelog_md_file), + ) + + # Make a full release + create_release_tagged_commit(git_repo, next_version, tag_format) + + return repo_dir, hvcs + + return _build_trunk_only_repo_w_prerelease_tags + + +# --------------------------------------------------------------------------- # +# Session-level fixtures to use to set up cached repositories on first use # +# --------------------------------------------------------------------------- # + + +@pytest.fixture(scope="session") +def cached_repo_with_single_branch_and_prereleases_angular_commits( + build_trunk_only_repo_w_prerelease_tags: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_with_single_branch_and_prereleases_angular_commits.__name__ + ) + build_trunk_only_repo_w_prerelease_tags(cached_repo_path, "angular") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_with_single_branch_and_prereleases_emoji_commits( + build_trunk_only_repo_w_prerelease_tags: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_with_single_branch_and_prereleases_emoji_commits.__name__ + ) + build_trunk_only_repo_w_prerelease_tags(cached_repo_path, "emoji") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_with_single_branch_and_prereleases_scipy_commits( + build_trunk_only_repo_w_prerelease_tags: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_with_single_branch_and_prereleases_scipy_commits.__name__ + ) + build_trunk_only_repo_w_prerelease_tags(cached_repo_path, "scipy") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_with_single_branch_and_prereleases_tag_commits( + build_trunk_only_repo_w_prerelease_tags: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_with_single_branch_and_prereleases_tag_commits.__name__ + ) + build_trunk_only_repo_w_prerelease_tags(cached_repo_path, "tag") + return teardown_cached_dir(cached_repo_path) + + +# --------------------------------------------------------------------------- # +# Test-level fixtures to use to set up temporary test directory # +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def repo_with_single_branch_and_prereleases_angular_commits( + cached_repo_with_single_branch_and_prereleases_angular_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_with_single_branch_and_prereleases_angular_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_with_single_branch_and_prereleases_angular_commits, + example_project_dir, + ) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_single_branch_and_prereleases_emoji_commits( + cached_repo_with_single_branch_and_prereleases_emoji_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_with_single_branch_and_prereleases_emoji_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_with_single_branch_and_prereleases_emoji_commits, + example_project_dir, + ) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_single_branch_and_prereleases_scipy_commits( + cached_repo_with_single_branch_and_prereleases_scipy_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_with_single_branch_and_prereleases_scipy_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_with_single_branch_and_prereleases_scipy_commits, + example_project_dir, + ) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_single_branch_and_prereleases_tag_commits( + cached_repo_with_single_branch_and_prereleases_tag_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_with_single_branch_and_prereleases_tag_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree( + cached_repo_with_single_branch_and_prereleases_tag_commits, example_project_dir + ) + return example_project_git_repo() diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py b/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py new file mode 100644 index 000000000..0669e684d --- /dev/null +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING + +import pytest +from git import Repo + +from tests.const import EXAMPLE_HVCS_DOMAIN +from tests.util import copy_dir_tree, temporary_working_directory + +if TYPE_CHECKING: + from pathlib import Path + + from semantic_release.hvcs import HvcsBase + + from tests.conftest import TeardownCachedDirFn + from tests.fixtures.example_project import ExProjectDir + from tests.fixtures.git_repo import ( + BaseRepoVersionDef, + BuildRepoFn, + CommitConvention, + CreateReleaseFn, + ExProjectGitRepoFn, + GetRepoDefinitionFn, + GetVersionStringsFn, + RepoDefinition, + SimulateChangeCommitsNReturnChangelogEntryFn, + SimulateDefaultChangelogCreationFn, + TomlSerializableTypes, + VersionStr, + ) + + +@pytest.fixture(scope="session") +def get_commits_for_trunk_only_repo_w_tags() -> GetRepoDefinitionFn: + base_definition: dict[str, BaseRepoVersionDef] = { + "0.1.0": { + "changelog_sections": { + "angular": [{"section": "Unknown", "i_commits": [0]}], + "emoji": [{"section": "Other", "i_commits": [0]}], + "scipy": [{"section": "Unknown", "i_commits": [0]}], + "tag": [{"section": "Unknown", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "Initial commit", + "emoji": "Initial commit", + "scipy": "Initial commit", + "tag": "Initial commit", + } + ], + }, + "0.1.1": { + "changelog_sections": { + "angular": [{"section": "Fix", "i_commits": [0]}], + "emoji": [{"section": ":bug:", "i_commits": [0]}], + "scipy": [{"section": "Fix", "i_commits": [0]}], + "tag": [{"section": "Fix", "i_commits": [0]}], + }, + "commits": [ + { + "angular": "fix: add some more text", + "emoji": ":bug: add some more text", + "scipy": "MAINT: add some more text", + "tag": ":nut_and_bolt: add some more text", + } + ], + }, + } + + def _get_commits_for_trunk_only_repo_w_tags( + commit_type: CommitConvention = "angular", + ) -> RepoDefinition: + definition: RepoDefinition = {} + + for version, version_def in base_definition.items(): + definition[version] = { + # Extract the correct changelog section header for the commit type + "changelog_sections": deepcopy( + version_def["changelog_sections"][commit_type] + ), + "commits": [ + # Extract the correct commit message for the commit type + message_variants[commit_type] + for message_variants in version_def["commits"] + ], + } + + return definition + + return _get_commits_for_trunk_only_repo_w_tags + + +@pytest.fixture(scope="session") +def get_versions_for_trunk_only_repo_w_tags( + get_commits_for_trunk_only_repo_w_tags: GetRepoDefinitionFn, +) -> GetVersionStringsFn: + def _get_versions_for_trunk_only_repo_w_tags() -> list[VersionStr]: + return list(get_commits_for_trunk_only_repo_w_tags().keys()) + + return _get_versions_for_trunk_only_repo_w_tags + + +@pytest.fixture(scope="session") +def build_trunk_only_repo_w_tags( + get_commits_for_trunk_only_repo_w_tags: GetRepoDefinitionFn, + build_configured_base_repo: BuildRepoFn, + default_tag_format_str: str, + changelog_md_file: Path, + simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, + simulate_default_changelog_creation: SimulateDefaultChangelogCreationFn, + create_release_tagged_commit: CreateReleaseFn, +) -> BuildRepoFn: + def _build_trunk_only_repo_w_tags( + dest_dir: Path | str, + commit_type: CommitConvention = "angular", + hvcs_client_name: str = "github", + hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, + tag_format_str: str | None = None, + extra_configs: dict[str, TomlSerializableTypes] | None = None, + ) -> tuple[Path, HvcsBase]: + repo_dir, hvcs = build_configured_base_repo( + dest_dir, + commit_type=commit_type, + hvcs_client_name=hvcs_client_name, + hvcs_domain=hvcs_domain, + tag_format_str=tag_format_str, + extra_configs=extra_configs, + ) + + repo_def = get_commits_for_trunk_only_repo_w_tags(commit_type) + versions = (key for key in repo_def) + next_version = next(versions) + next_version_def = repo_def[next_version] + + # must be after build_configured_base_repo() so we dont set the + # default tag format in the pyproject.toml (we want semantic-release to use its defaults) + # however we need it to manually create the tags it knows how to parse + tag_format = tag_format_str or default_tag_format_str + + # Run Git operations to simulate repo commit & release history + with temporary_working_directory(repo_dir), Repo(".") as git_repo: + # commit initial files & update commit msg with sha & url + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # Publish initial feature release (v0.1.0) [updates tool.poetry.version] + create_release_tagged_commit(git_repo, next_version, tag_format) + + # Increment version pointer + next_version = next(versions) + next_version_def = repo_def[next_version] + + # Add a patch level change + next_version_def["commits"] = simulate_change_commits_n_rtn_changelog_entry( + git_repo, next_version_def["commits"], hvcs + ) + + # write expected changelog (should match template changelog) + simulate_default_changelog_creation( + repo_def, + repo_dir.joinpath(changelog_md_file), + ) + + # Make a patch level release (v0.1.1) + create_release_tagged_commit(git_repo, next_version, tag_format) + + return repo_dir, hvcs + + return _build_trunk_only_repo_w_tags + + +# --------------------------------------------------------------------------- # +# Session-level fixtures to use to set up cached repositories on first use # +# --------------------------------------------------------------------------- # + + +@pytest.fixture(scope="session") +def cached_repo_with_single_branch_angular_commits( + build_trunk_only_repo_w_tags: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_with_single_branch_angular_commits.__name__ + ) + build_trunk_only_repo_w_tags(cached_repo_path, "angular") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_with_single_branch_emoji_commits( + build_trunk_only_repo_w_tags: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_with_single_branch_emoji_commits.__name__ + ) + build_trunk_only_repo_w_tags(cached_repo_path, "emoji") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_with_single_branch_scipy_commits( + build_trunk_only_repo_w_tags: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_with_single_branch_scipy_commits.__name__ + ) + build_trunk_only_repo_w_tags(cached_repo_path, "scipy") + return teardown_cached_dir(cached_repo_path) + + +@pytest.fixture(scope="session") +def cached_repo_with_single_branch_tag_commits( + build_trunk_only_repo_w_tags: BuildRepoFn, + cached_files_dir: Path, + teardown_cached_dir: TeardownCachedDirFn, +) -> Path: + cached_repo_path = cached_files_dir.joinpath( + cached_repo_with_single_branch_tag_commits.__name__ + ) + build_trunk_only_repo_w_tags(cached_repo_path, "tag") + return teardown_cached_dir(cached_repo_path) + + +# --------------------------------------------------------------------------- # +# Test-level fixtures to use to set up temporary test directory # +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def repo_with_single_branch_angular_commits( + cached_repo_with_single_branch_angular_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_with_single_branch_angular_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree(cached_repo_with_single_branch_angular_commits, example_project_dir) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_single_branch_emoji_commits( + cached_repo_with_single_branch_emoji_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_with_single_branch_emoji_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree(cached_repo_with_single_branch_emoji_commits, example_project_dir) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_single_branch_scipy_commits( + cached_repo_with_single_branch_scipy_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_with_single_branch_scipy_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree(cached_repo_with_single_branch_scipy_commits, example_project_dir) + return example_project_git_repo() + + +@pytest.fixture +def repo_with_single_branch_tag_commits( + cached_repo_with_single_branch_tag_commits: Path, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> Repo: + if not cached_repo_with_single_branch_tag_commits.exists(): + raise RuntimeError("Unable to find cached repository!") + copy_dir_tree(cached_repo_with_single_branch_tag_commits, example_project_dir) + return example_project_git_repo() diff --git a/tests/fixtures/scipy.py b/tests/fixtures/scipy.py index 7bdfcd543..0023ece5c 100644 --- a/tests/fixtures/scipy.py +++ b/tests/fixtures/scipy.py @@ -71,6 +71,11 @@ def valid_scipy_commit(scipy_tag, subject, body_parts): return _make_scipy_commit(scipy_tag, subject, body_parts) +@pytest.fixture +def scipy_chore_commits(): + return ["DOC: Add a note to the documentation"] + + @pytest.fixture( params=xdist_sort_hack( [ @@ -80,7 +85,7 @@ def valid_scipy_commit(scipy_tag, subject, body_parts): ] ) ) -def scipy_commits_patch(request, subject): +def scipy_patch_commits(request, subject): return [ _make_scipy_commit(request.param, subject, body_parts) for body_parts in SCIPY_FORMATTED_COMMIT_BODY_PARTS @@ -96,7 +101,7 @@ def scipy_commits_patch(request, subject): ] ) ) -def scipy_commits_minor(request, subject, default_scipy_parser_options): +def scipy_minor_commits(request, subject, default_scipy_parser_options): patch_tags = [ k for k, v in default_scipy_parser_options.tag_to_level.items() @@ -126,7 +131,7 @@ def scipy_commits_minor(request, subject, default_scipy_parser_options): ] ) ) -def scipy_commits_major(request, subject, default_scipy_parser_options): +def scipy_major_commits(request, subject, default_scipy_parser_options): patch_tags = [ k for k, v in default_scipy_parser_options.tag_to_level.items() diff --git a/tests/scenario/test_next_version.py b/tests/scenario/test_next_version.py index 6f25e8600..ac059b30d 100644 --- a/tests/scenario/test_next_version.py +++ b/tests/scenario/test_next_version.py @@ -1,11 +1,14 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + import pytest # Limitation in pytest-lazy-fixture - see https://stackoverflow.com/a/69884019 -from pytest import lazy_fixture +from pytest_lazyfixture import lazy_fixture from semantic_release.version.algorithm import next_version from semantic_release.version.translator import VersionTranslator -from semantic_release.version.version import Version from tests.const import ( ANGULAR_COMMITS_MAJOR, @@ -18,8 +21,106 @@ TAG_COMMITS_MINOR, TAG_COMMITS_PATCH, ) +from tests.fixtures import ( + default_angular_parser, + default_emoji_parser, + default_scipy_parser, + default_tag_parser, + repo_w_github_flow_w_feature_release_channel_angular_commits, + repo_w_github_flow_w_feature_release_channel_emoji_commits, + repo_w_github_flow_w_feature_release_channel_scipy_commits, + repo_w_github_flow_w_feature_release_channel_tag_commits, + repo_with_git_flow_and_release_channels_angular_commits, + repo_with_git_flow_and_release_channels_emoji_commits, + repo_with_git_flow_and_release_channels_scipy_commits, + repo_with_git_flow_and_release_channels_tag_commits, + repo_with_git_flow_angular_commits, + repo_with_git_flow_emoji_commits, + repo_with_git_flow_scipy_commits, + repo_with_git_flow_tag_commits, + repo_with_no_tags_angular_commits, + repo_with_no_tags_emoji_commits, + repo_with_no_tags_scipy_commits, + repo_with_no_tags_tag_commits, + repo_with_single_branch_and_prereleases_angular_commits, + repo_with_single_branch_and_prereleases_emoji_commits, + repo_with_single_branch_and_prereleases_scipy_commits, + repo_with_single_branch_and_prereleases_tag_commits, + repo_with_single_branch_angular_commits, + repo_with_single_branch_emoji_commits, + repo_with_single_branch_scipy_commits, + repo_with_single_branch_tag_commits, + scipy_chore_commits, + scipy_major_commits, + scipy_minor_commits, + scipy_patch_commits, +) from tests.util import add_text_to_file, xdist_sort_hack +if TYPE_CHECKING: + from git import Repo + + +@pytest.fixture +def angular_major_commits(): + return ANGULAR_COMMITS_MAJOR + + +@pytest.fixture +def angular_minor_commits(): + return ANGULAR_COMMITS_MINOR + + +@pytest.fixture +def angular_patch_commits(): + return ANGULAR_COMMITS_PATCH + + +@pytest.fixture +def angular_chore_commits() -> list[str]: + return ["chore: change dev tool configuration"] + + +@pytest.fixture +def emoji_major_commits(): + return EMOJI_COMMITS_MAJOR + + +@pytest.fixture +def emoji_minor_commits(): + return EMOJI_COMMITS_MINOR + + +@pytest.fixture +def emoji_patch_commits(): + return EMOJI_COMMITS_PATCH + + +@pytest.fixture +def emoji_chore_commits() -> list[str]: + return [":broom: change dev tool configuration"] + + +@pytest.fixture +def tag_patch_commits(): + return TAG_COMMITS_PATCH + + +@pytest.fixture +def tag_minor_commits(): + return TAG_COMMITS_MINOR + + +@pytest.fixture +def tag_major_commits(): + return TAG_COMMITS_MAJOR + + +@pytest.fixture +def tag_chore_commits() -> list[str]: + return [":broom: change dev tool configuration"] + + # TODO: it'd be nice to not hard-code the versions into # this testing @@ -51,46 +152,88 @@ # The last full release version was 1.1.1, so it's had a minor # prerelease ( - "repo_with_git_flow_angular_commits", - "default_angular_parser", + repo_with_git_flow_angular_commits.__name__, + default_angular_parser.__name__, VersionTranslator(prerelease_token="alpha"), ): [ *( (commits, True, "1.2.0-alpha.2") - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(angular_chore_commits.__name__), + ) ), # Models a merge of commits from the branch to the main branch, now # that prerelease=False - *((commits, False, "1.2.0") for commits in ([], ["uninteresting"])), - (ANGULAR_COMMITS_PATCH, False, "1.2.0"), - (ANGULAR_COMMITS_PATCH, True, "1.2.0-alpha.3"), - (ANGULAR_COMMITS_MINOR, False, "1.2.0"), - (ANGULAR_COMMITS_MINOR, True, "1.2.0-alpha.3"), - (ANGULAR_COMMITS_MAJOR, False, "2.0.0"), - (ANGULAR_COMMITS_MAJOR, True, "2.0.0-alpha.1"), + *( + (commits, False, "1.2.0") + for commits in ( + None, + lazy_fixture(angular_chore_commits.__name__), + ) + ), + (lazy_fixture(angular_patch_commits.__name__), False, "1.2.0"), + ( + lazy_fixture(angular_patch_commits.__name__), + True, + "1.2.0-alpha.3", + ), + (lazy_fixture(angular_minor_commits.__name__), False, "1.2.0"), + ( + lazy_fixture(angular_minor_commits.__name__), + True, + "1.2.0-alpha.3", + ), + (lazy_fixture(angular_major_commits.__name__), False, "2.0.0"), + ( + lazy_fixture(angular_major_commits.__name__), + True, + "2.0.0-alpha.1", + ), ], # Latest version for repo_with_git_flow_and_release_channels is # currently 1.1.0-alpha.3 # The last full release version was 1.0.0, so it's had a minor # prerelease ( - "repo_with_git_flow_and_release_channels_angular_commits", - "default_angular_parser", + repo_with_git_flow_and_release_channels_angular_commits.__name__, + default_angular_parser.__name__, VersionTranslator(prerelease_token="alpha"), ): [ *( (commits, True, "1.1.0-alpha.3") - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(angular_chore_commits.__name__), + ) ), # Models a merge of commits from the branch to the main branch, now # that prerelease=False - *((commits, False, "1.1.0") for commits in ([], ["uninteresting"])), - (ANGULAR_COMMITS_PATCH, False, "1.1.0"), - (ANGULAR_COMMITS_PATCH, True, "1.1.0-alpha.4"), - (ANGULAR_COMMITS_MINOR, False, "1.1.0"), - (ANGULAR_COMMITS_MINOR, True, "1.1.0-alpha.4"), - (ANGULAR_COMMITS_MAJOR, False, "2.0.0"), - (ANGULAR_COMMITS_MAJOR, True, "2.0.0-alpha.1"), + *( + (commits, False, "1.1.0") + for commits in ( + None, + lazy_fixture(angular_chore_commits.__name__), + ) + ), + (lazy_fixture(angular_patch_commits.__name__), False, "1.1.0"), + ( + lazy_fixture(angular_patch_commits.__name__), + True, + "1.1.0-alpha.4", + ), + (lazy_fixture(angular_minor_commits.__name__), False, "1.1.0"), + ( + lazy_fixture(angular_minor_commits.__name__), + True, + "1.1.0-alpha.4", + ), + (lazy_fixture(angular_major_commits.__name__), False, "2.0.0"), + ( + lazy_fixture(angular_major_commits.__name__), + True, + "2.0.0-alpha.1", + ), ], }.items() for (commit_messages, prerelease, expected_new_version) in values @@ -98,6 +241,7 @@ ), ) @pytest.mark.parametrize("major_on_zero", [True, False]) +@pytest.mark.parametrize("allow_zero_version", [True, False]) def test_algorithm_no_zero_dot_versions_angular( repo, file_in_repo, @@ -107,18 +251,20 @@ def test_algorithm_no_zero_dot_versions_angular( prerelease, expected_new_version, major_on_zero, + allow_zero_version, ): - for commit_message in commit_messages: + # Setup + for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message) + # Action new_version = next_version( - repo, translator, commit_parser, prerelease, major_on_zero + repo, translator, commit_parser, prerelease, major_on_zero, allow_zero_version ) - assert new_version == Version.parse( - expected_new_version, prerelease_token=translator.prerelease_token - ) + # Verify + assert expected_new_version == str(new_version) @pytest.mark.parametrize( @@ -139,46 +285,64 @@ def test_algorithm_no_zero_dot_versions_angular( # The last full release version was 1.1.1, so it's had a minor # prerelease ( - "repo_with_git_flow_emoji_commits", - "default_emoji_parser", + repo_with_git_flow_emoji_commits.__name__, + default_emoji_parser.__name__, VersionTranslator(prerelease_token="alpha"), ): [ *( (commits, True, "1.2.0-alpha.2") - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(emoji_chore_commits.__name__), + ) ), # Models a merge of commits from the branch to the main branch, now # that prerelease=False - *((commits, False, "1.2.0") for commits in ([], ["uninteresting"])), - (EMOJI_COMMITS_PATCH, False, "1.2.0"), - (EMOJI_COMMITS_PATCH, True, "1.2.0-alpha.3"), - (EMOJI_COMMITS_MINOR, False, "1.2.0"), - (EMOJI_COMMITS_MINOR, True, "1.2.0-alpha.3"), - (EMOJI_COMMITS_MAJOR, False, "2.0.0"), - (EMOJI_COMMITS_MAJOR, True, "2.0.0-alpha.1"), + *( + (commits, False, "1.2.0") + for commits in ( + None, + lazy_fixture(emoji_chore_commits.__name__), + ) + ), + (lazy_fixture(emoji_patch_commits.__name__), False, "1.2.0"), + (lazy_fixture(emoji_patch_commits.__name__), True, "1.2.0-alpha.3"), + (lazy_fixture(emoji_minor_commits.__name__), False, "1.2.0"), + (lazy_fixture(emoji_minor_commits.__name__), True, "1.2.0-alpha.3"), + (lazy_fixture(emoji_major_commits.__name__), False, "2.0.0"), + (lazy_fixture(emoji_major_commits.__name__), True, "2.0.0-alpha.1"), ], # Latest version for repo_with_git_flow_and_release_channels is # currently 1.1.0-alpha.3 # The last full release version was 1.0.0, so it's had a minor # prerelease ( - "repo_with_git_flow_and_release_channels_emoji_commits", - "default_emoji_parser", + repo_with_git_flow_and_release_channels_emoji_commits.__name__, + default_emoji_parser.__name__, VersionTranslator(prerelease_token="alpha"), ): [ *( (commits, True, "1.1.0-alpha.3") - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(emoji_chore_commits.__name__), + ) ), # Models a merge of commits from the branch to the main branch, now # that prerelease=False - *((commits, False, "1.1.0") for commits in ([], ["uninteresting"])), - (EMOJI_COMMITS_PATCH, False, "1.1.0"), - (EMOJI_COMMITS_PATCH, True, "1.1.0-alpha.4"), - (EMOJI_COMMITS_MINOR, False, "1.1.0"), - (EMOJI_COMMITS_MINOR, True, "1.1.0-alpha.4"), - (EMOJI_COMMITS_MAJOR, False, "2.0.0"), - (EMOJI_COMMITS_MAJOR, True, "2.0.0-alpha.1"), + *( + (commits, False, "1.1.0") + for commits in ( + None, + lazy_fixture(emoji_chore_commits.__name__), + ) + ), + (lazy_fixture(emoji_patch_commits.__name__), False, "1.1.0"), + (lazy_fixture(emoji_patch_commits.__name__), True, "1.1.0-alpha.4"), + (lazy_fixture(emoji_minor_commits.__name__), False, "1.1.0"), + (lazy_fixture(emoji_minor_commits.__name__), True, "1.1.0-alpha.4"), + (lazy_fixture(emoji_major_commits.__name__), False, "2.0.0"), + (lazy_fixture(emoji_major_commits.__name__), True, "2.0.0-alpha.1"), ], }.items() for (commit_messages, prerelease, expected_new_version) in values @@ -186,6 +350,7 @@ def test_algorithm_no_zero_dot_versions_angular( ), ) @pytest.mark.parametrize("major_on_zero", [True, False]) +@pytest.mark.parametrize("allow_zero_version", [True, False]) def test_algorithm_no_zero_dot_versions_emoji( repo, file_in_repo, @@ -195,18 +360,20 @@ def test_algorithm_no_zero_dot_versions_emoji( prerelease, expected_new_version, major_on_zero, + allow_zero_version, ): - for commit_message in commit_messages: + # Setup + for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message) + # Action new_version = next_version( - repo, translator, commit_parser, prerelease, major_on_zero + repo, translator, commit_parser, prerelease, major_on_zero, allow_zero_version ) - assert new_version == Version.parse( - expected_new_version, prerelease_token=translator.prerelease_token - ) + # Verify + assert expected_new_version == str(new_version) @pytest.mark.parametrize( @@ -227,46 +394,64 @@ def test_algorithm_no_zero_dot_versions_emoji( # The last full release version was 1.1.1, so it's had a minor # prerelease ( - "repo_with_git_flow_scipy_commits", - "default_scipy_parser", + repo_with_git_flow_scipy_commits.__name__, + default_scipy_parser.__name__, VersionTranslator(prerelease_token="alpha"), ): [ *( (commits, True, "1.2.0-alpha.2") - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(scipy_chore_commits.__name__), + ) ), # Models a merge of commits from the branch to the main branch, now # that prerelease=False - *((commits, False, "1.2.0") for commits in ([], ["uninteresting"])), - (lazy_fixture("scipy_commits_patch"), False, "1.2.0"), - (lazy_fixture("scipy_commits_patch"), True, "1.2.0-alpha.3"), - (lazy_fixture("scipy_commits_minor"), False, "1.2.0"), - (lazy_fixture("scipy_commits_minor"), True, "1.2.0-alpha.3"), - (lazy_fixture("scipy_commits_major"), False, "2.0.0"), - (lazy_fixture("scipy_commits_major"), True, "2.0.0-alpha.1"), + *( + (commits, False, "1.2.0") + for commits in ( + None, + lazy_fixture(scipy_chore_commits.__name__), + ) + ), + (lazy_fixture(scipy_patch_commits.__name__), False, "1.2.0"), + (lazy_fixture(scipy_patch_commits.__name__), True, "1.2.0-alpha.3"), + (lazy_fixture(scipy_minor_commits.__name__), False, "1.2.0"), + (lazy_fixture(scipy_minor_commits.__name__), True, "1.2.0-alpha.3"), + (lazy_fixture(scipy_major_commits.__name__), False, "2.0.0"), + (lazy_fixture(scipy_major_commits.__name__), True, "2.0.0-alpha.1"), ], # Latest version for repo_with_git_flow_and_release_channels is # currently 1.1.0-alpha.3 # The last full release version was 1.0.0, so it's had a minor # prerelease ( - "repo_with_git_flow_and_release_channels_scipy_commits", - "default_scipy_parser", + repo_with_git_flow_and_release_channels_scipy_commits.__name__, + default_scipy_parser.__name__, VersionTranslator(prerelease_token="alpha"), ): [ *( (commits, True, "1.1.0-alpha.3") - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(scipy_chore_commits.__name__), + ) ), # Models a merge of commits from the branch to the main branch, now # that prerelease=False - *((commits, False, "1.1.0") for commits in ([], ["uninteresting"])), - (lazy_fixture("scipy_commits_patch"), False, "1.1.0"), - (lazy_fixture("scipy_commits_patch"), True, "1.1.0-alpha.4"), - (lazy_fixture("scipy_commits_minor"), False, "1.1.0"), - (lazy_fixture("scipy_commits_minor"), True, "1.1.0-alpha.4"), - (lazy_fixture("scipy_commits_major"), False, "2.0.0"), - (lazy_fixture("scipy_commits_major"), True, "2.0.0-alpha.1"), + *( + (commits, False, "1.1.0") + for commits in ( + None, + lazy_fixture(scipy_chore_commits.__name__), + ) + ), + (lazy_fixture(scipy_patch_commits.__name__), False, "1.1.0"), + (lazy_fixture(scipy_patch_commits.__name__), True, "1.1.0-alpha.4"), + (lazy_fixture(scipy_minor_commits.__name__), False, "1.1.0"), + (lazy_fixture(scipy_minor_commits.__name__), True, "1.1.0-alpha.4"), + (lazy_fixture(scipy_major_commits.__name__), False, "2.0.0"), + (lazy_fixture(scipy_major_commits.__name__), True, "2.0.0-alpha.1"), ], }.items() for (commit_messages, prerelease, expected_new_version) in values @@ -274,6 +459,7 @@ def test_algorithm_no_zero_dot_versions_emoji( ), ) @pytest.mark.parametrize("major_on_zero", [True, False]) +@pytest.mark.parametrize("allow_zero_version", [True, False]) def test_algorithm_no_zero_dot_versions_scipy( repo, file_in_repo, @@ -283,18 +469,20 @@ def test_algorithm_no_zero_dot_versions_scipy( prerelease, expected_new_version, major_on_zero, + allow_zero_version, ): - for commit_message in commit_messages: + # Setup + for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message) + # Action new_version = next_version( - repo, translator, commit_parser, prerelease, major_on_zero + repo, translator, commit_parser, prerelease, major_on_zero, allow_zero_version ) - assert new_version == Version.parse( - expected_new_version, prerelease_token=translator.prerelease_token - ) + # Verify + assert expected_new_version == str(new_version) @pytest.mark.parametrize( @@ -315,46 +503,64 @@ def test_algorithm_no_zero_dot_versions_scipy( # The last full release version was 1.1.1, so it's had a minor # prerelease ( - "repo_with_git_flow_tag_commits", - "default_tag_parser", + repo_with_git_flow_tag_commits.__name__, + default_tag_parser.__name__, VersionTranslator(prerelease_token="alpha"), ): [ *( (commits, True, "1.2.0-alpha.2") - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(tag_chore_commits.__name__), + ) ), # Models a merge of commits from the branch to the main branch, now # that prerelease=False - *((commits, False, "1.2.0") for commits in ([], ["uninteresting"])), - (TAG_COMMITS_PATCH, False, "1.2.0"), - (TAG_COMMITS_PATCH, True, "1.2.0-alpha.3"), - (TAG_COMMITS_MINOR, False, "1.2.0"), - (TAG_COMMITS_MINOR, True, "1.2.0-alpha.3"), - (TAG_COMMITS_MAJOR, False, "2.0.0"), - (TAG_COMMITS_MAJOR, True, "2.0.0-alpha.1"), + *( + (commits, False, "1.2.0") + for commits in ( + None, + lazy_fixture(tag_chore_commits.__name__), + ) + ), + (lazy_fixture(tag_patch_commits.__name__), False, "1.2.0"), + (lazy_fixture(tag_patch_commits.__name__), True, "1.2.0-alpha.3"), + (lazy_fixture(tag_minor_commits.__name__), False, "1.2.0"), + (lazy_fixture(tag_minor_commits.__name__), True, "1.2.0-alpha.3"), + (lazy_fixture(tag_major_commits.__name__), False, "2.0.0"), + (lazy_fixture(tag_major_commits.__name__), True, "2.0.0-alpha.1"), ], # Latest version for repo_with_git_flow_and_release_channels is # currently 1.1.0-alpha.3 # The last full release version was 1.0.0, so it's had a minor # prerelease ( - "repo_with_git_flow_and_release_channels_tag_commits", - "default_tag_parser", + repo_with_git_flow_and_release_channels_tag_commits.__name__, + default_tag_parser.__name__, VersionTranslator(prerelease_token="alpha"), ): [ *( (commits, True, "1.1.0-alpha.3") - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(tag_chore_commits.__name__), + ) ), # Models a merge of commits from the branch to the main branch, now # that prerelease=False - *((commits, False, "1.1.0") for commits in ([], ["uninteresting"])), - (TAG_COMMITS_PATCH, False, "1.1.0"), - (TAG_COMMITS_PATCH, True, "1.1.0-alpha.4"), - (TAG_COMMITS_MINOR, False, "1.1.0"), - (TAG_COMMITS_MINOR, True, "1.1.0-alpha.4"), - (TAG_COMMITS_MAJOR, False, "2.0.0"), - (TAG_COMMITS_MAJOR, True, "2.0.0-alpha.1"), + *( + (commits, False, "1.1.0") + for commits in ( + None, + lazy_fixture(tag_chore_commits.__name__), + ) + ), + (lazy_fixture(tag_patch_commits.__name__), False, "1.1.0"), + (lazy_fixture(tag_patch_commits.__name__), True, "1.1.0-alpha.4"), + (lazy_fixture(tag_minor_commits.__name__), False, "1.1.0"), + (lazy_fixture(tag_minor_commits.__name__), True, "1.1.0-alpha.4"), + (lazy_fixture(tag_major_commits.__name__), False, "2.0.0"), + (lazy_fixture(tag_major_commits.__name__), True, "2.0.0-alpha.1"), ], }.items() for (commit_messages, prerelease, expected_new_version) in values @@ -362,6 +568,7 @@ def test_algorithm_no_zero_dot_versions_scipy( ), ) @pytest.mark.parametrize("major_on_zero", [True, False]) +@pytest.mark.parametrize("allow_zero_version", [True, False]) def test_algorithm_no_zero_dot_versions_tag( repo, file_in_repo, @@ -371,18 +578,20 @@ def test_algorithm_no_zero_dot_versions_tag( prerelease, expected_new_version, major_on_zero, + allow_zero_version, ): - for commit_message in commit_messages: + # Setup + for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message) + # Action new_version = next_version( - repo, translator, commit_parser, prerelease, major_on_zero + repo, translator, commit_parser, prerelease, major_on_zero, allow_zero_version ) - assert new_version == Version.parse( - expected_new_version, prerelease_token=translator.prerelease_token - ) + # Verify + assert expected_new_version == str(new_version) ##### @@ -391,8 +600,19 @@ def test_algorithm_no_zero_dot_versions_tag( @pytest.mark.parametrize( - "repo, commit_parser, translator, commit_messages," - "prerelease, major_on_zero, expected_new_version", + str.join( + ", ", + [ + "repo", + "commit_parser", + "translator", + "commit_messages", + "prerelease", + "major_on_zero", + "allow_zero_version", + "expected_new_version", + ], + ), xdist_sort_hack( [ ( @@ -402,140 +622,366 @@ def test_algorithm_no_zero_dot_versions_tag( commit_messages, prerelease, major_on_zero, + allow_zero_version, expected_new_version, ) for (repo_fixture_name, parser_fixture_name, translator), values in { # Latest version for repo_with_no_tags is currently 0.0.0 (default) # It's biggest change type is minor, so the next version should be 0.1.0 ( - "repo_with_no_tags_angular_commits", - "default_angular_parser", + repo_with_no_tags_angular_commits.__name__, + default_angular_parser.__name__, VersionTranslator(), ): [ *( - (commits, False, major_on_zero, "0.1.0") + # when prerelease is False, & major_on_zero is False & + # allow_zero_version is True, the version should be + # 0.1.0, with the given commits + (commits, False, False, True, "0.1.0") + for commits in ( + # Even when this test does not change anything, the base modification + # will be a minor change and thus the version will be bumped to 0.1.0 + None, + # Non version bumping commits are absorbed into the previously detected minor bump + lazy_fixture(angular_chore_commits.__name__), + # Patch commits are absorbed into the previously detected minor bump + lazy_fixture(angular_patch_commits.__name__), + # Minor level commits are absorbed into the previously detected minor bump + lazy_fixture(angular_minor_commits.__name__), + # Given the major_on_zero is False and the version is starting at 0.0.0, + # the major level commits are limited to only causing a minor level bump + lazy_fixture(angular_major_commits.__name__), + ) + ), + # when prerelease is False, & major_on_zero is False, & allow_zero_version is True, + # the version should only be minor bumped when provided major commits because + # of the major_on_zero value + ( + lazy_fixture(angular_major_commits.__name__), + False, + False, + True, + "0.1.0", + ), + # when prerelease is False, & major_on_zero is True & allow_zero_version is True, + # the version should be major bumped when provided major commits because + # of the major_on_zero value + ( + lazy_fixture(angular_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # when prerelease is False, & allow_zero_version is False, the version should be + # 1.0.0, across the board because 0 is not a valid major version. + # major_on_zero is ignored as it is not relevant but tested for completeness + (commits, False, major_on_zero, False, "1.0.0") for major_on_zero in (True, False) for commits in ( - [], - ["uninteresting"], - ANGULAR_COMMITS_PATCH, - ANGULAR_COMMITS_MINOR, + None, + lazy_fixture(angular_chore_commits.__name__), + lazy_fixture(angular_patch_commits.__name__), + lazy_fixture(angular_minor_commits.__name__), + lazy_fixture(angular_major_commits.__name__), ) ), - (ANGULAR_COMMITS_MAJOR, False, False, "0.1.0"), - (ANGULAR_COMMITS_MAJOR, False, True, "1.0.0"), ], # Latest version for repo_with_single_branch is currently 0.1.1 # Note repo_with_single_branch isn't modelled with prereleases ( - "repo_with_single_branch_angular_commits", - "default_angular_parser", + repo_with_single_branch_angular_commits.__name__, + default_angular_parser.__name__, VersionTranslator(), ): [ *( - (commits, False, major_on_zero, "0.1.1") + # when prerelease must be False, and allow_zero_version is True, + # the version is not bumped because of non valuable changes regardless + # of the major_on_zero value + (commits, False, major_on_zero, True, "0.1.1") for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(angular_chore_commits.__name__), + ) ), *( - (ANGULAR_COMMITS_PATCH, False, major_on_zero, "0.1.2") + # when prerelease must be False, and allow_zero_version is True, + # the version is patch bumped because of the patch level commits + # regardless of the major_on_zero value + ( + lazy_fixture(angular_patch_commits.__name__), + False, + major_on_zero, + True, + "0.1.2", + ) for major_on_zero in (True, False) ), *( - (ANGULAR_COMMITS_MINOR, False, major_on_zero, "0.2.0") + # when prerelease must be False, and allow_zero_version is True, + # the version is minor bumped because of the major_on_zero value=False + (commits, False, False, True, "0.2.0") + for commits in ( + lazy_fixture(angular_minor_commits.__name__), + lazy_fixture(angular_major_commits.__name__), + ) + ), + # when prerelease must be False, and allow_zero_version is True, + # but the major_on_zero is True, then when a major level commit is given, + # the version should be bumped to the next major version + ( + lazy_fixture(angular_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # when prerelease must be False, & allow_zero_version is False, the version should be + # 1.0.0, with any change regardless of major_on_zero + (commits, False, major_on_zero, False, "1.0.0") for major_on_zero in (True, False) + for commits in ( + None, + lazy_fixture(angular_chore_commits.__name__), + lazy_fixture(angular_patch_commits.__name__), + lazy_fixture(angular_minor_commits.__name__), + lazy_fixture(angular_major_commits.__name__), + ) ), - (ANGULAR_COMMITS_MAJOR, False, False, "0.2.0"), - (ANGULAR_COMMITS_MAJOR, False, True, "1.0.0"), ], # Latest version for repo_with_single_branch_and_prereleases is # currently 0.2.0 ( - "repo_with_single_branch_and_prereleases_angular_commits", - "default_angular_parser", + repo_with_single_branch_and_prereleases_angular_commits.__name__, + default_angular_parser.__name__, VersionTranslator(), ): [ *( - (commits, prerelease, major_on_zero, "0.2.0") + # when allow_zero_version is True, the version is not bumped + # regardless of prerelease and major_on_zero values when given + # non valuable changes + (commits, prerelease, major_on_zero, True, "0.2.0") for prerelease in (True, False) for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(angular_chore_commits.__name__), + ) ), - *( - (ANGULAR_COMMITS_PATCH, False, major_on_zero, "0.2.1") - for major_on_zero in (True, False) + # when allow_zero_version is True, + # prerelease is False, & major_on_zero is False, the version should be + # patch bumped as a prerelease version, when given patch level commits + ( + lazy_fixture(angular_patch_commits.__name__), + True, + False, + True, + "0.2.1-rc.1", + ), + # when allow_zero_version is True, + # prerelease is False, & major_on_zero is False, the version should be + # patch bumped, when given patch level commits + ( + lazy_fixture(angular_patch_commits.__name__), + False, + False, + True, + "0.2.1", + ), + *( + # when allow_zero_version is True, + # prerelease is True, & major_on_zero is False, the version should be + # minor bumped as a prerelease version, when given commits of a minor or major level + (commits, True, False, True, "0.3.0-rc.1") + for commits in ( + lazy_fixture(angular_minor_commits.__name__), + lazy_fixture(angular_major_commits.__name__), + ) ), *( - (ANGULAR_COMMITS_PATCH, True, major_on_zero, "0.2.1-rc.1") - for major_on_zero in (True, False) + # when allow_zero_version is True, + # prerelease is True, & major_on_zero is False, the version should be + # minor bumped, when given commits of a minor or major level because + # major_on_zero = False + (commits, False, False, True, "0.3.0") + for commits in ( + lazy_fixture(angular_minor_commits.__name__), + lazy_fixture(angular_major_commits.__name__), + ) ), - *( - (ANGULAR_COMMITS_MINOR, False, major_on_zero, "0.3.0") + # when prerelease is True, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0 as a prerelease version, when + # given major level commits + ( + lazy_fixture(angular_major_commits.__name__), + True, + True, + True, + "1.0.0-rc.1", + ), + # when prerelease is False, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0, when given major level commits + ( + lazy_fixture(angular_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # when prerelease is True, & allow_zero_version is False, the version should be + # bumped to 1.0.0 as a prerelease version, when given any/none commits + # because 0.x is no longer a valid version regardless of the major_on_zero value + (commits, True, major_on_zero, False, "1.0.0-rc.1") for major_on_zero in (True, False) + for commits in ( + None, + lazy_fixture(angular_chore_commits.__name__), + lazy_fixture(angular_patch_commits.__name__), + lazy_fixture(angular_minor_commits.__name__), + lazy_fixture(angular_major_commits.__name__), + ) ), *( - (ANGULAR_COMMITS_MINOR, True, major_on_zero, "0.3.0-rc.1") + # when prerelease is True, & allow_zero_version is False, the version should be + # bumped to 1.0.0, when given any/none commits + # because 0.x is no longer a valid version regardless of the major_on_zero value + (commits, False, major_on_zero, False, "1.0.0") for major_on_zero in (True, False) + for commits in ( + lazy_fixture(angular_patch_commits.__name__), + lazy_fixture(angular_minor_commits.__name__), + lazy_fixture(angular_major_commits.__name__), + ) ), - (ANGULAR_COMMITS_MAJOR, False, True, "1.0.0"), - (ANGULAR_COMMITS_MAJOR, True, True, "1.0.0-rc.1"), - (ANGULAR_COMMITS_MAJOR, False, False, "0.3.0"), - (ANGULAR_COMMITS_MAJOR, True, False, "0.3.0-rc.1"), ], # Latest version for repo_with_main_and_feature_branches is currently - # 0.3.0-rc.1. + # 0.3.0-beta.1. # The last full release version was 0.2.0, so it's had a minor # prerelease ( - "repo_with_main_and_feature_branches_angular_commits", - "default_angular_parser", + repo_w_github_flow_w_feature_release_channel_angular_commits.__name__, + default_angular_parser.__name__, VersionTranslator(prerelease_token="beta"), ): [ *( - (commits, True, major_on_zero, "0.3.0-beta.1") - for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) - ), - # Models a merge of commits from the branch to the main branch, now - # that prerelease=False - *( - (commits, False, major_on_zero, "0.3.0") + # when prerelease is True, & major_on_zero is True & False, + # the version is not bumped because nothing of importance happened + (commits, True, major_on_zero, True, "0.3.0-beta.1") for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(angular_chore_commits.__name__), + ) ), *( - (ANGULAR_COMMITS_PATCH, False, major_on_zero, "0.3.0") - for major_on_zero in (True, False) + (commits, True, False, True, "0.3.0-beta.2") + for commits in ( + # when prerelease is True, & major_on_zero is False, the version should be + # increment the next prerelease version, when given patch level commits + # because the last full release was 0.2.0 and the prior prerelease consumes the + # patch bump + lazy_fixture(angular_patch_commits.__name__), + # when prerelease is True, & major_on_zero is False, the version should be + # minor bumped, when given patch level commits because last full version was 0.2.0 + lazy_fixture(angular_minor_commits.__name__), + # when prerelease is True, & major_on_zero is False, the version should be + # increment the next prerelease version, when given new breaking changes + # because major_on_zero is false, the last full release was 0.2.0 + # and the prior prerelease consumes the breaking changes + lazy_fixture(angular_major_commits.__name__), + ) ), *( - (ANGULAR_COMMITS_PATCH, True, major_on_zero, "0.3.0-beta.2") - for major_on_zero in (True, False) + (commits, False, False, True, "0.3.0") + for commits in ( + # Even though we are not making any changes, and prerelease is + # off, we look at the last full version which is 0.2.0 and + # consider the previous minor commit to cause the bump to 0.3.0 + None, + # same as None, but with a chore commit + lazy_fixture(angular_chore_commits.__name__), + # when prerelease is False, & major_on_zero is False, the version should be + # patch bumped, when given patch level commits because last full version was 0.2.0 + # and it was previously identified (from the prerelease) that a minor commit + # exists between 0.2.0 and now + lazy_fixture(angular_patch_commits.__name__), + # when prerelease is False, & major_on_zero is False, the version should be + # minor bumped, when given patch level commits because last full version was 0.2.0 + lazy_fixture(angular_minor_commits.__name__), + # when prerelease is False, & major_on_zero is False, the version should be + # minor bumped, when given new breaking changes because + # major_on_zero is false and last full version was 0.2.0 + lazy_fixture(angular_major_commits.__name__), + ) ), - *( - (ANGULAR_COMMITS_MINOR, False, major_on_zero, "0.3.0") + # when prerelease is True, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0 as a prerelease version, when + # given major level commits. The previous prerelease is ignored because of the major + # bump + ( + lazy_fixture(angular_major_commits.__name__), + True, + True, + True, + "1.0.0-beta.1", + ), + # when prerelease is False, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0, when given major level commits + ( + lazy_fixture(angular_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # Since allow_zero_version is False, the version should be 1.0.0 + # as a prerelease value due to prerelease=True, across the board + # regardless of the major_on_zero value + (commits, True, major_on_zero, False, "1.0.0-beta.1") for major_on_zero in (True, False) + for commits in ( + # None & chore commits are absorbed into the previously detected minor bump + # and because 0 versions are not allowed + None, + lazy_fixture(angular_chore_commits.__name__), + lazy_fixture(angular_patch_commits.__name__), + lazy_fixture(angular_minor_commits.__name__), + lazy_fixture(angular_major_commits.__name__), + ) ), *( - (ANGULAR_COMMITS_MINOR, True, major_on_zero, "0.3.0-beta.2") - for major_on_zero in (True, False) + # Since allow_zero_version is False, the version should be 1.0.0 + # across the board regardless of the major_on_zero value + (commits, False, True, False, "1.0.0") + for commits in ( + # None & chore commits are absorbed into the previously detected minor bump + # and because 0 versions are not allowed + None, + # Same as above, even though our change does not trigger a bump normally + lazy_fixture(angular_chore_commits.__name__), + # Even though we apply more patch, minor, major commits, the previous + # minor commit (in the prerelase tag) triggers a higher bump & + # with allow_zero_version=False, and ignore prereleases, we bump to 1.0.0 + lazy_fixture(angular_patch_commits.__name__), + lazy_fixture(angular_minor_commits.__name__), + lazy_fixture(angular_major_commits.__name__), + ) ), - (ANGULAR_COMMITS_MAJOR, False, True, "1.0.0"), - (ANGULAR_COMMITS_MAJOR, True, True, "1.0.0-beta.1"), - (ANGULAR_COMMITS_MAJOR, False, False, "0.3.0"), - # Note - since breaking changes are absorbed into the minor digit - # with major_on_zero = False, and that's already been incremented - # since the last full release, the breaking change here will only - # trigger a prerelease revision - (ANGULAR_COMMITS_MAJOR, True, False, "0.3.0-beta.2"), ], }.items() for ( commit_messages, prerelease, major_on_zero, + allow_zero_version, expected_new_version, ) in values - ] + ], ), ) def test_algorithm_with_zero_dot_versions_angular( @@ -547,23 +993,36 @@ def test_algorithm_with_zero_dot_versions_angular( prerelease, expected_new_version, major_on_zero, + allow_zero_version, ): - for commit_message in commit_messages: + # Setup + for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message) + # Action new_version = next_version( - repo, translator, commit_parser, prerelease, major_on_zero + repo, translator, commit_parser, prerelease, major_on_zero, allow_zero_version ) - assert new_version == Version.parse( - expected_new_version, prerelease_token=translator.prerelease_token - ) + # Verify + assert expected_new_version == str(new_version) @pytest.mark.parametrize( - "repo, commit_parser, translator, commit_messages," - "prerelease, major_on_zero, expected_new_version", + str.join( + ", ", + [ + "repo", + "commit_parser", + "translator", + "commit_messages", + "prerelease", + "major_on_zero", + "allow_zero_version", + "expected_new_version", + ], + ), xdist_sort_hack( [ ( @@ -573,140 +1032,366 @@ def test_algorithm_with_zero_dot_versions_angular( commit_messages, prerelease, major_on_zero, + allow_zero_version, expected_new_version, ) for (repo_fixture_name, parser_fixture_name, translator), values in { # Latest version for repo_with_no_tags is currently 0.0.0 (default) # It's biggest change type is minor, so the next version should be 0.1.0 ( - "repo_with_no_tags_emoji_commits", - "default_emoji_parser", + repo_with_no_tags_emoji_commits.__name__, + default_emoji_parser.__name__, VersionTranslator(), ): [ *( - (commits, False, major_on_zero, "0.1.0") + # when prerelease is False, & major_on_zero is False & + # allow_zero_version is True, the version should be + # 0.1.0, with the given commits + (commits, False, False, True, "0.1.0") + for commits in ( + # Even when this test does not change anything, the base modification + # will be a minor change and thus the version will be bumped to 0.1.0 + None, + # Non version bumping commits are absorbed into the previously detected minor bump + lazy_fixture(emoji_chore_commits.__name__), + # Patch commits are absorbed into the previously detected minor bump + lazy_fixture(emoji_patch_commits.__name__), + # Minor level commits are absorbed into the previously detected minor bump + lazy_fixture(emoji_minor_commits.__name__), + # Given the major_on_zero is False and the version is starting at 0.0.0, + # the major level commits are limited to only causing a minor level bump + lazy_fixture(emoji_major_commits.__name__), + ) + ), + # when prerelease is False, & major_on_zero is False, & allow_zero_version is True, + # the version should only be minor bumped when provided major commits because + # of the major_on_zero value + ( + lazy_fixture(emoji_major_commits.__name__), + False, + False, + True, + "0.1.0", + ), + # when prerelease is False, & major_on_zero is True & allow_zero_version is True, + # the version should be major bumped when provided major commits because + # of the major_on_zero value + ( + lazy_fixture(emoji_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # when prerelease is False, & allow_zero_version is False, the version should be + # 1.0.0, across the board because 0 is not a valid major version. + # major_on_zero is ignored as it is not relevant but tested for completeness + (commits, False, major_on_zero, False, "1.0.0") for major_on_zero in (True, False) for commits in ( - [], - ["uninteresting"], - EMOJI_COMMITS_PATCH, - EMOJI_COMMITS_MINOR, + None, + lazy_fixture(emoji_chore_commits.__name__), + lazy_fixture(emoji_patch_commits.__name__), + lazy_fixture(emoji_minor_commits.__name__), + lazy_fixture(emoji_major_commits.__name__), ) ), - (EMOJI_COMMITS_MAJOR, False, False, "0.1.0"), - (EMOJI_COMMITS_MAJOR, False, True, "1.0.0"), ], # Latest version for repo_with_single_branch is currently 0.1.1 # Note repo_with_single_branch isn't modelled with prereleases ( - "repo_with_single_branch_emoji_commits", - "default_emoji_parser", + repo_with_single_branch_emoji_commits.__name__, + default_emoji_parser.__name__, VersionTranslator(), ): [ *( - (commits, False, major_on_zero, "0.1.1") + # when prerelease must be False, and allow_zero_version is True, + # the version is not bumped because of non valuable changes regardless + # of the major_on_zero value + (commits, False, major_on_zero, True, "0.1.1") for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(emoji_chore_commits.__name__), + ) ), *( - (EMOJI_COMMITS_PATCH, False, major_on_zero, "0.1.2") + # when prerelease must be False, and allow_zero_version is True, + # the version is patch bumped because of the patch level commits + # regardless of the major_on_zero value + ( + lazy_fixture(emoji_patch_commits.__name__), + False, + major_on_zero, + True, + "0.1.2", + ) for major_on_zero in (True, False) ), *( - (EMOJI_COMMITS_MINOR, False, major_on_zero, "0.2.0") + # when prerelease must be False, and allow_zero_version is True, + # the version is minor bumped because of the major_on_zero value=False + (commits, False, False, True, "0.2.0") + for commits in ( + lazy_fixture(emoji_minor_commits.__name__), + lazy_fixture(emoji_major_commits.__name__), + ) + ), + # when prerelease must be False, and allow_zero_version is True, + # but the major_on_zero is True, then when a major level commit is given, + # the version should be bumped to the next major version + ( + lazy_fixture(emoji_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # when prerelease must be False, & allow_zero_version is False, the version should be + # 1.0.0, with any change regardless of major_on_zero + (commits, False, major_on_zero, False, "1.0.0") for major_on_zero in (True, False) + for commits in ( + None, + lazy_fixture(emoji_chore_commits.__name__), + lazy_fixture(emoji_patch_commits.__name__), + lazy_fixture(emoji_minor_commits.__name__), + lazy_fixture(emoji_major_commits.__name__), + ) ), - (EMOJI_COMMITS_MAJOR, False, False, "0.2.0"), - (EMOJI_COMMITS_MAJOR, False, True, "1.0.0"), ], # Latest version for repo_with_single_branch_and_prereleases is # currently 0.2.0 ( - "repo_with_single_branch_and_prereleases_emoji_commits", - "default_emoji_parser", + repo_with_single_branch_and_prereleases_emoji_commits.__name__, + default_emoji_parser.__name__, VersionTranslator(), ): [ *( - (commits, prerelease, major_on_zero, "0.2.0") + # when allow_zero_version is True, the version is not bumped + # regardless of prerelease and major_on_zero values when given + # non valuable changes + (commits, prerelease, major_on_zero, True, "0.2.0") for prerelease in (True, False) for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(emoji_chore_commits.__name__), + ) ), - *( - (EMOJI_COMMITS_PATCH, False, major_on_zero, "0.2.1") - for major_on_zero in (True, False) + # when allow_zero_version is True, + # prerelease is False, & major_on_zero is False, the version should be + # patch bumped as a prerelease version, when given patch level commits + ( + lazy_fixture(emoji_patch_commits.__name__), + True, + False, + True, + "0.2.1-rc.1", + ), + # when allow_zero_version is True, + # prerelease is False, & major_on_zero is False, the version should be + # patch bumped, when given patch level commits + ( + lazy_fixture(emoji_patch_commits.__name__), + False, + False, + True, + "0.2.1", + ), + *( + # when allow_zero_version is True, + # prerelease is True, & major_on_zero is False, the version should be + # minor bumped as a prerelease version, when given commits of a minor or major level + (commits, True, False, True, "0.3.0-rc.1") + for commits in ( + lazy_fixture(emoji_minor_commits.__name__), + lazy_fixture(emoji_major_commits.__name__), + ) ), *( - (EMOJI_COMMITS_PATCH, True, major_on_zero, "0.2.1-rc.1") - for major_on_zero in (True, False) + # when allow_zero_version is True, + # prerelease is True, & major_on_zero is False, the version should be + # minor bumped, when given commits of a minor or major level because + # major_on_zero = False + (commits, False, False, True, "0.3.0") + for commits in ( + lazy_fixture(emoji_minor_commits.__name__), + lazy_fixture(emoji_major_commits.__name__), + ) ), - *( - (EMOJI_COMMITS_MINOR, False, major_on_zero, "0.3.0") + # when prerelease is True, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0 as a prerelease version, when + # given major level commits + ( + lazy_fixture(emoji_major_commits.__name__), + True, + True, + True, + "1.0.0-rc.1", + ), + # when prerelease is False, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0, when given major level commits + ( + lazy_fixture(emoji_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # when prerelease is True, & allow_zero_version is False, the version should be + # bumped to 1.0.0 as a prerelease version, when given any/none commits + # because 0.x is no longer a valid version regardless of the major_on_zero value + (commits, True, major_on_zero, False, "1.0.0-rc.1") for major_on_zero in (True, False) + for commits in ( + None, + lazy_fixture(emoji_chore_commits.__name__), + lazy_fixture(emoji_patch_commits.__name__), + lazy_fixture(emoji_minor_commits.__name__), + lazy_fixture(emoji_major_commits.__name__), + ) ), *( - (EMOJI_COMMITS_MINOR, True, major_on_zero, "0.3.0-rc.1") + # when prerelease is True, & allow_zero_version is False, the version should be + # bumped to 1.0.0, when given any/none commits + # because 0.x is no longer a valid version regardless of the major_on_zero value + (commits, False, major_on_zero, False, "1.0.0") for major_on_zero in (True, False) + for commits in ( + lazy_fixture(emoji_patch_commits.__name__), + lazy_fixture(emoji_minor_commits.__name__), + lazy_fixture(emoji_major_commits.__name__), + ) ), - (EMOJI_COMMITS_MAJOR, False, True, "1.0.0"), - (EMOJI_COMMITS_MAJOR, True, True, "1.0.0-rc.1"), - (EMOJI_COMMITS_MAJOR, False, False, "0.3.0"), - (EMOJI_COMMITS_MAJOR, True, False, "0.3.0-rc.1"), ], # Latest version for repo_with_main_and_feature_branches is currently # 0.3.0-beta.1. # The last full release version was 0.2.0, so it's had a minor # prerelease ( - "repo_with_main_and_feature_branches_emoji_commits", - "default_emoji_parser", + repo_w_github_flow_w_feature_release_channel_emoji_commits.__name__, + default_emoji_parser.__name__, VersionTranslator(prerelease_token="beta"), ): [ *( - (commits, True, major_on_zero, "0.3.0-beta.1") - for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) - ), - # Models a merge of commits from the branch to the main branch, now - # that prerelease=False - *( - (commits, False, major_on_zero, "0.3.0") + # when prerelease is True, & major_on_zero is True & False, + # the version is not bumped because nothing of importance happened + (commits, True, major_on_zero, True, "0.3.0-beta.1") for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(emoji_chore_commits.__name__), + ) ), *( - (EMOJI_COMMITS_PATCH, False, major_on_zero, "0.3.0") - for major_on_zero in (True, False) + (commits, True, False, True, "0.3.0-beta.2") + for commits in ( + # when prerelease is True, & major_on_zero is False, the version should be + # increment the next prerelease version, when given patch level commits + # because the last full release was 0.2.0 and the prior prerelease consumes the + # patch bump + lazy_fixture(emoji_patch_commits.__name__), + # when prerelease is True, & major_on_zero is False, the version should be + # minor bumped, when given patch level commits because last full version was 0.2.0 + lazy_fixture(emoji_minor_commits.__name__), + # when prerelease is True, & major_on_zero is False, the version should be + # increment the next prerelease version, when given new breaking changes + # because major_on_zero is false, the last full release was 0.2.0 + # and the prior prerelease consumes the breaking changes + lazy_fixture(emoji_major_commits.__name__), + ) ), *( - (EMOJI_COMMITS_PATCH, True, major_on_zero, "0.3.0-beta.2") - for major_on_zero in (True, False) + (commits, False, False, True, "0.3.0") + for commits in ( + # Even though we are not making any changes, and prerelease is + # off, we look at the last full version which is 0.2.0 and + # consider the previous minor commit to cause the bump to 0.3.0 + None, + # same as None, but with a chore commit + lazy_fixture(emoji_chore_commits.__name__), + # when prerelease is False, & major_on_zero is False, the version should be + # patch bumped, when given patch level commits because last full version was 0.2.0 + # and it was previously identified (from the prerelease) that a minor commit + # exists between 0.2.0 and now + lazy_fixture(emoji_patch_commits.__name__), + # when prerelease is False, & major_on_zero is False, the version should be + # minor bumped, when given patch level commits because last full version was 0.2.0 + lazy_fixture(emoji_minor_commits.__name__), + # when prerelease is False, & major_on_zero is False, the version should be + # minor bumped, when given new breaking changes because + # major_on_zero is false and last full version was 0.2.0 + lazy_fixture(emoji_major_commits.__name__), + ) ), - *( - (EMOJI_COMMITS_MINOR, False, major_on_zero, "0.3.0") + # when prerelease is True, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0 as a prerelease version, when + # given major level commits. The previous prerelease is ignored because of the major + # bump + ( + lazy_fixture(emoji_major_commits.__name__), + True, + True, + True, + "1.0.0-beta.1", + ), + # when prerelease is False, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0, when given major level commits + ( + lazy_fixture(emoji_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # Since allow_zero_version is False, the version should be 1.0.0 + # as a prerelease value due to prerelease=True, across the board + # regardless of the major_on_zero value + (commits, True, major_on_zero, False, "1.0.0-beta.1") for major_on_zero in (True, False) + for commits in ( + # None & chore commits are absorbed into the previously detected minor bump + # and because 0 versions are not allowed + None, + lazy_fixture(emoji_chore_commits.__name__), + lazy_fixture(emoji_patch_commits.__name__), + lazy_fixture(emoji_minor_commits.__name__), + lazy_fixture(emoji_major_commits.__name__), + ) ), *( - (EMOJI_COMMITS_MINOR, True, major_on_zero, "0.3.0-beta.2") - for major_on_zero in (True, False) + # Since allow_zero_version is False, the version should be 1.0.0 + # across the board regardless of the major_on_zero value + (commits, False, True, False, "1.0.0") + for commits in ( + # None & chore commits are absorbed into the previously detected minor bump + # and because 0 versions are not allowed + None, + # Same as above, even though our change does not trigger a bump normally + lazy_fixture(emoji_chore_commits.__name__), + # Even though we apply more patch, minor, major commits, the previous + # minor commit (in the prerelase tag) triggers a higher bump & + # with allow_zero_version=False, and ignore prereleases, we bump to 1.0.0 + lazy_fixture(emoji_patch_commits.__name__), + lazy_fixture(emoji_minor_commits.__name__), + lazy_fixture(emoji_major_commits.__name__), + ) ), - (EMOJI_COMMITS_MAJOR, False, True, "1.0.0"), - (EMOJI_COMMITS_MAJOR, True, True, "1.0.0-beta.1"), - (EMOJI_COMMITS_MAJOR, False, False, "0.3.0"), - # Note - since breaking changes are absorbed into the minor digit - # with major_on_zero = False, and that's already been incremented - # since the last full release, the breaking change here will only - # trigger a prerelease revision - (EMOJI_COMMITS_MAJOR, True, False, "0.3.0-beta.2"), ], }.items() for ( commit_messages, prerelease, major_on_zero, + allow_zero_version, expected_new_version, ) in values - ] + ], ), ) def test_algorithm_with_zero_dot_versions_emoji( @@ -718,23 +1403,36 @@ def test_algorithm_with_zero_dot_versions_emoji( prerelease, expected_new_version, major_on_zero, + allow_zero_version, ): - for commit_message in commit_messages: + # Setup + for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message) + # Action new_version = next_version( - repo, translator, commit_parser, prerelease, major_on_zero + repo, translator, commit_parser, prerelease, major_on_zero, allow_zero_version ) - assert new_version == Version.parse( - expected_new_version, prerelease_token=translator.prerelease_token - ) + # Verify + assert expected_new_version == str(new_version) @pytest.mark.parametrize( - "repo, commit_parser, translator, commit_messages," - "prerelease, major_on_zero, expected_new_version", + str.join( + ", ", + [ + "repo", + "commit_parser", + "translator", + "commit_messages", + "prerelease", + "major_on_zero", + "allow_zero_version", + "expected_new_version", + ], + ), xdist_sort_hack( [ ( @@ -744,187 +1442,363 @@ def test_algorithm_with_zero_dot_versions_emoji( commit_messages, prerelease, major_on_zero, + allow_zero_version, expected_new_version, ) for (repo_fixture_name, parser_fixture_name, translator), values in { # Latest version for repo_with_no_tags is currently 0.0.0 (default) # It's biggest change type is minor, so the next version should be 0.1.0 ( - "repo_with_no_tags_scipy_commits", - "default_scipy_parser", + repo_with_no_tags_scipy_commits.__name__, + default_scipy_parser.__name__, VersionTranslator(), ): [ *( - (commits, False, major_on_zero, "0.1.0") + # when prerelease is False, & major_on_zero is False & + # allow_zero_version is True, the version should be + # 0.1.0, with the given commits + (commits, False, False, True, "0.1.0") + for commits in ( + # Even when this test does not change anything, the base modification + # will be a minor change and thus the version will be bumped to 0.1.0 + None, + # Non version bumping commits are absorbed into the previously detected minor bump + lazy_fixture(scipy_chore_commits.__name__), + # Patch commits are absorbed into the previously detected minor bump + lazy_fixture(scipy_patch_commits.__name__), + # Minor level commits are absorbed into the previously detected minor bump + lazy_fixture(scipy_minor_commits.__name__), + # Given the major_on_zero is False and the version is starting at 0.0.0, + # the major level commits are limited to only causing a minor level bump + lazy_fixture(scipy_major_commits.__name__), + ) + ), + # when prerelease is False, & major_on_zero is False, & allow_zero_version is True, + # the version should only be minor bumped when provided major commits because + # of the major_on_zero value + ( + lazy_fixture(scipy_major_commits.__name__), + False, + False, + True, + "0.1.0", + ), + # when prerelease is False, & major_on_zero is True & allow_zero_version is True, + # the version should be major bumped when provided major commits because + # of the major_on_zero value + ( + lazy_fixture(scipy_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # when prerelease is False, & allow_zero_version is False, the version should be + # 1.0.0, across the board because 0 is not a valid major version. + # major_on_zero is ignored as it is not relevant but tested for completeness + (commits, False, major_on_zero, False, "1.0.0") for major_on_zero in (True, False) for commits in ( - [], - ["uninteresting"], - lazy_fixture("scipy_commits_patch"), - lazy_fixture("scipy_commits_minor"), + None, + lazy_fixture(scipy_chore_commits.__name__), + lazy_fixture(scipy_patch_commits.__name__), + lazy_fixture(scipy_minor_commits.__name__), + lazy_fixture(scipy_major_commits.__name__), ) ), - (lazy_fixture("scipy_commits_major"), False, False, "0.1.0"), - (lazy_fixture("scipy_commits_major"), False, True, "1.0.0"), ], # Latest version for repo_with_single_branch is currently 0.1.1 # Note repo_with_single_branch isn't modelled with prereleases ( - "repo_with_single_branch_scipy_commits", - "default_scipy_parser", + repo_with_single_branch_scipy_commits.__name__, + default_scipy_parser.__name__, VersionTranslator(), ): [ *( - (commits, False, major_on_zero, "0.1.1") + # when prerelease must be False, and allow_zero_version is True, + # the version is not bumped because of non valuable changes regardless + # of the major_on_zero value + (commits, False, major_on_zero, True, "0.1.1") for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) + for commits in ( + None, + lazy_fixture(scipy_chore_commits.__name__), + ) ), *( + # when prerelease must be False, and allow_zero_version is True, + # the version is patch bumped because of the patch level commits + # regardless of the major_on_zero value ( - lazy_fixture("scipy_commits_patch"), + lazy_fixture(scipy_patch_commits.__name__), False, major_on_zero, + True, "0.1.2", ) for major_on_zero in (True, False) ), *( - ( - lazy_fixture("scipy_commits_minor"), - False, - major_on_zero, - "0.2.0", + # when prerelease must be False, and allow_zero_version is True, + # the version is minor bumped because of the major_on_zero value=False + (commits, False, False, True, "0.2.0") + for commits in ( + lazy_fixture(scipy_minor_commits.__name__), + lazy_fixture(scipy_major_commits.__name__), ) + ), + # when prerelease must be False, and allow_zero_version is True, + # but the major_on_zero is True, then when a major level commit is given, + # the version should be bumped to the next major version + ( + lazy_fixture(scipy_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # when prerelease must be False, & allow_zero_version is False, the version should be + # 1.0.0, with any change regardless of major_on_zero + (commits, False, major_on_zero, False, "1.0.0") for major_on_zero in (True, False) + for commits in ( + None, + lazy_fixture(scipy_chore_commits.__name__), + lazy_fixture(scipy_patch_commits.__name__), + lazy_fixture(scipy_minor_commits.__name__), + lazy_fixture(scipy_major_commits.__name__), + ) ), - (lazy_fixture("scipy_commits_major"), False, False, "0.2.0"), - (lazy_fixture("scipy_commits_major"), False, True, "1.0.0"), ], # Latest version for repo_with_single_branch_and_prereleases is # currently 0.2.0 ( - "repo_with_single_branch_and_prereleases_scipy_commits", - "default_scipy_parser", + repo_with_single_branch_and_prereleases_scipy_commits.__name__, + default_scipy_parser.__name__, VersionTranslator(), ): [ *( - (commits, prerelease, major_on_zero, "0.2.0") + # when allow_zero_version is True, the version is not bumped + # regardless of prerelease and major_on_zero values when given + # non valuable changes + (commits, prerelease, major_on_zero, True, "0.2.0") for prerelease in (True, False) for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) - ), - *( - ( - lazy_fixture("scipy_commits_patch"), - False, - major_on_zero, - "0.2.1", + for commits in ( + None, + lazy_fixture(scipy_chore_commits.__name__), ) - for major_on_zero in (True, False) ), - *( - ( - lazy_fixture("scipy_commits_patch"), - True, - major_on_zero, - "0.2.1-rc.1", + # when allow_zero_version is True, + # prerelease is False, & major_on_zero is False, the version should be + # patch bumped as a prerelease version, when given patch level commits + ( + lazy_fixture(scipy_patch_commits.__name__), + True, + False, + True, + "0.2.1-rc.1", + ), + # when allow_zero_version is True, + # prerelease is False, & major_on_zero is False, the version should be + # patch bumped, when given patch level commits + ( + lazy_fixture(scipy_patch_commits.__name__), + False, + False, + True, + "0.2.1", + ), + *( + # when allow_zero_version is True, + # prerelease is True, & major_on_zero is False, the version should be + # minor bumped as a prerelease version, when given commits of a minor or major level + (commits, True, False, True, "0.3.0-rc.1") + for commits in ( + lazy_fixture(scipy_minor_commits.__name__), + lazy_fixture(scipy_major_commits.__name__), ) - for major_on_zero in (True, False) ), *( - ( - lazy_fixture("scipy_commits_minor"), - False, - major_on_zero, - "0.3.0", + # when allow_zero_version is True, + # prerelease is True, & major_on_zero is False, the version should be + # minor bumped, when given commits of a minor or major level because + # major_on_zero = False + (commits, False, False, True, "0.3.0") + for commits in ( + lazy_fixture(scipy_minor_commits.__name__), + lazy_fixture(scipy_major_commits.__name__), ) + ), + # when prerelease is True, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0 as a prerelease version, when + # given major level commits + ( + lazy_fixture(scipy_major_commits.__name__), + True, + True, + True, + "1.0.0-rc.1", + ), + # when prerelease is False, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0, when given major level commits + ( + lazy_fixture(scipy_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # when prerelease is True, & allow_zero_version is False, the version should be + # bumped to 1.0.0 as a prerelease version, when given any/none commits + # because 0.x is no longer a valid version regardless of the major_on_zero value + (commits, True, major_on_zero, False, "1.0.0-rc.1") for major_on_zero in (True, False) + for commits in ( + None, + lazy_fixture(scipy_chore_commits.__name__), + lazy_fixture(scipy_patch_commits.__name__), + lazy_fixture(scipy_minor_commits.__name__), + lazy_fixture(scipy_major_commits.__name__), + ) ), *( - ( - lazy_fixture("scipy_commits_minor"), - True, - major_on_zero, - "0.3.0-rc.1", - ) + # when prerelease is True, & allow_zero_version is False, the version should be + # bumped to 1.0.0, when given any/none commits + # because 0.x is no longer a valid version regardless of the major_on_zero value + (commits, False, major_on_zero, False, "1.0.0") for major_on_zero in (True, False) + for commits in ( + lazy_fixture(scipy_patch_commits.__name__), + lazy_fixture(scipy_minor_commits.__name__), + lazy_fixture(scipy_major_commits.__name__), + ) ), - (lazy_fixture("scipy_commits_major"), False, True, "1.0.0"), - (lazy_fixture("scipy_commits_major"), True, True, "1.0.0-rc.1"), - (lazy_fixture("scipy_commits_major"), False, False, "0.3.0"), - (lazy_fixture("scipy_commits_major"), True, False, "0.3.0-rc.1"), ], # Latest version for repo_with_main_and_feature_branches is currently - # 0.3.0-rc.1. + # 0.3.0-beta.1. # The last full release version was 0.2.0, so it's had a minor # prerelease ( - "repo_with_main_and_feature_branches_scipy_commits", - "default_scipy_parser", + repo_w_github_flow_w_feature_release_channel_scipy_commits.__name__, + default_scipy_parser.__name__, VersionTranslator(prerelease_token="beta"), ): [ *( - (commits, True, major_on_zero, "0.3.0-beta.1") + # when prerelease is True, & major_on_zero is True & False, + # the version is not bumped because nothing of importance happened + (commits, True, major_on_zero, True, "0.3.0-beta.1") for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) - ), - # Models a merge of commits from the branch to the main branch, now - # that prerelease=False - *( - (commits, False, major_on_zero, "0.3.0") - for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) - ), - *( - ( - lazy_fixture("scipy_commits_patch"), - False, - major_on_zero, - "0.3.0", + for commits in ( + None, + lazy_fixture(scipy_chore_commits.__name__), ) - for major_on_zero in (True, False) ), *( - ( - lazy_fixture("scipy_commits_patch"), - True, - major_on_zero, - "0.3.0-beta.2", + (commits, True, False, True, "0.3.0-beta.2") + for commits in ( + # when prerelease is True, & major_on_zero is False, the version should be + # increment the next prerelease version, when given patch level commits + # because the last full release was 0.2.0 and the prior prerelease consumes the + # patch bump + lazy_fixture(scipy_patch_commits.__name__), + # when prerelease is True, & major_on_zero is False, the version should be + # minor bumped, when given patch level commits because last full version was 0.2.0 + lazy_fixture(scipy_minor_commits.__name__), + # when prerelease is True, & major_on_zero is False, the version should be + # increment the next prerelease version, when given new breaking changes + # because major_on_zero is false, the last full release was 0.2.0 + # and the prior prerelease consumes the breaking changes + lazy_fixture(scipy_major_commits.__name__), ) - for major_on_zero in (True, False) ), *( - ( - lazy_fixture("scipy_commits_minor"), - False, - major_on_zero, - "0.3.0", + (commits, False, False, True, "0.3.0") + for commits in ( + # Even though we are not making any changes, and prerelease is + # off, we look at the last full version which is 0.2.0 and + # consider the previous minor commit to cause the bump to 0.3.0 + None, + # same as None, but with a chore commit + lazy_fixture(scipy_chore_commits.__name__), + # when prerelease is False, & major_on_zero is False, the version should be + # patch bumped, when given patch level commits because last full version was 0.2.0 + # and it was previously identified (from the prerelease) that a minor commit + # exists between 0.2.0 and now + lazy_fixture(scipy_patch_commits.__name__), + # when prerelease is False, & major_on_zero is False, the version should be + # minor bumped, when given patch level commits because last full version was 0.2.0 + lazy_fixture(scipy_minor_commits.__name__), + # when prerelease is False, & major_on_zero is False, the version should be + # minor bumped, when given new breaking changes because + # major_on_zero is false and last full version was 0.2.0 + lazy_fixture(scipy_major_commits.__name__), ) + ), + # when prerelease is True, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0 as a prerelease version, when + # given major level commits. The previous prerelease is ignored because of the major + # bump + ( + lazy_fixture(scipy_major_commits.__name__), + True, + True, + True, + "1.0.0-beta.1", + ), + # when prerelease is False, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0, when given major level commits + ( + lazy_fixture(scipy_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # Since allow_zero_version is False, the version should be 1.0.0 + # as a prerelease value due to prerelease=True, across the board + # regardless of the major_on_zero value + (commits, True, major_on_zero, False, "1.0.0-beta.1") for major_on_zero in (True, False) + for commits in ( + # None & chore commits are absorbed into the previously detected minor bump + # and because 0 versions are not allowed + None, + lazy_fixture(scipy_chore_commits.__name__), + lazy_fixture(scipy_patch_commits.__name__), + lazy_fixture(scipy_minor_commits.__name__), + lazy_fixture(scipy_major_commits.__name__), + ) ), *( - ( - lazy_fixture("scipy_commits_minor"), - True, - major_on_zero, - "0.3.0-beta.2", + # Since allow_zero_version is False, the version should be 1.0.0 + # across the board regardless of the major_on_zero value + (commits, False, True, False, "1.0.0") + for commits in ( + # None & chore commits are absorbed into the previously detected minor bump + # and because 0 versions are not allowed + None, + # Same as above, even though our change does not trigger a bump normally + lazy_fixture(scipy_chore_commits.__name__), + # Even though we apply more patch, minor, major commits, the previous + # minor commit (in the prerelase tag) triggers a higher bump & + # with allow_zero_version=False, and ignore prereleases, we bump to 1.0.0 + lazy_fixture(scipy_patch_commits.__name__), + lazy_fixture(scipy_minor_commits.__name__), + lazy_fixture(scipy_major_commits.__name__), ) - for major_on_zero in (True, False) ), - (lazy_fixture("scipy_commits_major"), False, True, "1.0.0"), - (lazy_fixture("scipy_commits_major"), True, True, "1.0.0-beta.1"), - (lazy_fixture("scipy_commits_major"), False, False, "0.3.0"), - # Note - since breaking changes are absorbed into the minor digit - # with major_on_zero = False, and that's already been incremented - # since the last full release, the breaking change here will only - # trigger a prerelease revision - (lazy_fixture("scipy_commits_major"), True, False, "0.3.0-beta.2"), ], }.items() for ( commit_messages, prerelease, major_on_zero, + allow_zero_version, expected_new_version, ) in values ], @@ -939,23 +1813,36 @@ def test_algorithm_with_zero_dot_versions_scipy( prerelease, expected_new_version, major_on_zero, + allow_zero_version, ): - for commit_message in commit_messages: + # Setup + for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message) + # Action new_version = next_version( - repo, translator, commit_parser, prerelease, major_on_zero + repo, translator, commit_parser, prerelease, major_on_zero, allow_zero_version ) - assert new_version == Version.parse( - expected_new_version, prerelease_token=translator.prerelease_token - ) + # Verify + assert expected_new_version == str(new_version) @pytest.mark.parametrize( - "repo, commit_parser, translator, commit_messages," - "prerelease, major_on_zero, expected_new_version", + str.join( + ", ", + [ + "repo", + "commit_parser", + "translator", + "commit_messages", + "prerelease", + "major_on_zero", + "allow_zero_version", + "expected_new_version", + ], + ), xdist_sort_hack( [ ( @@ -965,144 +1852,743 @@ def test_algorithm_with_zero_dot_versions_scipy( commit_messages, prerelease, major_on_zero, + allow_zero_version, expected_new_version, ) for (repo_fixture_name, parser_fixture_name, translator), values in { # Latest version for repo_with_no_tags is currently 0.0.0 (default) # It's biggest change type is minor, so the next version should be 0.1.0 ( - "repo_with_no_tags_tag_commits", - "default_tag_parser", + repo_with_no_tags_tag_commits.__name__, + default_tag_parser.__name__, VersionTranslator(), ): [ *( - (commits, False, major_on_zero, "0.1.0") + # when prerelease is False, & major_on_zero is False & + # allow_zero_version is True, the version should be + # 0.1.0, with the given commits + (commits, False, False, True, "0.1.0") + for commits in ( + # Even when this test does not change anything, the base modification + # will be a minor change and thus the version will be bumped to 0.1.0 + None, + # Non version bumping commits are absorbed into the previously detected minor bump + lazy_fixture(tag_chore_commits.__name__), + # Patch commits are absorbed into the previously detected minor bump + lazy_fixture(tag_patch_commits.__name__), + # Minor level commits are absorbed into the previously detected minor bump + lazy_fixture(tag_minor_commits.__name__), + # Given the major_on_zero is False and the version is starting at 0.0.0, + # the major level commits are limited to only causing a minor level bump + lazy_fixture(tag_major_commits.__name__), + ) + ), + # when prerelease is False, & major_on_zero is False, & allow_zero_version is True, + # the version should only be minor bumped when provided major commits because + # of the major_on_zero value + ( + lazy_fixture(tag_major_commits.__name__), + False, + False, + True, + "0.1.0", + ), + # when prerelease is False, & major_on_zero is True & allow_zero_version is True, + # the version should be major bumped when provided major commits because + # of the major_on_zero value + ( + lazy_fixture(tag_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # when prerelease is False, & allow_zero_version is False, the version should be + # 1.0.0, across the board because 0 is not a valid major version. + # major_on_zero is ignored as it is not relevant but tested for completeness + (commits, False, major_on_zero, False, "1.0.0") for major_on_zero in (True, False) for commits in ( - [], - ["uninteresting"], - TAG_COMMITS_PATCH, - TAG_COMMITS_MINOR, + None, + lazy_fixture(tag_chore_commits.__name__), + lazy_fixture(tag_patch_commits.__name__), + lazy_fixture(tag_minor_commits.__name__), + lazy_fixture(tag_major_commits.__name__), ) ), - (TAG_COMMITS_MAJOR, False, False, "0.1.0"), - (TAG_COMMITS_MAJOR, False, True, "1.0.0"), ], # Latest version for repo_with_single_branch is currently 0.1.1 # Note repo_with_single_branch isn't modelled with prereleases ( - "repo_with_single_branch_tag_commits", - "default_tag_parser", + repo_with_single_branch_tag_commits.__name__, + default_tag_parser.__name__, VersionTranslator(), ): [ *( - (commits, False, major_on_zero, "0.1.1") + # when prerelease must be False, and allow_zero_version is True, + # the version is not bumped because of non valuable changes regardless + # of the major_on_zero value + (commits, False, major_on_zero, True, "0.1.1") for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) + for commits in (None, lazy_fixture(tag_chore_commits.__name__)) ), *( - (TAG_COMMITS_PATCH, False, major_on_zero, "0.1.2") + # when prerelease must be False, and allow_zero_version is True, + # the version is patch bumped because of the patch level commits + # regardless of the major_on_zero value + ( + lazy_fixture(tag_patch_commits.__name__), + False, + major_on_zero, + True, + "0.1.2", + ) for major_on_zero in (True, False) ), *( - (TAG_COMMITS_MINOR, False, major_on_zero, "0.2.0") + # when prerelease must be False, and allow_zero_version is True, + # the version is minor bumped because of the major_on_zero value=False + (commits, False, False, True, "0.2.0") + for commits in ( + lazy_fixture(tag_minor_commits.__name__), + lazy_fixture(tag_major_commits.__name__), + ) + ), + # when prerelease must be False, and allow_zero_version is True, + # but the major_on_zero is True, then when a major level commit is given, + # the version should be bumped to the next major version + ( + lazy_fixture(tag_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # when prerelease must be False, & allow_zero_version is False, the version should be + # 1.0.0, with any change regardless of major_on_zero + (commits, False, major_on_zero, False, "1.0.0") for major_on_zero in (True, False) + for commits in ( + None, + lazy_fixture(tag_chore_commits.__name__), + lazy_fixture(tag_patch_commits.__name__), + lazy_fixture(tag_minor_commits.__name__), + lazy_fixture(tag_major_commits.__name__), + ) ), - (TAG_COMMITS_MAJOR, False, False, "0.2.0"), - (TAG_COMMITS_MAJOR, False, True, "1.0.0"), ], # Latest version for repo_with_single_branch_and_prereleases is # currently 0.2.0 ( - "repo_with_single_branch_and_prereleases_tag_commits", - "default_tag_parser", + repo_with_single_branch_and_prereleases_tag_commits.__name__, + default_tag_parser.__name__, VersionTranslator(), ): [ *( - (commits, prerelease, major_on_zero, "0.2.0") + # when allow_zero_version is True, the version is not bumped + # regardless of prerelease and major_on_zero values when given + # non valuable changes + (commits, prerelease, major_on_zero, True, "0.2.0") for prerelease in (True, False) for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) - ), - *( - (TAG_COMMITS_PATCH, False, major_on_zero, "0.2.1") - for major_on_zero in (True, False) + for commits in (None, lazy_fixture(tag_chore_commits.__name__)) + ), + # when allow_zero_version is True, + # prerelease is False, & major_on_zero is False, the version should be + # patch bumped as a prerelease version, when given patch level commits + ( + lazy_fixture(tag_patch_commits.__name__), + True, + False, + True, + "0.2.1-rc.1", + ), + # when allow_zero_version is True, + # prerelease is False, & major_on_zero is False, the version should be + # patch bumped, when given patch level commits + ( + lazy_fixture(tag_patch_commits.__name__), + False, + False, + True, + "0.2.1", + ), + *( + # when allow_zero_version is True, + # prerelease is True, & major_on_zero is False, the version should be + # minor bumped as a prerelease version, when given commits of a minor or major level + (commits, True, False, True, "0.3.0-rc.1") + for commits in ( + lazy_fixture(tag_minor_commits.__name__), + lazy_fixture(tag_major_commits.__name__), + ) ), *( - (TAG_COMMITS_PATCH, True, major_on_zero, "0.2.1-rc.1") - for major_on_zero in (True, False) + # when allow_zero_version is True, + # prerelease is True, & major_on_zero is False, the version should be + # minor bumped, when given commits of a minor or major level because + # major_on_zero = False + (commits, False, False, True, "0.3.0") + for commits in ( + lazy_fixture(tag_minor_commits.__name__), + lazy_fixture(tag_major_commits.__name__), + ) ), - *( - (TAG_COMMITS_MINOR, False, major_on_zero, "0.3.0") + # when prerelease is True, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0 as a prerelease version, when + # given major level commits + ( + lazy_fixture(tag_major_commits.__name__), + True, + True, + True, + "1.0.0-rc.1", + ), + # when prerelease is False, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0, when given major level commits + ( + lazy_fixture(tag_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # when prerelease is True, & allow_zero_version is False, the version should be + # bumped to 1.0.0 as a prerelease version, when given any/none commits + # because 0.x is no longer a valid version regardless of the major_on_zero value + (commits, True, major_on_zero, False, "1.0.0-rc.1") for major_on_zero in (True, False) + for commits in ( + None, + lazy_fixture(tag_chore_commits.__name__), + lazy_fixture(tag_patch_commits.__name__), + lazy_fixture(tag_minor_commits.__name__), + lazy_fixture(tag_major_commits.__name__), + ) ), *( - (TAG_COMMITS_MINOR, True, major_on_zero, "0.3.0-rc.1") + # when prerelease is True, & allow_zero_version is False, the version should be + # bumped to 1.0.0, when given any/none commits + # because 0.x is no longer a valid version regardless of the major_on_zero value + (commits, False, major_on_zero, False, "1.0.0") for major_on_zero in (True, False) + for commits in ( + lazy_fixture(tag_patch_commits.__name__), + lazy_fixture(tag_minor_commits.__name__), + lazy_fixture(tag_major_commits.__name__), + ) ), - (TAG_COMMITS_MAJOR, False, True, "1.0.0"), - (TAG_COMMITS_MAJOR, True, True, "1.0.0-rc.1"), - (TAG_COMMITS_MAJOR, False, False, "0.3.0"), - (TAG_COMMITS_MAJOR, True, False, "0.3.0-rc.1"), ], # Latest version for repo_with_main_and_feature_branches is currently # 0.3.0-beta.1. # The last full release version was 0.2.0, so it's had a minor # prerelease ( - "repo_with_main_and_feature_branches_tag_commits", - "default_tag_parser", + repo_w_github_flow_w_feature_release_channel_tag_commits.__name__, + default_tag_parser.__name__, VersionTranslator(prerelease_token="beta"), ): [ *( - (commits, True, major_on_zero, "0.3.0-beta.1") + # when prerelease is True, & major_on_zero is True & False, + # the version is not bumped because nothing of importance happened + (commits, True, major_on_zero, True, "0.3.0-beta.1") for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) + for commits in (None, lazy_fixture(tag_chore_commits.__name__)) + ), + *( + (commits, True, False, True, "0.3.0-beta.2") + for commits in ( + # when prerelease is True, & major_on_zero is False, the version should be + # increment the next prerelease version, when given patch level commits + # because the last full release was 0.2.0 and the prior prerelease consumes the + # patch bump + lazy_fixture(tag_patch_commits.__name__), + # when prerelease is True, & major_on_zero is False, the version should be + # minor bumped, when given patch level commits because last full version was 0.2.0 + lazy_fixture(tag_minor_commits.__name__), + # when prerelease is True, & major_on_zero is False, the version should be + # increment the next prerelease version, when given new breaking changes + # because major_on_zero is false, the last full release was 0.2.0 + # and the prior prerelease consumes the breaking changes + lazy_fixture(tag_major_commits.__name__), + ) ), - # Models a merge of commits from the branch to the main branch, now - # that prerelease=False *( - (commits, False, major_on_zero, "0.3.0") + (commits, False, False, True, "0.3.0") + for commits in ( + # Even though we are not making any changes, and prerelease is + # off, we look at the last full version which is 0.2.0 and + # consider the previous minor commit to cause the bump to 0.3.0 + None, + # same as None, but with a chore commit + lazy_fixture(tag_chore_commits.__name__), + # when prerelease is False, & major_on_zero is False, the version should be + # patch bumped, when given patch level commits because last full version was 0.2.0 + # and it was previously identified (from the prerelease) that a minor commit + # exists between 0.2.0 and now + lazy_fixture(tag_patch_commits.__name__), + # when prerelease is False, & major_on_zero is False, the version should be + # minor bumped, when given patch level commits because last full version was 0.2.0 + lazy_fixture(tag_minor_commits.__name__), + # when prerelease is False, & major_on_zero is False, the version should be + # minor bumped, when given new breaking changes because + # major_on_zero is false and last full version was 0.2.0 + lazy_fixture(tag_major_commits.__name__), + ) + ), + # when prerelease is True, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0 as a prerelease version, when + # given major level commits. The previous prerelease is ignored because of the major + # bump + ( + lazy_fixture(tag_major_commits.__name__), + True, + True, + True, + "1.0.0-beta.1", + ), + # when prerelease is False, & major_on_zero is True, and allow_zero_version + # is True, the version should be bumped to 1.0.0, when given major level commits + ( + lazy_fixture(tag_major_commits.__name__), + False, + True, + True, + "1.0.0", + ), + *( + # Since allow_zero_version is False, the version should be 1.0.0 + # as a prerelease value due to prerelease=True, across the board + # regardless of the major_on_zero value + (commits, True, major_on_zero, False, "1.0.0-beta.1") for major_on_zero in (True, False) - for commits in ([], ["uninteresting"]) + for commits in ( + # None & chore commits are absorbed into the previously detected minor bump + # and because 0 versions are not allowed + None, + lazy_fixture(tag_chore_commits.__name__), + lazy_fixture(tag_patch_commits.__name__), + lazy_fixture(tag_minor_commits.__name__), + lazy_fixture(tag_major_commits.__name__), + ) ), *( - (TAG_COMMITS_PATCH, False, major_on_zero, "0.3.0") + # Since allow_zero_version is False, the version should be 1.0.0 + # across the board regardless of the major_on_zero value + (commits, False, True, False, "1.0.0") + for commits in ( + # None & chore commits are absorbed into the previously detected minor bump + # and because 0 versions are not allowed + None, + # Same as above, even though our change does not trigger a bump normally + lazy_fixture(tag_chore_commits.__name__), + # Even though we apply more patch, minor, major commits, the previous + # minor commit (in the prerelase tag) triggers a higher bump & + # with allow_zero_version=False, and ignore prereleases, we bump to 1.0.0 + lazy_fixture(tag_patch_commits.__name__), + lazy_fixture(tag_minor_commits.__name__), + lazy_fixture(tag_major_commits.__name__), + ) + ), + ], + }.items() + for ( + commit_messages, + prerelease, + major_on_zero, + allow_zero_version, + expected_new_version, + ) in values + ], + ), +) +def test_algorithm_with_zero_dot_versions_tag( + repo, + file_in_repo, + commit_parser, + translator, + commit_messages, + prerelease, + expected_new_version, + major_on_zero, + allow_zero_version, +): + # Setup + for commit_message in commit_messages or []: + add_text_to_file(repo, file_in_repo) + repo.git.commit(m=commit_message) + + # Action + new_version = next_version( + repo, translator, commit_parser, prerelease, major_on_zero, allow_zero_version + ) + + # Verify + assert expected_new_version == str(new_version) + + +@pytest.mark.parametrize( + str.join( + " ,", + [ + "repo", + "commit_parser", + "translator", + "commit_messages", + "prerelease", + "major_on_zero", + "allow_zero_version", + "expected_new_version", + ], + ), + xdist_sort_hack( + [ + ( + lazy_fixture(repo_with_no_tags_tag_commits.__name__), + lazy_fixture(parser_fixture_name), + translator[0], + commit_messages, + prerelease, + major_on_zero, + allow_zero_version, + expected_new_version, + ) + for translator, values in { + # Latest version for repo_with_no_tags is currently 0.0.0 (default) + # It's biggest change type is minor, so the next version should be 0.1.0 + (VersionTranslator(),): [ + *( + # when prerelease is False, major_on_zero is True & False, & allow_zero_version is True + # the version should be 0.0.0, when no distintive changes have been made since the + # start of the project + (commits, parser, prerelease, major_on_zero, True, "0.0.0") + for prerelease in (True, False) for major_on_zero in (True, False) + for commits, parser in ( + # No commits added, so base is just initial commit at 0.0.0 + (None, default_tag_parser.__name__), + # Chore like commits also don't trigger a version bump so it stays 0.0.0 + ( + lazy_fixture(angular_chore_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(emoji_chore_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(scipy_chore_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(tag_chore_commits.__name__), + default_tag_parser.__name__, + ), + ) + ), + *( + (commits, parser, True, major_on_zero, True, "0.0.1-rc.1") + for major_on_zero in (True, False) + for commits, parser in ( + # when prerelease is True & allow_zero_version is True, the version should be + # a patch bump as a prerelease version, because of the patch level commits + # major_on_zero is irrelevant here as we are only applying patch commits + ( + lazy_fixture(angular_patch_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(emoji_patch_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(scipy_patch_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(tag_patch_commits.__name__), + default_tag_parser.__name__, + ), + ) ), *( - (TAG_COMMITS_PATCH, True, major_on_zero, "0.3.0-beta.2") + (commits, parser, False, major_on_zero, True, "0.0.1") for major_on_zero in (True, False) + for commits, parser in ( + # when prerelease is False, & allow_zero_version is True, the version should be + # a patch bump because of the patch commits added + # major_on_zero is irrelevant here as we are only applying patch commits + ( + lazy_fixture(angular_patch_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(emoji_patch_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(scipy_patch_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(tag_patch_commits.__name__), + default_tag_parser.__name__, + ), + ) ), *( - (TAG_COMMITS_MINOR, False, major_on_zero, "0.3.0") + (commits, parser, True, False, True, "0.1.0-rc.1") + for commits, parser in ( + # when prerelease is False, & major_on_zero is False, the version should be + # a minor bump because of the minor commits added + ( + lazy_fixture(angular_minor_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(emoji_minor_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(scipy_minor_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(tag_minor_commits.__name__), + default_tag_parser.__name__, + ), + # Given the major_on_zero is False and the version is starting at 0.0.0, + # the major level commits are limited to only causing a minor level bump + ( + lazy_fixture(angular_major_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(emoji_major_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(scipy_major_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(tag_major_commits.__name__), + default_tag_parser.__name__, + ), + ) + ), + *( + (commits, parser, False, False, True, "0.1.0") + for commits, parser in ( + # when prerelease is False, + # major_on_zero is False, & allow_zero_version is True + # the version should be a minor bump of 0.0.0 + # because of the minor commits added and zero version is allowed + ( + lazy_fixture(angular_minor_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(emoji_minor_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(scipy_minor_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(tag_minor_commits.__name__), + default_tag_parser.__name__, + ), + # Given the major_on_zero is False and the version is starting at 0.0.0, + # the major level commits are limited to only causing a minor level bump + ( + lazy_fixture(angular_major_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(emoji_major_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(scipy_major_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(tag_major_commits.__name__), + default_tag_parser.__name__, + ), + ) + ), + *( + # when prerelease is True, & allow_zero_version is False, the version should be + # a prerelease version 1.0.0-rc.1, across the board when any valuable change + # is made because of the allow_zero_version is False, major_on_zero is ignored + # when allow_zero_version is False (but we still test it) + (commits, parser, True, major_on_zero, False, "1.0.0-rc.1") for major_on_zero in (True, False) + for commits, parser in ( + # parser doesn't matter here as long as it detects a NO_RELEASE on Initial Commit + (None, default_tag_parser.__name__), + ( + lazy_fixture(angular_chore_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(angular_patch_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(angular_minor_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(angular_major_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(emoji_chore_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(emoji_patch_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(emoji_minor_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(emoji_major_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(scipy_chore_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(scipy_patch_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(scipy_minor_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(scipy_major_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(tag_chore_commits.__name__), + default_tag_parser.__name__, + ), + ( + lazy_fixture(tag_patch_commits.__name__), + default_tag_parser.__name__, + ), + ( + lazy_fixture(tag_minor_commits.__name__), + default_tag_parser.__name__, + ), + ( + lazy_fixture(tag_major_commits.__name__), + default_tag_parser.__name__, + ), + ) ), *( - (TAG_COMMITS_MINOR, True, major_on_zero, "0.3.0-beta.2") + # when prerelease is True, & allow_zero_version is False, the version should be + # 1.0.0, across the board when any valuable change + # is made because of the allow_zero_version is False. major_on_zero is ignored + # when allow_zero_version is False (but we still test it) + (commits, parser, False, major_on_zero, False, "1.0.0") for major_on_zero in (True, False) + for commits, parser in ( + (None, default_tag_parser.__name__), + ( + lazy_fixture(angular_chore_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(angular_patch_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(angular_minor_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(angular_major_commits.__name__), + default_angular_parser.__name__, + ), + ( + lazy_fixture(emoji_chore_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(emoji_patch_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(emoji_minor_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(emoji_major_commits.__name__), + default_emoji_parser.__name__, + ), + ( + lazy_fixture(scipy_chore_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(scipy_patch_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(scipy_minor_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(scipy_major_commits.__name__), + default_scipy_parser.__name__, + ), + ( + lazy_fixture(tag_chore_commits.__name__), + default_tag_parser.__name__, + ), + ( + lazy_fixture(tag_patch_commits.__name__), + default_tag_parser.__name__, + ), + ( + lazy_fixture(tag_minor_commits.__name__), + default_tag_parser.__name__, + ), + ( + lazy_fixture(tag_major_commits.__name__), + default_tag_parser.__name__, + ), + ) ), - (TAG_COMMITS_MAJOR, False, True, "1.0.0"), - (TAG_COMMITS_MAJOR, True, True, "1.0.0-beta.1"), - (TAG_COMMITS_MAJOR, False, False, "0.3.0"), - # Note - since breaking changes are absorbed into the minor digit - # with major_on_zero = False, and that's already been incremented - # since the last full release, the breaking change here will only - # trigger a prerelease revision - (TAG_COMMITS_MAJOR, True, False, "0.3.0-beta.2"), ], }.items() for ( commit_messages, + parser_fixture_name, prerelease, major_on_zero, + allow_zero_version, expected_new_version, ) in values ], ), ) -def test_algorithm_with_zero_dot_versions_tag( - repo, +def test_algorithm_with_zero_dot_versions_minimums( + repo: Repo, file_in_repo, commit_parser, translator, @@ -1110,15 +2596,21 @@ def test_algorithm_with_zero_dot_versions_tag( prerelease, expected_new_version, major_on_zero, + allow_zero_version, ): - for commit_message in commit_messages: + # Setup + # Move tree down to the Initial Commit + initial_commit = repo.git.log("--max-parents=0", "--format=%H").strip() + repo.git.reset("--hard", initial_commit) + + for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message) + # Action new_version = next_version( - repo, translator, commit_parser, prerelease, major_on_zero + repo, translator, commit_parser, prerelease, major_on_zero, allow_zero_version ) - assert new_version == Version.parse( - expected_new_version, prerelease_token=translator.prerelease_token - ) + # Verify + assert expected_new_version == str(new_version) diff --git a/tests/scenario/test_release_history.py b/tests/scenario/test_release_history.py index 3fdc75bc7..0c51ced8f 100644 --- a/tests/scenario/test_release_history.py +++ b/tests/scenario/test_release_history.py @@ -93,7 +93,7 @@ class FakeReleaseHistoryElements(NamedTuple): "unknown": [COMMIT_MESSAGE.format(version="0.2.0")], }, Version.parse("0.3.0-beta.1"): { - "feature": ["feat: (feature) add some more text\n"], + "feature": ["feat(feature): add some more text\n"], "unknown": [COMMIT_MESSAGE.format(version="0.3.0-beta.1")], }, }, @@ -119,20 +119,20 @@ class FakeReleaseHistoryElements(NamedTuple): "unknown": [COMMIT_MESSAGE.format(version="1.0.0")], }, Version.parse("1.1.0"): { - "feature": ["feat: (dev) add some more text\n"], + "feature": ["feat(dev): add some more text\n"], "unknown": [COMMIT_MESSAGE.format(version="1.1.0")], }, Version.parse("1.1.1"): { - "fix": ["fix: (dev) add some more text\n"], + "fix": ["fix(dev): add some more text\n"], "unknown": [COMMIT_MESSAGE.format(version="1.1.1")], }, Version.parse("1.2.0-alpha.1"): { - "feature": ["feat: (feature) add some more text\n"], + "feature": ["feat(feature): add some more text\n"], "unknown": [COMMIT_MESSAGE.format(version="1.2.0-alpha.1")], }, Version.parse("1.2.0-alpha.2"): { - "feature": ["feat: (feature) add some more text\n"], - "fix": ["fix: (feature) add some missing text\n"], + "feature": ["feat(feature): add some more text\n"], + "fix": ["fix(feature): add some missing text\n"], "unknown": [COMMIT_MESSAGE.format(version="1.2.0-alpha.2")], }, }, @@ -158,23 +158,23 @@ class FakeReleaseHistoryElements(NamedTuple): "unknown": [COMMIT_MESSAGE.format(version="1.0.0")], }, Version.parse("1.1.0-rc.1"): { - "feature": ["feat: (dev) add some more text\n"], + "feature": ["feat(dev): add some more text\n"], "unknown": [COMMIT_MESSAGE.format(version="1.1.0-rc.1")], }, Version.parse("1.1.0-rc.2"): { - "fix": ["fix: (dev) add some more text\n"], + "fix": ["fix(dev): add some more text\n"], "unknown": [COMMIT_MESSAGE.format(version="1.1.0-rc.2")], }, Version.parse("1.1.0-alpha.1"): { - "feature": ["feat: (feature) add some more text\n"], + "feature": ["feat(feature): add some more text\n"], "unknown": [COMMIT_MESSAGE.format(version="1.1.0-alpha.1")], }, Version.parse("1.1.0-alpha.2"): { - "feature": ["feat: (feature) add some more text\n"], + "feature": ["feat(feature): add some more text\n"], "unknown": [COMMIT_MESSAGE.format(version="1.1.0-alpha.2")], }, Version.parse("1.1.0-alpha.3"): { - "fix": ["fix: (feature) add some more text\n"], + "fix": ["fix(feature): add some missing text\n"], "unknown": [COMMIT_MESSAGE.format(version="1.1.0-alpha.3")], }, }, @@ -199,7 +199,9 @@ class FakeReleaseHistoryElements(NamedTuple): REPO_WITH_SINGLE_BRANCH_AND_PRERELEASES_EXPECTED_RELEASE_HISTORY, ), ( - lazy_fixture("repo_with_main_and_feature_branches_angular_commits"), + lazy_fixture( + "repo_w_github_flow_w_feature_release_channel_angular_commits" + ), REPO_WITH_MAIN_AND_FEATURE_BRANCHES_EXPECTED_RELEASE_HISTORY, ), ( @@ -231,15 +233,22 @@ def test_release_history( ), ) for k in expected_release_history.released: - expected, actual = expected_release_history.released[k], released[k]["elements"] - actual_released_messages = [ - res.commit.message for results in actual.values() for res in results - ] - assert all( - msg in actual_released_messages - for bucket in expected.values() - for msg in bucket + expected = expected_release_history.released[k] + actual = released[k]["elements"] + actual_released_messages = str.join( + "\n\n", + sorted( + [ + str(res.commit.message) + for results in actual.values() + for res in results + ] + ), ) + expected_released_messages = str.join( + "\n\n", sorted([msg for bucket in expected.values() for msg in bucket]) + ) + assert expected_released_messages == actual_released_messages for commit_message in ANGULAR_COMMITS_MINOR: add_text_to_file(repo, file_in_repo) @@ -249,17 +258,33 @@ def test_release_history( new_unreleased, new_released = ReleaseHistory.from_git_history( repo, translator, default_angular_parser ) - actual_unreleased_messages = [ - res.commit.message for results in new_unreleased.values() for res in results - ] - assert all( - msg in actual_unreleased_messages - for bucket in [ - *expected_release_history.unreleased.values(), - ANGULAR_COMMITS_MINOR, - ] - for msg in bucket + + actual_unreleased_messages = str.join( + "\n\n", + sorted( + [ + str(res.commit.message) + for results in new_unreleased.values() + for res in results + ] + ), ) + + expected_unreleased_messages = str.join( + "\n\n", + sorted( + [ + msg + for bucket in [ + ANGULAR_COMMITS_MINOR[::-1], + *expected_release_history.unreleased.values(), + ] + for msg in bucket + ] + ), + ) + + assert expected_unreleased_messages == actual_unreleased_messages assert ( new_released == released ), "something that shouldn't be considered release has been released" @@ -271,7 +296,7 @@ def test_release_history( lazy_fixture("repo_with_no_tags_angular_commits"), lazy_fixture("repo_with_single_branch_angular_commits"), lazy_fixture("repo_with_single_branch_and_prereleases_angular_commits"), - lazy_fixture("repo_with_main_and_feature_branches_angular_commits"), + lazy_fixture("repo_w_github_flow_w_feature_release_channel_angular_commits"), lazy_fixture("repo_with_git_flow_angular_commits"), lazy_fixture("repo_with_git_flow_and_release_channels_angular_commits"), ], @@ -311,7 +336,7 @@ def test_release_history_releases(repo, default_angular_parser): lazy_fixture("repo_with_no_tags_angular_commits"), lazy_fixture("repo_with_single_branch_angular_commits"), lazy_fixture("repo_with_single_branch_and_prereleases_angular_commits"), - lazy_fixture("repo_with_main_and_feature_branches_angular_commits"), + lazy_fixture("repo_w_github_flow_w_feature_release_channel_angular_commits"), lazy_fixture("repo_with_git_flow_angular_commits"), lazy_fixture("repo_with_git_flow_and_release_channels_angular_commits"), ], diff --git a/tests/scenario/test_template_render.py b/tests/scenario/test_template_render.py index c4fbbb0af..b3d1f1598 100644 --- a/tests/scenario/test_template_render.py +++ b/tests/scenario/test_template_render.py @@ -1,11 +1,19 @@ +from __future__ import annotations + import itertools import os -from pathlib import Path +from typing import TYPE_CHECKING import pytest from semantic_release.changelog.template import environment, recursive_render +if TYPE_CHECKING: + from pathlib import Path + + from tests.fixtures.example_project import ExProjectDir + + NORMAL_TEMPLATE_SRC = """--- content: - a string @@ -38,53 +46,54 @@ def _strip_trailing_j2(path: Path) -> Path: @pytest.fixture -def normal_template(example_project_template_dir): +def normal_template(example_project_template_dir: Path) -> Path: template = example_project_template_dir / "normal.yaml.j2" + template.parent.mkdir(parents=True, exist_ok=True) template.write_text(NORMAL_TEMPLATE_SRC) return template @pytest.fixture -def long_directory_path(example_project_template_dir): +def long_directory_path(example_project_template_dir: Path) -> Path: # NOTE: fixture enables using Path rather than # constant string, so no issue with / vs \ on Windows - d = example_project_template_dir / "long" / "dir" / "path" - os.makedirs(str(d.resolve()), exist_ok=True) - return d + return example_project_template_dir / "long" / "dir" / "path" @pytest.fixture -def deeply_nested_file(long_directory_path): +def deeply_nested_file(long_directory_path: Path) -> Path: file = long_directory_path / "buried.txt" + file.parent.mkdir(parents=True, exist_ok=True) file.write_text(PLAINTEXT_FILE_CONTENT) return file @pytest.fixture -def hidden_file(example_project_template_dir): +def hidden_file(example_project_template_dir: Path) -> Path: file = example_project_template_dir / ".hidden" + file.parent.mkdir(parents=True, exist_ok=True) file.write_text("I shouldn't be present") return file @pytest.fixture -def directory_path_with_hidden_subfolder(example_project_template_dir): - d = example_project_template_dir / "path" / ".subfolder" / "hidden" - os.makedirs(str(d.resolve()), exist_ok=True) - return d +def directory_path_with_hidden_subfolder(example_project_template_dir: Path) -> Path: + return example_project_template_dir / "path" / ".subfolder" / "hidden" @pytest.fixture -def excluded_file(directory_path_with_hidden_subfolder): +def excluded_file(directory_path_with_hidden_subfolder: Path) -> Path: file = directory_path_with_hidden_subfolder / "excluded.txt" + file.parent.mkdir(parents=True, exist_ok=True) file.write_text("I shouldn't be present") return file @pytest.mark.usefixtures("excluded_file") def test_recursive_render( - example_project, - example_project_template_dir, + init_example_project: None, + example_project_dir: Path, + example_project_template_dir: Path, normal_template, deeply_nested_file, hidden_file, @@ -92,39 +101,77 @@ def test_recursive_render( tmpl_dir = str(example_project_template_dir.resolve()) env = environment(template_dir=tmpl_dir) - preexisting_paths = set(example_project.rglob("**/*")) + preexisting_paths = set(example_project_dir.rglob("**/*")) recursive_render( - template_dir=str(tmpl_dir), + template_dir=example_project_template_dir.resolve(), environment=env, - _root_dir=str(example_project.resolve()), + _root_dir=str(example_project_dir.resolve()), ) rendered_normal_template = _strip_trailing_j2( - example_project / normal_template.relative_to(example_project_template_dir) + example_project_dir / normal_template.relative_to(example_project_template_dir) ) assert rendered_normal_template.exists() assert rendered_normal_template.read_text() == NORMAL_TEMPLATE_RENDERED - rendered_deeply_nested = example_project / deeply_nested_file.relative_to( + rendered_deeply_nested = example_project_dir / deeply_nested_file.relative_to( example_project_template_dir ) assert rendered_deeply_nested.exists() assert rendered_deeply_nested.read_text() == PLAINTEXT_FILE_CONTENT - rendered_hidden = example_project / hidden_file.relative_to( + rendered_hidden = example_project_dir / hidden_file.relative_to( example_project_template_dir ) assert not rendered_hidden.exists() - assert not (example_project / "path").exists() + assert not (example_project_dir / "path").exists() - assert set(example_project.rglob("**/*")) == preexisting_paths.union( - example_project / p + assert set(example_project_dir.rglob("**/*")) == preexisting_paths.union( + example_project_dir / p for t in ( rendered_normal_template, rendered_deeply_nested, ) for p in itertools.accumulate( - t.relative_to(example_project).parts, func=lambda *a: os.sep.join(a) + t.relative_to(example_project_dir).parts, func=lambda *a: os.sep.join(a) ) ) + + +@pytest.fixture +def dotfolder_template_dir(example_project_dir: ExProjectDir) -> Path: + return example_project_dir / ".templates/.psr-templates" + + +@pytest.fixture +def dotfolder_template( + init_example_project: None, dotfolder_template_dir: Path +) -> Path: + tmpl = dotfolder_template_dir / "template.txt" + tmpl.parent.mkdir(parents=True, exist_ok=True) + tmpl.write_text("I am a template") + return tmpl + + +def test_recursive_render_with_top_level_dotfolder( + init_example_project: None, + example_project_dir: ExProjectDir, + dotfolder_template: Path, + dotfolder_template_dir: Path, +): + preexisting_paths = set(example_project_dir.rglob("**/*")) + env = environment(template_dir=dotfolder_template_dir.resolve()) + + recursive_render( + template_dir=dotfolder_template_dir.resolve(), + environment=env, + _root_dir=example_project_dir.resolve(), + ) + + rendered_template = example_project_dir / dotfolder_template.name + assert rendered_template.exists() + + assert set(example_project_dir.rglob("**/*")) == preexisting_paths.union( + {example_project_dir / rendered_template} + ) diff --git a/tests/unit/semantic_release/changelog/TEST_CHANGELOG.md.j2 b/tests/unit/semantic_release/changelog/TEST_CHANGELOG.md.j2 deleted file mode 100644 index e28faed21..000000000 --- a/tests/unit/semantic_release/changelog/TEST_CHANGELOG.md.j2 +++ /dev/null @@ -1,24 +0,0 @@ -{# - NOTE: this changelog test doesn't include commit hashes from the default template as - they always change - which makes it notoriously difficult to check exact content -#}# CHANGELOG -{% if context.history.unreleased | length > 0 %} -## Unreleased -{% for type_, commits in context.history.unreleased | dictsort %} -### {{ type_ | capitalize }} -{% for commit in commits %}{% if type_ != "unknown" %} -* {{ commit.message.rstrip() }} -{% else %} -* {{ commit.message.rstrip() }} -{% endif %}{% endfor %} -{% endfor %}{% endif %} -{% for version, release in context.history.released.items() %} -## {{ version.as_semver_tag() }} ({{ release.tagged_date.strftime("%Y-%m-%d") }}) -{% for type_, commits in release["elements"] | dictsort %} -### {{ type_ | capitalize }} -{% for commit in commits %}{% if type_ != "unknown" %} -* {{ commit.message.rstrip() }} -{% else %} -* {{ commit.message.rstrip() }} -{% endif %}{% endfor %} -{% endfor %}{% endfor %} diff --git a/tests/unit/semantic_release/changelog/test_changelog_context.py b/tests/unit/semantic_release/changelog/test_changelog_context.py deleted file mode 100644 index f90cc7432..000000000 --- a/tests/unit/semantic_release/changelog/test_changelog_context.py +++ /dev/null @@ -1,144 +0,0 @@ -import pytest -from git.objects.base import Object -from pytest_lazyfixture import lazy_fixture - -from semantic_release.changelog import environment, make_changelog_context -from semantic_release.changelog.release_history import ReleaseHistory -from semantic_release.hvcs import Gitea, Github, Gitlab -from semantic_release.version.translator import VersionTranslator - -NULL_HEX_SHA = Object.NULL_HEX_SHA -SHORT_SHA = NULL_HEX_SHA[:7] - - -# Test with just one project for the moment - can be expanded to all -# example projects later - -CHANGELOG_TEMPLATE = r""" -# CHANGELOG -{% if context.history.unreleased | length > 0 %} -## Unreleased -{% for type_, commits in context.history.unreleased.items() %} -### {{ type_ | capitalize }} -{% for commit in commits %}{% if type_ != "unknown" %} -* {{ commit.message.rstrip() }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }})) -{% else %} -* {{ commit.message.rstrip() }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }})) -{% endif %}{% endfor %}{% endfor %}{% endif %} -{% for version, release in context.history.released.items() %} -## {{ version.as_tag() }} ({{ release.tagged_date.strftime("%Y-%m-%d") }}) -{% for type_, commits in release["elements"].items() %} -### {{ type_ | capitalize }} -{% for commit in commits %}{% if type_ != "unknown" %} -* {{ commit.message.rstrip() }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }})) -{% else %} -* {{ commit.message.rstrip() }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }})) -{% endif %}{% endfor %}{% endfor %}{% endfor %} -""" # noqa: E501 - -EXPECTED_CHANGELOG_CONTENT_ANGULAR = r""" -# CHANGELOG -## v0.2.0 -### Feature -* feat: add some more text ([`{SHORT_SHA}`]({commit_url})) -## v0.2.0-rc.1 -### Feature -* feat: add some more text ([`{SHORT_SHA}`]({commit_url})) -## v0.1.1-rc.1 -### Fix -* fix: add some more text ([`{SHORT_SHA}`]({commit_url})) -## v0.1.0 -### Unknown -* Initial commit ([`{SHORT_SHA}`]({commit_url})) -""" - - -EXPECTED_CHANGELOG_CONTENT_EMOJI = r""" -# CHANGELOG -## v0.2.0 -### :sparkles: -* :sparkles: add some more text ([`{SHORT_SHA}`]({commit_url})) -## v0.2.0-rc.1 -### :sparkles: -* :sparkles: add some more text ([`{SHORT_SHA}`]({commit_url})) -## v0.1.1-rc.1 -### :bug: -* :bug: add some more text ([`{SHORT_SHA}`]({commit_url})) -## v0.1.0 -### Unknown -* Initial commit ([`{SHORT_SHA}`]({commit_url})) -""" - -EXPECTED_CHANGELOG_CONTENT_SCIPY = r""" -# CHANGELOG -## v0.2.0 -### ENH: -* ENH: add some more text ([`{SHORT_SHA}`]({commit_url})) -## v0.2.0-rc.1 -### ENH: -* ENH: add some more text ([`{SHORT_SHA}`]({commit_url})) -## v0.1.1-rc.1 -### MAINT: -* MAINT: add some more text ([`{SHORT_SHA}`]({commit_url})) -## v0.1.0 -### Unknown -* Initial commit ([`{SHORT_SHA}`]({commit_url})) -""" - -EXPECTED_CHANGELOG_CONTENT_TAG = r""" -# CHANGELOG -## v0.2.0 -### :sparkles: -* :sparkles: add some more text ([`{SHORT_SHA}`]({commit_url})) -## v0.2.0-rc.1 -### :sparkles: -* :sparkles: add some more text ([`{SHORT_SHA}`]({commit_url})) -## v0.1.1-rc.1 -### :nut_and_bolt: -* :nut_and_bolt: add some more text ([`{SHORT_SHA}`]({commit_url})) -## v0.1.0 -### Unknown -* Initial commit ([`{SHORT_SHA}`]({commit_url})) -""" - - -@pytest.mark.parametrize("changelog_template", (CHANGELOG_TEMPLATE,)) -@pytest.mark.parametrize( - "repo, commit_parser, expected_changelog", - [ - ( - lazy_fixture("repo_with_single_branch_and_prereleases_angular_commits"), - lazy_fixture("default_angular_parser"), - EXPECTED_CHANGELOG_CONTENT_ANGULAR, - ), - ( - lazy_fixture("repo_with_single_branch_and_prereleases_emoji_commits"), - lazy_fixture("default_emoji_parser"), - EXPECTED_CHANGELOG_CONTENT_EMOJI, - ), - ( - lazy_fixture("repo_with_single_branch_and_prereleases_scipy_commits"), - lazy_fixture("default_scipy_parser"), - EXPECTED_CHANGELOG_CONTENT_SCIPY, - ), - ( - lazy_fixture("repo_with_single_branch_and_prereleases_tag_commits"), - lazy_fixture("default_tag_parser"), - EXPECTED_CHANGELOG_CONTENT_TAG, - ), - ], -) -@pytest.mark.parametrize("hvcs_client_class", (Github, Gitlab, Gitea)) -@pytest.mark.usefixtures("expected_changelog") -def test_changelog_context(repo, changelog_template, commit_parser, hvcs_client_class): - # NOTE: this test only checks that the changelog can be rendered with the - # contextual information we claim to offer. Testing that templates render - # appropriately is the responsibility of the template engine's authors, - # so we shouldn't be re-testing that here. - hvcs_client = hvcs_client_class(remote_url=repo.remote().url) - env = environment(lstrip_blocks=True, keep_trailing_newline=True, trim_blocks=True) - rh = ReleaseHistory.from_git_history(repo, VersionTranslator(), commit_parser) - context = make_changelog_context(hvcs_client=hvcs_client, release_history=rh) - context.bind_to_environment(env) - actual_content = env.from_string(changelog_template).render() - assert actual_content diff --git a/tests/unit/semantic_release/changelog/test_default_changelog.py b/tests/unit/semantic_release/changelog/test_default_changelog.py index e79fe323b..f5dc9fbc3 100644 --- a/tests/unit/semantic_release/changelog/test_default_changelog.py +++ b/tests/unit/semantic_release/changelog/test_default_changelog.py @@ -1,109 +1,189 @@ -# NOTE: use backport with newer API +from __future__ import annotations + from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +from git import Commit, Object, Repo +# NOTE: use backport with newer API from importlib_resources import files +import semantic_release from semantic_release.changelog.context import make_changelog_context -from semantic_release.changelog.release_history import ReleaseHistory +from semantic_release.changelog.release_history import Release, ReleaseHistory from semantic_release.changelog.template import environment -from semantic_release.hvcs import Github -from semantic_release.version.translator import VersionTranslator - -from tests.const import COMMIT_MESSAGE - -default_changelog_template = ( - files("tests") - .joinpath("unit/semantic_release/changelog/TEST_CHANGELOG.md.j2") - .read_text(encoding="utf-8") -) - -today_as_str = datetime.now().strftime("%Y-%m-%d") - - -def _cm_rstripped(version: str) -> str: - return COMMIT_MESSAGE.format(version=version).rstrip() - - -EXPECTED_CONTENT = f"""\ -# CHANGELOG -## v1.1.0-alpha.3 ({today_as_str}) -### Fix -* fix: (feature) add some more text -### Unknown -* {_cm_rstripped("1.1.0-alpha.3")} -## v1.1.0-alpha.2 ({today_as_str}) -### Feature -* feat: (feature) add some more text -### Unknown -* {_cm_rstripped("1.1.0-alpha.2")} -## v1.1.0-alpha.1 ({today_as_str}) -### Feature -* feat: (feature) add some more text -### Unknown -* {_cm_rstripped("1.1.0-alpha.1")} -## v1.1.0-rc.2 ({today_as_str}) -### Fix -* fix: (dev) add some more text -### Unknown -* {_cm_rstripped("1.1.0-rc.2")} -## v1.1.0-rc.1 ({today_as_str}) -### Feature -* feat: (dev) add some more text -### Unknown -* {_cm_rstripped("1.1.0-rc.1")} -## v1.0.0 ({today_as_str}) -### Feature -* feat: add some more text -### Unknown -* {_cm_rstripped("1.0.0")} -## v1.0.0-rc.1 ({today_as_str}) -### Breaking -* feat!: add some more text -### Unknown -* {_cm_rstripped("1.0.0-rc.1")} -## v0.1.1-rc.1 ({today_as_str}) -### Fix -* fix: add some more text -### Unknown -* {_cm_rstripped("0.1.1-rc.1")} -## v0.1.0 ({today_as_str}) -### Unknown -* {_cm_rstripped("0.1.0")} -* Initial commit -""" +from semantic_release.commit_parser import ParsedCommit +from semantic_release.enums import LevelBump +from semantic_release.hvcs import Bitbucket, Gitea, Github, Gitlab +from semantic_release.version.translator import Version + +from tests.const import TODAY_DATE_STR + +if TYPE_CHECKING: + from git import Actor + + from semantic_release.hvcs import HvcsBase + + +@pytest.fixture +def default_changelog_template() -> str: + """Retrieve the semantic-release default changelog template.""" + version_notes_template = files(semantic_release.__name__).joinpath( + Path("data", "templates", "CHANGELOG.md.j2") + ) + return version_notes_template.read_text(encoding="utf-8") +@pytest.fixture +def artificial_release_history(commit_author: Actor): + version = Version.parse("1.0.0") + + commit_subject = "fix(cli): fix a problem" + + fix_commit = Commit( + Repo("."), + Object.NULL_HEX_SHA[:20].encode("utf-8"), + message=commit_subject, + ) + + fix_commit_parsed = ParsedCommit( + bump=LevelBump.PATCH, + type="fix", + scope="cli", + descriptions=[commit_subject], + breaking_descriptions=[], + commit=fix_commit, + ) + + commit_subject = "feat(cli): add a new feature" + + feat_commit = Commit( + Repo("."), + Object.NULL_HEX_SHA[:20].encode("utf-8"), + message=commit_subject, + ) + + feat_commit_parsed = ParsedCommit( + bump=LevelBump.MINOR, + type="feat", + scope="cli", + descriptions=[commit_subject], + breaking_descriptions=[], + commit=feat_commit, + ) + + return ReleaseHistory( + unreleased={ + "feature": [feat_commit_parsed], + }, + released={ + version: Release( + tagger=commit_author, + committer=commit_author, + tagged_date=datetime.utcnow(), + elements={ + "feature": [feat_commit_parsed], + "fix": [fix_commit_parsed], + }, + ) + }, + ) + + +@pytest.mark.parametrize("hvcs_client", [Github, Gitlab, Gitea, Bitbucket]) def test_default_changelog_template( - repo_with_git_flow_and_release_channels_angular_commits, default_angular_parser + default_changelog_template: str, + hvcs_client: type[HvcsBase], + example_git_https_url: str, + artificial_release_history: ReleaseHistory, ): - repo = repo_with_git_flow_and_release_channels_angular_commits - env = environment(trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True) - rh = ReleaseHistory.from_git_history( - repo=repo, translator=VersionTranslator(), commit_parser=default_angular_parser + version_str = "1.0.0" + version = Version.parse(version_str) + rh = artificial_release_history + rh.unreleased = {} # Wipe out unreleased + + feat_commit_obj = artificial_release_history.released[version]["elements"][ + "feature" + ][0] + feat_commit_url = hvcs_client(example_git_https_url).commit_hash_url( + feat_commit_obj.commit.hexsha + ) + feat_description = str.join("\n", feat_commit_obj.descriptions) + + fix_commit_obj = artificial_release_history.released[version]["elements"]["fix"][0] + fix_commit_url = hvcs_client(example_git_https_url).commit_hash_url( + fix_commit_obj.commit.hexsha + ) + fix_description = str.join("\n", fix_commit_obj.descriptions) + + expected_changelog = str.join( + "\n", + [ + "# CHANGELOG", + f"## v{version_str} ({TODAY_DATE_STR})", + "### Feature", + f"* {feat_description} ([`{feat_commit_obj.commit.hexsha[:7]}`]({feat_commit_url}))", + "### Fix", + f"* {fix_description} ([`{fix_commit_obj.commit.hexsha[:7]}`]({fix_commit_url}))", + "", + ], ) + env = environment(trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True) context = make_changelog_context( - hvcs_client=Github(remote_url=repo.remote().url), release_history=rh + hvcs_client=hvcs_client(remote_url=example_git_https_url), + release_history=rh, ) context.bind_to_environment(env) - actual_content = env.from_string(default_changelog_template).render() - assert actual_content == EXPECTED_CONTENT + actual_changelog = env.from_string(default_changelog_template).render() + assert expected_changelog == actual_changelog -def test_default_changelog_template_using_tag_format( - repo_with_git_flow_and_release_channels_angular_commits_using_tag_format, - default_angular_parser, +@pytest.mark.parametrize("hvcs_client", [Github, Gitlab, Gitea, Bitbucket]) +def test_default_changelog_template_w_unreleased_changes( + default_changelog_template: str, + hvcs_client: type[HvcsBase], + example_git_https_url: str, + artificial_release_history: ReleaseHistory, ): - repo = repo_with_git_flow_and_release_channels_angular_commits_using_tag_format - env = environment(trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True) - rh = ReleaseHistory.from_git_history( - repo=repo, - translator=VersionTranslator(tag_format="vpy{version}"), - commit_parser=default_angular_parser, + version_str = "1.0.0" + version = Version.parse(version_str) + + feat_commit_obj = artificial_release_history.released[version]["elements"][ + "feature" + ][0] + feat_commit_url = hvcs_client(example_git_https_url).commit_hash_url( + feat_commit_obj.commit.hexsha + ) + feat_description = str.join("\n", feat_commit_obj.descriptions) + + fix_commit_obj = artificial_release_history.released[version]["elements"]["fix"][0] + fix_commit_url = hvcs_client(example_git_https_url).commit_hash_url( + fix_commit_obj.commit.hexsha ) + fix_description = str.join("\n", fix_commit_obj.descriptions) + + expected_changelog = str.join( + "\n", + [ + "# CHANGELOG", + "## Unreleased", + "### Feature", + f"* {feat_description} ([`{feat_commit_obj.commit.hexsha[:7]}`]({feat_commit_url}))", + f"## v{version_str} ({TODAY_DATE_STR})", + "### Feature", + f"* {feat_description} ([`{feat_commit_obj.commit.hexsha[:7]}`]({feat_commit_url}))", + "### Fix", + f"* {fix_description} ([`{fix_commit_obj.commit.hexsha[:7]}`]({fix_commit_url}))", + "", + ], + ) + env = environment(trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True) context = make_changelog_context( - hvcs_client=Github(remote_url=repo.remote().url), release_history=rh + hvcs_client=hvcs_client(remote_url=example_git_https_url), + release_history=artificial_release_history, ) context.bind_to_environment(env) - - actual_content = env.from_string(default_changelog_template).render() - assert actual_content == EXPECTED_CONTENT + actual_changelog = env.from_string(default_changelog_template).render() + assert expected_changelog == actual_changelog diff --git a/tests/unit/semantic_release/changelog/test_release_notes.md.j2 b/tests/unit/semantic_release/changelog/test_release_notes.md.j2 deleted file mode 100644 index 088337c84..000000000 --- a/tests/unit/semantic_release/changelog/test_release_notes.md.j2 +++ /dev/null @@ -1,8 +0,0 @@ -# {{ version.as_tag() }} ({{ release.tagged_date.strftime("%Y-%m-%d") }}) -{% for type_, commits in release["elements"] | dictsort %} -## {{ type_ | capitalize }} -{% for commit in commits %}{% if type_ != "unknown" %} -* {{ commit.message.rstrip() }} -{% else %} -* {{ commit.message.rstrip() }} -{% endif %}{% endfor %}{% endfor %} diff --git a/tests/unit/semantic_release/changelog/test_release_notes.py b/tests/unit/semantic_release/changelog/test_release_notes.py index 441522612..285eea1b1 100644 --- a/tests/unit/semantic_release/changelog/test_release_notes.py +++ b/tests/unit/semantic_release/changelog/test_release_notes.py @@ -1,53 +1,112 @@ -# NOTE: use backport with newer API +from __future__ import annotations + from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +from git import Commit, Repo +from git.objects import Object +# NOTE: use backport with newer API to support 3.7 from importlib_resources import files +import semantic_release from semantic_release.changelog.context import make_changelog_context -from semantic_release.changelog.release_history import ReleaseHistory +from semantic_release.changelog.release_history import Release, ReleaseHistory from semantic_release.changelog.template import environment -from semantic_release.hvcs import Github -from semantic_release.version import Version, VersionTranslator +from semantic_release.commit_parser import ParsedCommit +from semantic_release.enums import LevelBump +from semantic_release.hvcs import Bitbucket, Gitea, Github, Gitlab +from semantic_release.version import Version -from tests.const import COMMIT_MESSAGE +from tests.const import TODAY_DATE_STR -default_release_notes_template = ( - files("tests") - .joinpath("unit/semantic_release/changelog/test_release_notes.md.j2") - .read_text(encoding="utf-8") -) +if TYPE_CHECKING: + from git import Actor -today_as_str = datetime.now().strftime("%Y-%m-%d") + from semantic_release.hvcs import HvcsBase -def _cm_rstripped(version: str) -> str: - return COMMIT_MESSAGE.format(version=version).rstrip() +@pytest.fixture +def artificial_release_history(commit_author: Actor): + version = Version.parse("1.1.0-alpha.3") + commit_subject = "fix(cli): fix a problem" + + fix_commit = Commit( + Repo("."), + Object.NULL_HEX_SHA[:20].encode("utf-8"), + message=commit_subject, + ) + fix_commit_parsed = ParsedCommit( + bump=LevelBump.PATCH, + type="fix", + scope="cli", + descriptions=[commit_subject], + breaking_descriptions=[], + commit=fix_commit, + ) -EXPECTED_CONTENT = f"""\ -# v1.1.0-alpha.3 ({today_as_str}) -## Fix -* fix: (feature) add some more text -## Unknown -* {_cm_rstripped("1.1.0-alpha.3")} -""" + return ReleaseHistory( + unreleased={}, + released={ + version: Release( + tagger=commit_author, + committer=commit_author, + tagged_date=datetime.utcnow(), + elements={ + "fix": [fix_commit_parsed], + }, + ) + }, + ) -def test_default_changelog_template( - repo_with_git_flow_and_release_channels_angular_commits, default_angular_parser +@pytest.fixture +def release_notes_template() -> str: + """Retrieve the semantic-release default release notes template.""" + version_notes_template = files(semantic_release.__name__).joinpath( + Path("data", "templates", "release_notes.md.j2") + ) + return version_notes_template.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("hvcs_client", [Github, Gitlab, Gitea, Bitbucket]) +def test_default_release_notes_template( + example_git_https_url: str, + hvcs_client: type[HvcsBase], + release_notes_template: str, + artificial_release_history: ReleaseHistory, ): - version = Version.parse("1.1.0-alpha.3") - repo = repo_with_git_flow_and_release_channels_angular_commits - env = environment(trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True) - rh = ReleaseHistory.from_git_history( - repo=repo, translator=VersionTranslator(), commit_parser=default_angular_parser + """ + Unit test goal: just make sure it renders the release notes template without error. + + Scenarios are better suited for all the variations (commit types). + """ + version_str = "1.1.0-alpha.3" + version = Version.parse(version_str) + commit_obj = artificial_release_history.released[version]["elements"]["fix"][0] + commit_url = hvcs_client(example_git_https_url).commit_hash_url( + commit_obj.commit.hexsha + ) + commit_description = str.join("\n", commit_obj.descriptions) + expected_content = str.join( + "\n", + [ + f"# v{version_str} ({TODAY_DATE_STR})", + "## Fix", + f"* {commit_description} ([`{commit_obj.commit.hexsha[:7]}`]({commit_url}))", + "", + ], ) + env = environment(trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True) context = make_changelog_context( - hvcs_client=Github(remote_url=repo.remote().url), release_history=rh + hvcs_client=hvcs_client(remote_url=example_git_https_url), + release_history=artificial_release_history, ) context.bind_to_environment(env) - release = rh.released[version] - actual_content = env.from_string(default_release_notes_template).render( - version=version, release=release + actual_content = env.from_string(release_notes_template).render( + version=version, release=context.history.released[version] ) - assert actual_content == EXPECTED_CONTENT + 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 9ead77eaf..1745bddd0 100644 --- a/tests/unit/semantic_release/cli/test_config.py +++ b/tests/unit/semantic_release/cli/test_config.py @@ -1,20 +1,128 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING from unittest import mock import pytest import tomlkit +from pydantic import RootModel, ValidationError from semantic_release.cli.config import ( + EnvConfigVar, GlobalCommandLineOptions, + HvcsClient, RawConfig, RuntimeContext, ) +from semantic_release.commit_parser.angular import AngularParserOptions +from semantic_release.commit_parser.emoji import EmojiParserOptions +from semantic_release.commit_parser.scipy import ScipyParserOptions +from semantic_release.commit_parser.tag import TagParserOptions from semantic_release.const import DEFAULT_COMMIT_AUTHOR +from semantic_release.enums import LevelBump + +from tests.fixtures.repos import repo_with_no_tags_angular_commits +from tests.util import CustomParserOpts + +if TYPE_CHECKING: + from pathlib import Path + from typing import Any + + from tests.fixtures.example_project import ExProjectDir + + +@pytest.mark.parametrize( + "remote_config, expected_token", + [ + ({"type": HvcsClient.GITHUB.value}, EnvConfigVar(env="GH_TOKEN")), + ({"type": HvcsClient.GITLAB.value}, EnvConfigVar(env="GITLAB_TOKEN")), + ({"type": HvcsClient.GITEA.value}, EnvConfigVar(env="GITEA_TOKEN")), + ({}, EnvConfigVar(env="GH_TOKEN")), # default not provided -> means Github + ( + {"type": HvcsClient.GITHUB.value, "token": {"env": "CUSTOM_TOKEN"}}, + EnvConfigVar(env="CUSTOM_TOKEN"), + ), + ], +) +def test_load_hvcs_default_token( + remote_config: dict[str, Any], expected_token: EnvConfigVar +): + raw_config = RawConfig.model_validate( + { + "remote": remote_config, + } + ) + assert expected_token == raw_config.remote.token + + +@pytest.mark.parametrize("remote_config", [{"type": "nonexistent"}]) +def test_invalid_hvcs_type(remote_config: dict[str, Any]): + with pytest.raises(ValidationError) as excinfo: + RawConfig.model_validate( + { + "remote": remote_config, + } + ) + assert "remote.type" in str(excinfo.value) + + +@pytest.mark.parametrize( + "commit_parser, expected_parser_opts", + [ + ( + None, + RootModel(AngularParserOptions()).model_dump(), + ), # default not provided -> means angular + ("angular", RootModel(AngularParserOptions()).model_dump()), + ("emoji", RootModel(EmojiParserOptions()).model_dump()), + ("scipy", RootModel(ScipyParserOptions()).model_dump()), + ("tag", RootModel(TagParserOptions()).model_dump()), + ("tests.util:CustomParserWithNoOpts", {}), + ("tests.util:CustomParserWithOpts", RootModel(CustomParserOpts()).model_dump()), + ], +) +def test_load_default_parser_opts( + commit_parser: str | None, expected_parser_opts: dict[str, Any] +): + raw_config = RawConfig.model_validate( + # Since TOML does not support NoneTypes, we need to not include the key + {"commit_parser": commit_parser} if commit_parser else {} + ) + assert expected_parser_opts == raw_config.commit_parser_options + + +def test_load_user_defined_parser_opts(): + user_defined_opts = { + "allowed_tags": ["foo", "bar", "baz"], + "minor_tags": ["bar"], + "patch_tags": ["baz"], + "default_bump_level": LevelBump.PATCH.value, + } + raw_config = RawConfig.model_validate( + { + "commit_parser": "angular", + "commit_parser_options": user_defined_opts, + } + ) + assert user_defined_opts == raw_config.commit_parser_options + + +@pytest.mark.parametrize("commit_parser", [""]) +def test_invalid_commit_parser_value(commit_parser: str): + with pytest.raises(ValidationError) as excinfo: + RawConfig.model_validate( + { + "commit_parser": commit_parser, + } + ) + assert "commit_parser" in str(excinfo.value) + +def test_default_toml_config_valid(example_project_dir: ExProjectDir): + default_config_file = example_project_dir / "default.toml" -def test_default_toml_config_valid(example_project): - default_config_file = example_project / "default.toml" default_config_file.write_text( - tomlkit.dumps(RawConfig().model_dump(exclude_none=True)) + tomlkit.dumps(RawConfig().model_dump(mode="json", exclude_none=True)) ) written = default_config_file.read_text(encoding="utf-8") @@ -35,20 +143,22 @@ def test_default_toml_config_valid(example_project): ({"GIT_COMMIT_AUTHOR": "foo "}, "foo "), ], ) +@pytest.mark.usefixtures(repo_with_no_tags_angular_commits.__name__) def test_commit_author_configurable( - example_project, repo_with_no_tags_angular_commits, mock_env, expected_author + example_pyproject_toml: Path, + mock_env: dict[str, str], + expected_author: str, + change_to_ex_proj_dir: str, ): - pyproject_toml = example_project / "pyproject.toml" - content = tomlkit.loads(pyproject_toml.read_text(encoding="utf-8")).unwrap() + content = tomlkit.loads(example_pyproject_toml.read_text(encoding="utf-8")).unwrap() with mock.patch.dict("os.environ", mock_env): raw = RawConfig.model_validate(content) runtime = RuntimeContext.from_raw_config( raw=raw, - repo=repo_with_no_tags_angular_commits, global_cli_options=GlobalCommandLineOptions(), ) - assert ( + resulting_author = ( f"{runtime.commit_author.name} <{runtime.commit_author.email}>" - == expected_author ) + assert expected_author == resulting_author diff --git a/tests/unit/semantic_release/commit_parser/test_util.py b/tests/unit/semantic_release/commit_parser/test_util.py index 68bc65193..eed8117c8 100644 --- a/tests/unit/semantic_release/commit_parser/test_util.py +++ b/tests/unit/semantic_release/commit_parser/test_util.py @@ -7,11 +7,19 @@ "text, expected", [ ("", []), - ("\n\n \n\n \n", [" ", " "]), + ("\n\n \n\n \n", []), # Unix (LF) - empty lines + ("\r\n\r\n \r\n\r\n \n", []), # Windows (CRLF) - empty lines + ("\n\nA\n\nB\n", ["A", "B"]), # Unix (LF) + ("\r\n\r\nA\r\n\r\nB\n", ["A", "B"]), # Windows (CRLF) ( "Long\nexplanation\n\nfull of interesting\ndetails", ["Long explanation", "full of interesting details"], ), + ( + # Windows uses CRLF + "Long\r\nexplanation\r\n\r\nfull of interesting\r\ndetails", + ["Long explanation", "full of interesting details"], + ), ], ) def test_parse_paragraphs(text, expected): diff --git a/tests/unit/semantic_release/hvcs/test_bitbucket.py b/tests/unit/semantic_release/hvcs/test_bitbucket.py new file mode 100644 index 000000000..2b76312ce --- /dev/null +++ b/tests/unit/semantic_release/hvcs/test_bitbucket.py @@ -0,0 +1,169 @@ +import os +from unittest import mock + +import pytest +from requests import Session + +from semantic_release.hvcs.bitbucket import Bitbucket + +from tests.const import EXAMPLE_REPO_NAME, EXAMPLE_REPO_OWNER + + +@pytest.fixture +def default_bitbucket_client(): + remote_url = f"git@bitbucket.org:{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}.git" + return Bitbucket(remote_url=remote_url) + + +@pytest.mark.parametrize( + ( + "patched_os_environ, hvcs_domain, hvcs_api_domain, " + "expected_hvcs_domain, expected_hvcs_api_domain" + ), + [({}, None, None, Bitbucket.DEFAULT_DOMAIN, Bitbucket.DEFAULT_API_DOMAIN)], +) +@pytest.mark.parametrize( + "remote_url", + [ + f"git@bitbucket.org:{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}.git", + f"https://bitbucket.org/{EXAMPLE_REPO_OWNER}/{EXAMPLE_REPO_NAME}.git", + ], +) +@pytest.mark.parametrize("token", ("abc123", None)) +def test_bitbucket_client_init( + patched_os_environ, + hvcs_domain, + hvcs_api_domain, + expected_hvcs_domain, + expected_hvcs_api_domain, + remote_url, + token, +): + with mock.patch.dict(os.environ, patched_os_environ, clear=True): + client = Bitbucket( + remote_url=remote_url, + hvcs_domain=hvcs_domain, + hvcs_api_domain=hvcs_api_domain, + token=token, + ) + + assert client.hvcs_domain == expected_hvcs_domain + assert client.hvcs_api_domain == expected_hvcs_api_domain + assert client.api_url == f"https://{client.hvcs_api_domain}/2.0" + assert client.token == token + assert client._remote_url == remote_url + assert hasattr(client, "session") + assert isinstance(getattr(client, "session", None), Session) + + +@pytest.mark.parametrize( + "patched_os_environ, expected_owner, expected_name", + [ + ({}, None, None), + ({"BITBUCKET_REPO_FULL_NAME": "path/to/repo/foo"}, "path/to/repo", "foo"), + ], +) +def test_bitbucket_get_repository_owner_and_name( + default_bitbucket_client, patched_os_environ, expected_owner, expected_name +): + with mock.patch.dict(os.environ, patched_os_environ, clear=True): + if expected_owner is None and expected_name is None: + assert ( + default_bitbucket_client._get_repository_owner_and_name() + == super( + Bitbucket, default_bitbucket_client + )._get_repository_owner_and_name() + ) + else: + assert default_bitbucket_client._get_repository_owner_and_name() == ( + expected_owner, + expected_name, + ) + + +def test_compare_url(default_bitbucket_client): + assert default_bitbucket_client.compare_url( + from_rev="revA", to_rev="revB" + ) == "https://{domain}/{owner}/{repo}/branches/compare/revA%0DrevB".format( + domain=default_bitbucket_client.hvcs_domain, + owner=default_bitbucket_client.owner, + repo=default_bitbucket_client.repo_name, + ) + + +@pytest.mark.parametrize( + "patched_os_environ, use_token, token, _remote_url, expected", + [ + ( + {"BITBUCKET_USER": "foo"}, + False, + "", + "git@bitbucket.org:custom/example.git", + "git@bitbucket.org:custom/example.git", + ), + ( + {}, + False, + "aabbcc", + "git@bitbucket.org:custom/example.git", + "git@bitbucket.org:custom/example.git", + ), + ( + {}, + True, + "aabbcc", + "git@bitbucket.org:custom/example.git", + "https://x-token-auth:aabbcc@bitbucket.org/custom/example.git", + ), + ( + {"BITBUCKET_USER": "foo"}, + False, + "aabbcc", + "git@bitbucket.org:custom/example.git", + "git@bitbucket.org:custom/example.git", + ), + ( + {"BITBUCKET_USER": "foo"}, + True, + "aabbcc", + "git@bitbucket.org:custom/example.git", + "https://foo:aabbcc@bitbucket.org/custom/example.git", + ), + ], +) +def test_remote_url( + patched_os_environ, + use_token, + token, + _remote_url, # noqa: PT019 + expected, + default_bitbucket_client, +): + with mock.patch.dict(os.environ, patched_os_environ, clear=True): + default_bitbucket_client._remote_url = _remote_url + default_bitbucket_client.token = token + assert default_bitbucket_client.remote_url(use_token=use_token) == expected + + +def test_commit_hash_url(default_bitbucket_client): + sha = "244f7e11bcb1e1ce097db61594056bc2a32189a0" + assert default_bitbucket_client.commit_hash_url( + sha + ) == "https://{domain}/{owner}/{repo}/commits/{sha}".format( + domain=default_bitbucket_client.hvcs_domain, + owner=default_bitbucket_client.owner, + repo=default_bitbucket_client.repo_name, + sha=sha, + ) + + +@pytest.mark.parametrize("pr_number", (420, "420")) +def test_pull_request_url(default_bitbucket_client, pr_number): + assert default_bitbucket_client.pull_request_url( + pr_number=pr_number + ) == "https://{domain}/{owner}/{repo}/pull-requests/{pr_number}".format( + domain=default_bitbucket_client.hvcs_domain, + owner=default_bitbucket_client.owner, + repo=default_bitbucket_client.repo_name, + pr_number=pr_number, + ) diff --git a/tests/unit/semantic_release/hvcs/test_gitea.py b/tests/unit/semantic_release/hvcs/test_gitea.py index 13441b4c5..9c47d7550 100644 --- a/tests/unit/semantic_release/hvcs/test_gitea.py +++ b/tests/unit/semantic_release/hvcs/test_gitea.py @@ -1,7 +1,10 @@ +from __future__ import annotations + import base64 import glob import os import re +from typing import TYPE_CHECKING from unittest import mock from urllib.parse import urlencode @@ -15,6 +18,9 @@ from tests.const import EXAMPLE_REPO_NAME, EXAMPLE_REPO_OWNER, RELEASE_NOTES from tests.util import netrc_file +if TYPE_CHECKING: + from pathlib import Path + @pytest.fixture def default_gitea_client(): @@ -508,7 +514,11 @@ def test_create_or_update_release_when_create_fails_and_no_release_for_tag( @pytest.mark.parametrize("status_code", (200, 201)) @pytest.mark.parametrize("mock_release_id", range(3)) def test_upload_asset_succeeds( - default_gitea_client, example_changelog_md, status_code, mock_release_id + init_example_project: None, + default_gitea_client: Gitea, + example_changelog_md: Path, + status_code: int, + mock_release_id: int, ): urlparams = {"name": example_changelog_md.name} with requests_mock.Mocker(session=default_gitea_client.session) as m: @@ -539,7 +549,11 @@ def test_upload_asset_succeeds( @pytest.mark.parametrize("status_code", (400, 500, 503)) @pytest.mark.parametrize("mock_release_id", range(3)) def test_upload_asset_fails( - default_gitea_client, example_changelog_md, status_code, mock_release_id + init_example_project: None, + default_gitea_client: Gitea, + example_changelog_md: Path, + status_code: int, + mock_release_id: int, ): urlparams = {"name": example_changelog_md.name} with requests_mock.Mocker(session=default_gitea_client.session) as m: @@ -606,9 +620,7 @@ def test_upload_dists_when_release_id_found( default_gitea_client, "upload_asset" ) as mock_upload_asset, mock.patch.object( glob, "glob" - ) as mock_glob_glob, mock.patch.object( - os.path, "isfile" - ) as mock_os_path_isfile: + ) as mock_glob_glob, mock.patch.object(os.path, "isfile") as mock_os_path_isfile: # Skip check as the files don't exist in filesystem mock_os_path_isfile.return_value = True diff --git a/tests/unit/semantic_release/hvcs/test_github.py b/tests/unit/semantic_release/hvcs/test_github.py index 97a7759f5..f682505a8 100644 --- a/tests/unit/semantic_release/hvcs/test_github.py +++ b/tests/unit/semantic_release/hvcs/test_github.py @@ -1,8 +1,11 @@ +from __future__ import annotations + import base64 import glob import mimetypes import os import re +from typing import TYPE_CHECKING from unittest import mock from urllib.parse import urlencode @@ -16,6 +19,9 @@ from tests.const import EXAMPLE_REPO_NAME, EXAMPLE_REPO_OWNER, RELEASE_NOTES from tests.util import netrc_file +if TYPE_CHECKING: + from pathlib import Path + @pytest.fixture def default_gh_client(): @@ -538,13 +544,14 @@ def test_asset_upload_url(default_gh_client): } with requests_mock.Mocker(session=default_gh_client.session) as m: m.register_uri("GET", github_api_matcher, json=resp_payload, status_code=200) - assert default_gh_client.asset_upload_url( - release_id - ) == "https://{domain}/repos/{owner}/{repo}/releases/{release_id}/assets".format( - domain=default_gh_client.DEFAULT_UPLOAD_DOMAIN, - owner=default_gh_client.owner, - repo=default_gh_client.repo_name, - release_id=release_id, + assert ( + default_gh_client.asset_upload_url(release_id) + == "https://{domain}/repos/{owner}/{repo}/releases/{release_id}/assets".format( + domain=default_gh_client.DEFAULT_UPLOAD_DOMAIN, + owner=default_gh_client.owner, + repo=default_gh_client.repo_name, + release_id=release_id, + ) ) assert m.called assert len(m.request_history) == 1 @@ -563,7 +570,11 @@ def test_asset_upload_url(default_gh_client): @pytest.mark.parametrize("status_code", (200, 201)) @pytest.mark.parametrize("mock_release_id", range(3)) def test_upload_asset_succeeds( - default_gh_client, example_changelog_md, status_code, mock_release_id + init_example_project: None, + default_gh_client: Github, + example_changelog_md: Path, + status_code: int, + mock_release_id: int, ): label = "abc123" urlparams = {"name": example_changelog_md.name, "label": label} @@ -617,7 +628,11 @@ def test_upload_asset_succeeds( @pytest.mark.parametrize("status_code", (400, 404, 429, 500, 503)) @pytest.mark.parametrize("mock_release_id", range(3)) def test_upload_asset_fails( - default_gh_client, example_changelog_md, status_code, mock_release_id + init_example_project: None, + default_gh_client: Github, + example_changelog_md: Path, + status_code: int, + mock_release_id: int, ): label = "abc123" urlparams = {"name": example_changelog_md.name, "label": label} @@ -704,9 +719,7 @@ def test_upload_dists_when_release_id_found( default_gh_client, "upload_asset" ) as mock_upload_asset, mock.patch.object( glob, "glob" - ) as mock_glob_glob, mock.patch.object( - os.path, "isfile" - ) as mock_os_path_isfile: + ) as mock_glob_glob, mock.patch.object(os.path, "isfile") as mock_os_path_isfile: # Skip check as the filenames deliberately don't exists for testing mock_os_path_isfile.return_value = True diff --git a/tests/unit/semantic_release/test_helpers.py b/tests/unit/semantic_release/test_helpers.py new file mode 100644 index 000000000..e7db7adfd --- /dev/null +++ b/tests/unit/semantic_release/test_helpers.py @@ -0,0 +1,133 @@ +import pytest + +from semantic_release.helpers import ParsedGitUrl, parse_git_url + + +@pytest.mark.parametrize( + "url, expected", + [ + ( + "http://git.mycompany.com/username/myproject.git", + ParsedGitUrl("http", "git.mycompany.com", "username", "myproject"), + ), + ( + "http://subsubdomain.subdomain.company-net.com/username/myproject.git", + ParsedGitUrl( + "http", + "subsubdomain.subdomain.company-net.com", + "username", + "myproject", + ), + ), + ( + "https://github.com/username/myproject.git", + ParsedGitUrl("https", "github.com", "username", "myproject"), + ), + ( + "https://gitlab.com/group/subgroup/myproject.git", + ParsedGitUrl("https", "gitlab.com", "group/subgroup", "myproject"), + ), + ( + "https://git.mycompany.com:4443/username/myproject.git", + ParsedGitUrl("https", "git.mycompany.com:4443", "username", "myproject"), + ), + ( + "https://subsubdomain.subdomain.company-net.com/username/myproject.git", + ParsedGitUrl( + "https", + "subsubdomain.subdomain.company-net.com", + "username", + "myproject", + ), + ), + ( + "git://host.xz/path/to/repo.git/", + ParsedGitUrl("git", "host.xz", "path/to", "repo"), + ), + ( + "git://host.xz:9418/path/to/repo.git/", + ParsedGitUrl("git", "host.xz:9418", "path/to", "repo"), + ), + ( + "git@github.com:username/myproject.git", + ParsedGitUrl("ssh", "git@github.com", "username", "myproject"), + ), + ( + "git@subsubdomain.subdomain.company-net.com:username/myproject.git", + ParsedGitUrl( + "ssh", + "git@subsubdomain.subdomain.company-net.com", + "username", + "myproject", + ), + ), + ( + "first.last_test-1@subsubdomain.subdomain.company-net.com:username/myproject.git", + ParsedGitUrl( + "ssh", + "first.last_test-1@subsubdomain.subdomain.company-net.com", + "username", + "myproject", + ), + ), + ( + "ssh://git@github.com:3759/myproject.git", + ParsedGitUrl("ssh", "git@github.com", "3759", "myproject"), + ), + ( + "ssh://git@github.com:username/myproject.git", + ParsedGitUrl("ssh", "git@github.com", "username", "myproject"), + ), + ( + "ssh://git@bitbucket.org:7999/username/myproject.git", + ParsedGitUrl("ssh", "git@bitbucket.org:7999", "username", "myproject"), + ), + ( + "ssh://git@subsubdomain.subdomain.company-net.com:username/myproject.git", + ParsedGitUrl( + "ssh", + "git@subsubdomain.subdomain.company-net.com", + "username", + "myproject", + ), + ), + ( + "git+ssh://git@github.com:username/myproject.git", + ParsedGitUrl("ssh", "git@github.com", "username", "myproject"), + ), + ( + "/Users/username/dev/remote/myproject.git", + ParsedGitUrl("file", "", "Users/username/dev/remote", "myproject"), + ), + ( + "file:///Users/username/dev/remote/myproject.git", + ParsedGitUrl("file", "", "Users/username/dev/remote", "myproject"), + ), + ( + "C:/Users/username/dev/remote/myproject.git", + ParsedGitUrl("file", "", "C:/Users/username/dev/remote", "myproject"), + ), + ( + "file:///C:/Users/username/dev/remote/myproject.git", + ParsedGitUrl("file", "", "C:/Users/username/dev/remote", "myproject"), + ), + ], +) +def test_parse_valid_git_urls(url: str, expected: ParsedGitUrl): + """Test that a valid given git remote url is parsed correctly.""" + assert expected == parse_git_url(url) + + +@pytest.mark.parametrize( + "url", + [ + "icmp://git", + "abcdefghijklmnop.git", + "../relative/path/to/repo.git", + "http://domain/project.git", + ], +) +def test_parse_invalid_git_urls(url: str): + """Test that an invalid git remote url throws a ValueError.""" + with pytest.raises(ValueError): + parse_git_url(url) diff --git a/tests/unit/semantic_release/version/test_algorithm.py b/tests/unit/semantic_release/version/test_algorithm.py index 00ec35336..9db7dd12f 100644 --- a/tests/unit/semantic_release/version/test_algorithm.py +++ b/tests/unit/semantic_release/version/test_algorithm.py @@ -1,12 +1,78 @@ import pytest -from git import Repo +from git import Commit, Repo, TagReference from semantic_release.enums import LevelBump -from semantic_release.version.algorithm import _increment_version, tags_and_versions +from semantic_release.version.algorithm import ( + _bfs_for_latest_version_in_history, + _increment_version, + tags_and_versions, +) from semantic_release.version.translator import VersionTranslator from semantic_release.version.version import Version +def test_bfs_for_latest_version_in_history(): + # Setup fake git graph + """ + * merge commit 6 (start) + |\ + | * commit 5 + | * commit 4 + |/ + * commit 3 + * commit 2 + * commit 1 + * v1.0.0 + """ + repo = Repo() + expected_version = Version.parse("1.0.0") + v1_commit = Commit(repo, binsha=b"0" * 20) + + class TagReferenceOverride(TagReference): + commit = v1_commit # mocking the commit property + + v1_tag = TagReferenceOverride(repo, "refs/tags/v1.0.0", check_path=False) + + trunk = Commit( + repo, + binsha=b"3" * 20, + parents=[ + Commit( + repo, + binsha=b"2" * 20, + parents=[ + Commit(repo, binsha=b"1" * 20, parents=[v1_commit]), + ], + ), + ], + ) + start_commit = Commit( + repo, + binsha=b"6" * 20, + parents=[ + trunk, + Commit( + repo, + binsha=b"5" * 20, + parents=[ + Commit(repo, binsha=b"4" * 20, parents=[trunk]), + ], + ), + ], + ) + + # Execute + actual = _bfs_for_latest_version_in_history( + start_commit, + [ + (v1_tag, expected_version), + ], + ) + + # Verify + assert expected_version == actual + + @pytest.mark.parametrize( "tags, sorted_tags", [ @@ -128,203 +194,204 @@ def test_tags_and_versions_ignores_invalid_tags_as_versions( # NOTE: level_bump != LevelBump.NO_RELEASE, we return early in the # algorithm to discount this case ( - Version.parse("1.0.0"), - Version.parse("1.0.0"), - Version.parse("1.0.0"), + "1.0.0", + "1.0.0", + "1.0.0", LevelBump.PRERELEASE_REVISION, False, "rc", - Version.parse("1.0.0-rc.1"), + "1.0.0-rc.1", ), ( - Version.parse("1.0.0"), - Version.parse("1.0.0"), - Version.parse("1.0.0"), + "1.0.0", + "1.0.0", + "1.0.0", LevelBump.PRERELEASE_REVISION, True, "rc", - Version.parse("1.0.0-rc.1"), + "1.0.0-rc.1", ), ( - Version.parse("1.0.0"), - Version.parse("1.0.0"), - Version.parse("1.0.0"), + "1.0.0", + "1.0.0", + "1.0.0", LevelBump.PATCH, False, "rc", - Version.parse("1.0.1"), + "1.0.1", ), ( - Version.parse("1.0.0"), - Version.parse("1.0.0"), - Version.parse("1.0.0"), + "1.0.0", + "1.0.0", + "1.0.0", LevelBump.PATCH, True, "rc", - Version.parse("1.0.1-rc.1"), + "1.0.1-rc.1", ), ( - Version.parse("1.0.0"), - Version.parse("1.0.0"), - Version.parse("1.0.0"), + "1.0.0", + "1.0.0", + "1.0.0", LevelBump.MINOR, False, "rc", - Version.parse("1.1.0"), + "1.1.0", ), ( - Version.parse("1.0.0"), - Version.parse("1.0.0"), - Version.parse("1.0.0"), + "1.0.0", + "1.0.0", + "1.0.0", LevelBump.MINOR, True, "rc", - Version.parse("1.1.0-rc.1"), + "1.1.0-rc.1", ), ( - Version.parse("1.0.0"), - Version.parse("1.0.0"), - Version.parse("1.0.0"), + "1.0.0", + "1.0.0", + "1.0.0", LevelBump.MAJOR, False, "rc", - Version.parse("2.0.0"), + "2.0.0", ), ( - Version.parse("1.0.0"), - Version.parse("1.0.0"), - Version.parse("1.0.0"), + "1.0.0", + "1.0.0", + "1.0.0", LevelBump.MAJOR, True, "rc", - Version.parse("2.0.0-rc.1"), + "2.0.0-rc.1", ), ( - Version.parse("1.2.4-rc.1"), - Version.parse("1.2.0"), - Version.parse("1.2.3"), + "1.2.4-rc.1", + "1.2.0", + "1.2.3", LevelBump.PATCH, False, "rc", - Version.parse("1.2.4"), + "1.2.4", ), ( - Version.parse("1.2.4-rc.1"), - Version.parse("1.2.0"), - Version.parse("1.2.3"), + "1.2.4-rc.1", + "1.2.0", + "1.2.3", LevelBump.PATCH, True, "rc", - Version.parse("1.2.4-rc.2"), + "1.2.4-rc.2", ), ( - Version.parse("1.2.4-rc.1"), - Version.parse("1.2.0"), - Version.parse("1.2.3"), + "1.2.4-rc.1", + "1.2.0", + "1.2.3", LevelBump.MINOR, False, "rc", - Version.parse("1.3.0"), + "1.3.0", ), ( - Version.parse("1.2.4-rc.1"), - Version.parse("1.2.0"), - Version.parse("1.2.3"), + "1.2.4-rc.1", + "1.2.0", + "1.2.3", LevelBump.MINOR, True, "rc", - Version.parse("1.3.0-rc.1"), + "1.3.0-rc.1", ), ( - Version.parse("1.2.4-rc.1"), - Version.parse("1.2.0"), - Version.parse("1.2.3"), + "1.2.4-rc.1", + "1.2.0", + "1.2.3", LevelBump.MAJOR, False, "rc", - Version.parse("2.0.0"), + "2.0.0", ), ( - Version.parse("1.2.4-rc.1"), - Version.parse("1.2.0"), - Version.parse("1.2.3"), + "1.2.4-rc.1", + "1.2.0", + "1.2.3", LevelBump.MAJOR, True, "rc", - Version.parse("2.0.0-rc.1"), + "2.0.0-rc.1", ), ( - Version.parse("2.0.0-rc.1"), - Version.parse("1.22.0"), - Version.parse("1.19.3"), + "2.0.0-rc.1", + "1.22.0", + "1.19.3", LevelBump.PATCH, False, "rc", - Version.parse("2.0.0"), + "2.0.0", ), ( - Version.parse("2.0.0-rc.1"), - Version.parse("1.22.0"), - Version.parse("1.19.3"), + "2.0.0-rc.1", + "1.22.0", + "1.19.3", LevelBump.PATCH, True, "rc", - Version.parse("2.0.0-rc.2"), + "2.0.0-rc.2", ), ( - Version.parse("2.0.0-rc.1"), - Version.parse("1.22.0"), - Version.parse("1.19.3"), + "2.0.0-rc.1", + "1.22.0", + "1.19.3", LevelBump.MINOR, False, "rc", - Version.parse("2.0.0"), + "2.0.0", ), ( - Version.parse("2.0.0-rc.1"), - Version.parse("1.22.0"), - Version.parse("1.19.3"), + "2.0.0-rc.1", + "1.22.0", + "1.19.3", LevelBump.MINOR, True, "rc", - Version.parse("2.0.0-rc.2"), + "2.0.0-rc.2", ), ( - Version.parse("2.0.0-rc.1"), - Version.parse("1.22.0"), - Version.parse("1.19.3"), + "2.0.0-rc.1", + "1.22.0", + "1.19.3", LevelBump.MAJOR, False, "rc", - Version.parse("2.0.0"), + "2.0.0", ), ( - Version.parse("2.0.0-rc.1"), - Version.parse("1.22.0"), - Version.parse("1.19.3"), + "2.0.0-rc.1", + "1.22.0", + "1.19.3", LevelBump.MAJOR, True, "rc", - Version.parse("2.0.0-rc.2"), + "2.0.0-rc.2", ), ], ) def test_increment_version_no_major_on_zero( - latest_version, - latest_full_version, - latest_full_version_in_history, - level_bump, - prerelease, - prerelease_token, - expected_version, + latest_version: str, + latest_full_version: str, + latest_full_version_in_history: str, + level_bump: LevelBump, + prerelease: bool, + prerelease_token: str, + expected_version: str, ): actual = _increment_version( - latest_version=latest_version, - latest_full_version=latest_full_version, - latest_full_version_in_history=latest_full_version_in_history, + latest_version=Version.parse(latest_version), + latest_full_version=Version.parse(latest_full_version), + latest_full_version_in_history=Version.parse(latest_full_version_in_history), level_bump=level_bump, prerelease=prerelease, prerelease_token=prerelease_token, major_on_zero=False, + allow_zero_version=True, ) - assert actual == expected_version + assert expected_version == str(actual) diff --git a/tests/unit/semantic_release/version/test_declaration.py b/tests/unit/semantic_release/version/test_declaration.py index 9d2b1e85e..ef10073e2 100644 --- a/tests/unit/semantic_release/version/test_declaration.py +++ b/tests/unit/semantic_release/version/test_declaration.py @@ -15,7 +15,8 @@ from tests.const import EXAMPLE_PROJECT_VERSION -def test_pyproject_toml_version_found(example_pyproject_toml): +@pytest.mark.usefixtures("init_example_project") +def test_pyproject_toml_version_found(example_pyproject_toml: Path): decl = TomlVersionDeclaration( example_pyproject_toml.resolve(), "tool.poetry.version" ) @@ -24,7 +25,8 @@ def test_pyproject_toml_version_found(example_pyproject_toml): assert versions.pop() == Version.parse(EXAMPLE_PROJECT_VERSION) -def test_setup_cfg_version_found(example_setup_cfg): +@pytest.mark.usefixtures("init_example_project") +def test_setup_cfg_version_found(example_setup_cfg: Path): decl = PatternVersionDeclaration( example_setup_cfg.resolve(), r"^version *= *(?P.*)$" ) @@ -48,6 +50,7 @@ def test_setup_cfg_version_found(example_setup_cfg): ), ], ) +@pytest.mark.usefixtures("init_example_project") def test_version_replace(decl_cls, config_file, search_text): new_version = Version(1, 0, 0) decl = decl_cls(config_file.resolve(), search_text) diff --git a/tests/util.py b/tests/util.py index 699e4873a..2d8774f56 100644 --- a/tests/util.py +++ b/tests/util.py @@ -2,21 +2,30 @@ import os import secrets +import shutil +import stat import string -from contextlib import contextmanager +from contextlib import contextmanager, suppress from tempfile import NamedTemporaryFile -from typing import TYPE_CHECKING, Any, Iterable, TypeVar +from typing import TYPE_CHECKING, Tuple + +from pydantic.dataclasses import dataclass from semantic_release.changelog.context import make_changelog_context from semantic_release.changelog.release_history import ReleaseHistory -from semantic_release.cli.commands import main +from semantic_release.cli import config as cliConfigModule +from semantic_release.commit_parser._base import CommitParser, ParserOptions +from semantic_release.commit_parser.token import ParseResult if TYPE_CHECKING: import filecmp + from pathlib import Path + from typing import Any, Generator, Iterable, TypeVar try: from typing import TypeAlias except ImportError: + # for python 3.8 and 3.9 from typing_extensions import TypeAlias from unittest.mock import MagicMock @@ -25,7 +34,46 @@ from semantic_release.cli.config import RuntimeContext - GitCommandWrapperType: TypeAlias = main.Repo.GitCommandWrapperType + _R = TypeVar("_R") + + GitCommandWrapperType: TypeAlias = cliConfigModule.Repo.GitCommandWrapperType + + +def copy_dir_tree(src_dir: Path | str, dst_dir: Path | str) -> None: + """Compatibility wrapper for shutil.copytree""" + # python3.8+ + shutil.copytree( + src=str(src_dir), + dst=str(dst_dir), + dirs_exist_ok=True, + ) + + +def remove_dir_tree(directory: Path | str = ".", force: bool = False) -> None: + """ + Compatibility wrapper for shutil.rmtree + + Helpful for deleting directories with .git/* files, which usually have some + read-only permissions + """ + + def on_read_only_error(_func, path, _exc_info): + 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): + shutil.rmtree(str(directory), onerror=on_read_only_error if force else None) + + +@contextmanager +def temporary_working_directory(directory: Path | str) -> Generator[None, None, None]: + cwd = os.getcwd() + os.chdir(str(directory)) + try: + yield + finally: + os.chdir(cwd) def shortuid(length: int = 8) -> str: @@ -58,14 +106,16 @@ def netrc_file(machine: str) -> NamedTemporaryFile: def flatten_dircmp(dcmp: filecmp.dircmp) -> list[str]: - return dcmp.diff_files + [ - os.sep.join((directory, file)) - for directory, cmp in dcmp.subdirs.items() - for file in flatten_dircmp(cmp) - ] - - -_R = TypeVar("_R") + return ( + dcmp.diff_files + + dcmp.left_only + + dcmp.right_only + + [ + os.sep.join((directory, file)) + for directory, cmp in dcmp.subdirs.items() + for file in flatten_dircmp(cmp) + ] + ) def xdist_sort_hack(it: Iterable[_R]) -> Iterable[_R]: @@ -126,7 +176,7 @@ def prepare_mocked_git_command_wrapper_type( >>> mocked_push.assert_called_once() """ - class MockGitCommandWrapperType(main.Repo.GitCommandWrapperType): + class MockGitCommandWrapperType(cliConfigModule.Repo.GitCommandWrapperType): def __getattr__(self, name: str) -> Any: try: return object.__getattribute__(self, f"mocked_{name}") @@ -136,3 +186,16 @@ def __getattr__(self, name: str) -> Any: for name, method in mocked_methods.items(): setattr(MockGitCommandWrapperType, f"mocked_{name}", method) return MockGitCommandWrapperType + + +class CustomParserWithNoOpts(CommitParser[ParseResult, ParserOptions]): + parser_options = ParserOptions + + +@dataclass +class CustomParserOpts(ParserOptions): + allowed_tags: Tuple[str, ...] = ("new", "custom") # noqa: UP006 + + +class CustomParserWithOpts(CommitParser[ParseResult, CustomParserOpts]): + parser_options = CustomParserOpts