diff --git a/.codacy.yaml b/.codacy.yaml new file mode 100644 index 0000000..cab9740 --- /dev/null +++ b/.codacy.yaml @@ -0,0 +1,3 @@ +exclude_paths: + - 'tests/**' + - 'docs/**' diff --git a/.github/.codecov.yml b/.github/.codecov.yml new file mode 100644 index 0000000..85b43f3 --- /dev/null +++ b/.github/.codecov.yml @@ -0,0 +1,7 @@ +coverage: + status: + project: + default: + # minimum of 97% (real 96%) + target: 97% + threshold: 1% diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..5d29c85 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @woile @Lee-W @noirbizarre diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..68203f2 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +open_collective: commitizen-tools +github: commitizen-tools diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..10d782d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,61 @@ +name: ๐Ÿ›  Bug report +description: Create a report to help us improve +title: Good bug title tells us about precise symptom, not about the root cause. +labels: [bug] +body: + - type: textarea + id: description + attributes: + label: Description + description: | + A clear and concise description of what the bug is + validations: + required: true + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps to reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. Run ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + id: current-behavior + attributes: + label: Current behavior + description: What happens actually so you think this is a bug. + validations: + required: true + - type: textarea + id: desired-behavior + attributes: + label: Desired behavior + description: | + A clear and concise description of what you expected to happen. + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: | + If applicable, add screenshots to help explain your problem. + - type: textarea + id: environment + attributes: + label: Environment + description: | + For older commitizen versions, please include the output of the following commands manually + placeholder: | + - commitizen version: `cz version` + - python version: `python --version` + - operating system: `python3 -c "import platform; print(platform.system())"` + + ```bash + cz version --report + ``` + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..884fe16 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +# Configuration: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository + +blank_issues_enabled: false +contact_links: + - name: Security Contact + about: Please report security vulnerabilities to santiwilly@gmail.com diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 0000000..51d378b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,29 @@ +name: ๐Ÿ“– Documentation +description: Suggest an improvement for the documentation of this project +title: Content to be added or fixed +labels: [documentation] +body: + - type: checkboxes + id: type + attributes: + label: Type + options: + - label: Content inaccurate + - label: Content missing + - label: Typo + - type: input + id: url + attributes: + label: URL + placeholder: | + URL to the code we did not clearly describe or the document page where the content is inaccurate + validations: + required: true + - type: textarea + id: description + attributes: + label: Description + description: | + A clear and concise description of what content should be added or fixed + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..7d67eb1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,31 @@ +name: ๐Ÿš€ Feature request +description: Suggest an idea for this project +title: "" +labels: [feature] +body: + - type: textarea + id: description + attributes: + label: Description + description: | + A clear and concise description for us to know your idea. + validations: + required: true + - type: textarea + id: possible-solution + attributes: + label: Possible Solution + description: | + A clear and concise description of what you want to happen. + - type: textarea + id: additional-context + attributes: + label: Additional context + description: | + Add any other context or screenshots about the feature request here. + - type: textarea + id: related-issue + attributes: + label: Additional context + description: | + If applicable, add link to existing issue also help us know better. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..82fb674 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: + - + # Maintain dependencies for GitHub Actions + package-ecosystem: github-actions + directory: / + schedule: + interval: daily + labels: + - dependencies + commit-message: + prefix: "ci" + include: "scope" + - + # Maintain python dependencies + package-ecosystem: pip + directory: / + schedule: + interval: daily + labels: + - dependencies + commit-message: + prefix: "build" + include: "scope" diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..5f3f5b8 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,3 @@ +'pr-status: wait-for-review': +- changed-files: + - any-glob-to-any-file: '**' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..0064604 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,29 @@ + + +## Description + + + +## Checklist + +- [ ] Add test cases to all the changes you introduce +- [ ] Run `poetry all` locally to ensure this change passes linter check and test +- [ ] Test the changes on the local machine manually +- [ ] Update the documentation for the changes + +## Expected behavior + + + +## Steps to Test This Pull Request + + + +## Additional context + diff --git a/.github/workflows/bumpversion.yml b/.github/workflows/bumpversion.yml new file mode 100644 index 0000000..ed0c8cf --- /dev/null +++ b/.github/workflows/bumpversion.yml @@ -0,0 +1,29 @@ +name: Bump version + +on: + push: + branches: + - master + +jobs: + bump-version: + if: "!startsWith(github.event.head_commit.message, 'bump:')" + runs-on: ubuntu-latest + name: "Bump version and create changelog with commitizen" + steps: + - name: Check out + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}" + - name: Create bump and changelog + uses: commitizen-tools/commitizen-action@master + with: + github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + changelog_increment_filename: body.md + - name: Release + uses: ncipollo/release-action@v1 + with: + tag: v${{ env.REVISION }} + bodyFile: "body.md" + skipIfReleaseExists: true diff --git a/.github/workflows/docspublish.yml b/.github/workflows/docspublish.yml new file mode 100644 index 0000000..a871d3c --- /dev/null +++ b/.github/workflows/docspublish.yml @@ -0,0 +1,78 @@ +name: Publish documentation + +on: + push: + branches: + - master + +jobs: + update-cli-screenshots: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install -U pip poetry poethepoet + poetry --version + poetry install --only main,script + - name: Update CLI screenshots + run: | + poetry doc:screenshots + - name: Commit and push updated CLI screenshots + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git add docs/images/cli_help + + if [[ -n "$(git status --porcelain)" ]]; then + git commit -m "docs(cli/screenshots): update CLI screenshots" -m "[skip ci]" + git push + else + echo "No changes to commit. Skipping." + fi + + publish-documentation: + runs-on: ubuntu-latest + needs: update-cli-screenshots + steps: + - uses: actions/checkout@v4 + with: + token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}" + fetch-depth: 0 + - name: Pull latest changes + run: | + git pull origin master + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install -U pip poetry poethepoet + poetry --version + poetry install --no-root --only documentation + - name: Build docs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + poetry doc:build + - name: Generate Sponsors ๐Ÿ’– + uses: JamesIves/github-sponsors-readme-action@v1 + with: + token: ${{ secrets.PERSONAL_ACCESS_TOKEN_FOR_ORG }} + file: "docs/README.md" + - name: Push doc to Github Page + uses: peaceiris/actions-gh-pages@v4 + with: + personal_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + publish_branch: gh-pages + publish_dir: ./site + user_name: "github-actions[bot]" + user_email: "github-actions[bot]@users.noreply.github.com" diff --git a/.github/workflows/homebrewpublish.yml b/.github/workflows/homebrewpublish.yml new file mode 100644 index 0000000..443a13b --- /dev/null +++ b/.github/workflows/homebrewpublish.yml @@ -0,0 +1,32 @@ +name: Publish to Homebrew + +on: + workflow_run: + workflows: ["Upload Python Package"] + types: + - completed + +jobs: + deploy: + runs-on: macos-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install -U commitizen + - name: Set Project version env variable + run: | + echo "project_version=$(cz version --project)" >> $GITHUB_ENV + - name: Update Homebrew formula + uses: dawidd6/action-homebrew-bump-formula@v4 + with: + token: ${{secrets.PERSONAL_ACCESS_TOKEN}} + formula: commitizen + tag: v${{ env.project_version }} + force: true diff --git a/.github/workflows/label_issues.yml b/.github/workflows/label_issues.yml new file mode 100644 index 0000000..45ca450 --- /dev/null +++ b/.github/workflows/label_issues.yml @@ -0,0 +1,23 @@ +name: Label issues + +on: + issues: + types: + - opened + - reopened + +jobs: + label-issue: + permissions: + issues: write + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['issue-status: needs-triage'] + }) diff --git a/.github/workflows/label_pr.yml b/.github/workflows/label_pr.yml new file mode 100644 index 0000000..b409c8b --- /dev/null +++ b/.github/workflows/label_pr.yml @@ -0,0 +1,19 @@ +name: "Label Pull Request" +on: +- pull_request_target + +jobs: + label-pr: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + .github/labeler.yml + sparse-checkout-cone-mode: false + - uses: actions/labeler@v5 + with: + configuration-path: .github/labeler.yml diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml new file mode 100644 index 0000000..f236374 --- /dev/null +++ b/.github/workflows/pythonpackage.yml @@ -0,0 +1,38 @@ +name: Python package + +on: [workflow_dispatch, pull_request] + +jobs: + python-check: + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + platform: [ubuntu-20.04, macos-latest, windows-latest] + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install -U pip poetry poethepoet + poetry --version + poetry install --only main,linters,test + - name: Run tests and linters + run: | + git config --global user.email "action@github.com" + git config --global user.name "GitHub Action" + poetry ci + shell: bash + - name: Upload coverage to Codecov + if: runner.os == 'Linux' + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.xml + flags: unittests + name: codecov-umbrella diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml new file mode 100644 index 0000000..dc522bc --- /dev/null +++ b/.github/workflows/pythonpublish.yml @@ -0,0 +1,28 @@ +name: Upload Python Package + +on: + push: + tags: + - "v*" + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}" + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install -U pip poetry + poetry --version + - name: Publish + env: + POETRY_HTTP_BASIC_PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} + POETRY_HTTP_BASIC_PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: poetry publish --build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be07cf2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,115 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +.static_storage/ +.media/ +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +.idea +.vscode/ +*.bak + +# macOSX +.DS_Store + +# ruff +.ruff_cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1373277 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,72 @@ +default_install_hook_types: + - pre-commit + - commit-msg + - pre-push + +default_stages: + - pre-commit + +repos: + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-vcs-permalinks + - id: end-of-file-fixer + exclude: "tests/((commands|data|providers/test_uv_provider)/|test_).+" + - id: trailing-whitespace + args: [ --markdown-linebreak-ext=md ] + exclude: '\.svg$' + - id: debug-statements + - id: no-commit-to-branch + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + args: [ '--unsafe' ] # for mkdocs.yml + - id: detect-private-key + + - repo: https://github.com/asottile/blacken-docs + rev: 1.19.1 + hooks: + - id: blacken-docs + additional_dependencies: [ black~=23.11 ] + + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell + name: Run codespell to check for common misspellings in files + # config section is within pyproject.toml + language: python + types: [ text ] + args: [ "--write-changes" ] + additional_dependencies: + - tomli + + - repo: https://github.com/commitizen-tools/commitizen + rev: v4.6.0 # automatically updated by Commitizen + hooks: + - id: commitizen + - id: commitizen-branch + stages: + - post-commit + + - repo: local + hooks: + - id: format + name: Format + language: system + pass_filenames: false + entry: poetry format + types: [ python ] + + - id: linter and test + name: Linters + language: system + pass_filenames: false + entry: poetry lint + types: [ python ] diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..2a3a088 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,27 @@ +- id: commitizen + name: commitizen check + description: > + Check whether the current commit message follows committing rules. Allow + empty commit messages by default, because they typically indicate to Git + that the commit should be aborted. + entry: cz check + args: [--allow-abort, --commit-msg-file] + stages: [commit-msg] + language: python + language_version: python3 + minimum_pre_commit_version: "1.4.3" + +- id: commitizen-branch + name: commitizen check branch + description: > + Check all commit messages that are already on the current branch but not the + default branch on the origin repository. Useful for checking messages after + the fact (e.g., pre-push or in CI) without an expensive check of the entire + repository history. + entry: cz check + args: [--rev-range, origin/HEAD..HEAD] + always_run: true + pass_filenames: false + language: python + language_version: python3 + minimum_pre_commit_version: "1.4.3" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1d2ab1e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1986 @@ +## v4.6.0 (2025-04-13) + +### Feat + +- **changelog**: expose commit parents' digests when processing commits +- **git**: add parents' digests in commit information + +## v4.5.1 (2025-04-09) + +### Fix + +- print which tag is invalid + +## v4.5.0 (2025-04-04) + +### Feat + +- **init**: set uv to default value if both pyproject.toml and uv.lock present + +### Fix + +- **commands/init**: add missing uv provider to "cz init" + +## v4.4.1 (2025-03-02) + +### Fix + +- **tags**: fixes ImportError on Python >=3.11 (#1363) (#1364) + +## v4.4.0 (2025-03-02) + +### Feat + +- **tags**: adds `legacy_tag_formats` and `ignored_tag_formats` settings + +### Refactor + +- **get_tag_regexes**: dedup tag regexes definition + +## v4.3.0 (2025-02-28) + +### Feat + +- **providers**: add uv_provider + +## v4.2.2 (2025-02-18) + +### Fix + +- **bump**: manual version bump if prerelease offset is configured + +## v4.2.1 (2025-02-08) + +### Fix + +- **bump**: add debugging to bump + +## v4.2.0 (2025-02-07) + +### Feat + +- draft of the --empty parameter + +### Refactor + +- **bump**: rename --empty as --allow-no-commit + +## v4.1.1 (2025-01-26) + +### Fix + +- **get-next-bump**: add a test case +- **get-next-bump**: fix to permit usage of --get-next options even when update_changelog_on_bump is set to true + +## v4.1.0 (2024-12-06) + +### Feat + +- **commit**: allow '-- --allow-empty' to create empty commits + +## v4.0.0 (2024-11-26) + +## v3.31.0 (2024-11-16) + +### Feat + +- **commitizen**: document '--' double dash in '--help' + +### Fix + +- **commit**: avoid warnings with 'always_signoff' configuration +- **commit**: resolve 'always_signoff' configuration and '-s' CLI issues + +## v3.30.1 (2024-11-10) + +### Refactor + +- **cli**: replace magic number 0 with ExitCode.EXPECTED_EXIT +- **defaults**: disallow style as None +- **cz_customize**: return empty string for info, example, schema and schema_pattern if not provided + +## v3.30.0 (2024-10-23) + +### Feat + +- **commands/commit**: add force-edit functionality after answering questions + +### Refactor + +- remove redundant return None + +## v3.29.1 (2024-09-26) + +### Fix + +- **changelog**: Factorized TAG_FORMAT_REGEXES +- **changelog**: Handle tag format without version pattern +- **changelog**: handle custom tag_format in changelog generation + +### Refactor + +- Use format strings + +## v3.29.0 (2024-08-11) + +### Feat + +- **bump**: add functionality to write the next version to stdout + +## v3.28.0 (2024-07-17) + +### Feat + +- add argument to limit length of commit message in checks + +## v3.27.0 (2024-05-22) + +### Feat + +- **config_files**: add support for "cz.toml" config file + +## v3.26.2 (2024-05-22) + +### Fix + +- **base.py**: add encoding when open changlelog_file + +## v3.26.1 (2024-05-22) + +### Fix + +- **cli/commands**: add description for subcommands + +### Refactor + +- **KNOWN_SCHEMES**: replace set comprehension for list comprehension +- **tests/commands**: move "other" tests for the correspondent file + +## v3.26.0 (2024-05-18) + +### Feat + +- **ci/cd**: automates the generation of CLI screenshots + +## v3.25.1 (2024-05-15) + +### Refactor + +- strip possessive from note about ci option + +## v3.25.0 (2024-04-30) + +### Feat + +- add an argument to limit the length of commit message + +### Fix + +- strip the commit message for calculating length +- resolve test error by removing defaults + +### Refactor + +- **commands/commit**: replace comparison with chained comparison +- check the length in Commit instead of Commitizen + +## v3.24.0 (2024-04-18) + +### Feat + +- **schemes**: adds support for SemVer 2.0 (dot in pre-releases) (fix #1025) (#1072) + +## v3.23.0 (2024-04-18) + +### Feat + +- **bump**: `version_files` now support glob patterns (fix #1067) (#1070) + +## v3.22.0 (2024-04-11) + +### Feat + +- **cli**: add config option to specify config file path + +## v3.21.3 (2024-03-30) + +### Refactor + +- **defaults**: move cz_conventional_commit defaults out of defaults.py + +## v3.21.2 (2024-03-30) + +### Fix + +- **commitizen/git.py,-tests/test_git.py**: Resolve tempfile path spaces issue in git commit function + +## v3.21.1 (2024-03-30) + +### Fix + +- **command-init**: "cz init" should list existing tag in reverse order + +## v3.21.0 (2024-03-30) + +### Feat + +- **commit**: add retry_after_failure config option and --no-retry flag + +### Refactor + +- **utils**: convert git project root to posix path for backup file name +- **commit**: use Optional[str] instead of str | None +- **commit**: remove unused tempfile import +- **git-hooks**: make git hooks use get_backup_file_path +- **utils**: move backup path creation to utils + +## v3.20.0 (2024-03-19) + +### Feat + +- **changelog**: expose commits `sha1`, `author` and `author_email` in changelog tree (fix #987) (#1013) + +## v3.19.0 (2024-03-19) + +### Feat + +- **changelog**: adds a `changelog_release_hook` called for each release in the changelog (#1018) + +## v3.18.4 (2024-03-14) + +### Fix + +- **changelog**: include latest change when dry run and incremental + +## v3.18.3 (2024-03-11) + +### Fix + +- **warnings**: all warnings should go to `stdout` + +## v3.18.2 (2024-03-11) + +### Fix + +- **git**: force the default git locale on methods relying on parsing the output (#1012) + +## v3.18.1 (2024-03-11) + +### Fix + +- **changelog**: changelog hook was not called on dry run + +## v3.18.0 (2024-03-07) + +### Feat + +- **changelog**: `changelog_message_build_hook` can now generate multiple changelog entries from a single commit (#1003) + +## v3.17.2 (2024-03-07) + +### Fix + +- **changelog**: ensure `changelog_message_builder_hook` can access and modify `change_type` (#1002) + +## v3.17.1 (2024-03-07) + +### Fix + +- **bump**: pre and post bump hooks were failing when an increment was provided (fix #1004) + +## v3.17.0 (2024-03-06) + +### Feat + +- **changelog**: `changelog_message_build_hook` can remove message by returning a falsy value + +## v3.16.0 (2024-02-26) + +### Feat + +- **commands**: add bump --exact + +### Fix + +- **bump**: change --exact-increment to --increment-mode +- **bump**: only get and validate commits if increment is not provided +- Improve type annotations + +## v3.15.0 (2024-02-17) + +### Feat + +- **bump**: functionality to add build-metadata to version string + +## v3.14.1 (2024-02-04) + +### Fix + +- **bump**: remove unused method +- **scm**: only search tags that are reachable by the current commit + +## v3.14.0 (2024-02-01) + +### Feat + +- properly bump versions between prereleases (#799) + +## v3.13.0 (2023-12-03) + +### Feat + +- **commands-bump**: automatically create annotated tag if message is given +- add tag message argument to cli +- **git**: add get tag message function +- add custom message to annotated git tag + +### Fix + +- **test-bump-command**: typo in --annotated-tag option inside test +- **commitizen-git**: add quotes for tag message + +### Refactor + +- **commands-bump**: make changelog variable in 1 line +- **commands-bump**: cast str to bool + +## v3.12.0 (2023-10-18) + +### Feat + +- **formats**: expose some new customizable changelog formats on the `commitizen.changelog_format` endpoint (Textile, AsciiDoc and RestructuredText) +- **template**: add `changelog --export-template` command +- **template**: allow to override the template from cli, configuration and plugins +- **cli.py**: Added support for extra git CLI args after -- separator for `cz commit` command + +### Fix + +- **filename**: ensure `file_name` can be passed to `changelog` from `bump` command + +### Refactor + +- **git.py**: Removed 'extra_args' from git.commit +- **extra_args**: Fixed broken code due to rebase and finalized tests +- Code Review - round 1 changes +- **Commit**: Added deprecation on git signoff mechanic + +## v3.10.1 (2023-10-14) + +### Fix + +- **bump**: add bump support with custom type + scope + exclamation mark +- **bump**: version bumping + +## v3.10.0 (2023-09-25) + +### Feat + +- Drop support for Python 3.7 (#858) + +## v3.9.1 (2023-09-22) + +### Fix + +- **conf**: handle parse error when init (#856) + +## v3.9.0 (2023-09-15) + +### Feat + +- **commands**: add arg of cz commit to execute git add + +### Fix + +- **tests**: modify the arg of commit from add to all +- **commitizen**: Modify the function of the arg a of commit from git add all to git add update + +### Refactor + +- **commitizen**: add return type hint of git add function + +## v3.8.2 (2023-09-09) + +### Refactor + +- **provider**: split provider code and related tests into individual files for maintainability (#830) + +## v3.8.1 (2023-09-08) + +### Fix + +- add sponsors to README + +## v3.8.0 (2023-09-05) + +### Feat + +- **defaults.py**: add always_signoff config option for commits + +## v3.7.1 (2023-09-04) + +### Fix + +- empty error on bump failure + +## v3.7.0 (2023-08-26) + +### Feat + +- **provider**: add npm2 provider to update package.json, package-lock.json, and npm-shrinkwrap.json + +### Fix + +- **provider**: fix npm version provider to update package-lock.json and npm-shrinkwrap.json if they exist +- **provider**: fix npm provider to update package-lock.json and npm-shrinkwrap.json if they exist +- **test**: pass correct type to get_package_version tests +- **tests**: completed test coverage for npm2 + +## v3.6.0 (2023-08-01) + +### Feat + +- **changelog.py**: add encoding to get_metadata +- **unicode**: add unicode support + +### Fix + +- add missing `encoding` parameter +- **out.py**: `TextIOWrapper.reconfigure` typing +- correct type hinting +- use base config for encoding + +### Refactor + +- **defaults.py**: use variables in `DEFAULT_SETTINGS` + +## v3.5.4 (2023-07-29) + +### Refactor + +- replace SemVer type literals by respective constants + +## v3.5.3 (2023-07-15) + +### Fix + +- Treat $version the same as unset tag_format in ScmProvider + +### Refactor + +- Make tag_format properly default to $version + +## v3.5.2 (2023-06-25) + +### Fix + +- **typing**: no_raise is declared as optional + +## v3.5.1 (2023-06-24) + +### Fix + +- only use version tags when generating a changelog + +## v3.5.0 (2023-06-23) + +### Feat + +- Add option in bump command to redirect git output to stderr + +## v3.4.1 (2023-06-23) + +### Fix + +- **veresion_schemes**: import missing Self for python 3.11 + +## v3.4.0 (2023-06-20) + +### Feat + +- **version-schemes**: expose `version_schemes` as a `commitizen.scheme` endpoint. + +## v3.3.0 (2023-06-13) + +### Feat + +- add support for cargo workspaces + +## v3.2.2 (2023-05-11) + +### Fix + +- **init**: fix is_pre_commit_installed method + +## v3.2.1 (2023-05-03) + +### Fix + +- add support for importlib_metadata 6 + +## v3.2.0 (2023-05-01) + +### Feat + +- **hooks**: add prepare-commit-msg and post-commit hooks +- **commit**: add --write-message-to-file option + +### Fix + +- **bump**: better match for change_type when finding increment +- **changelog**: breaking change on additional types for conventional commits +- **bump**: breaking changes on additional types for conventional commits +- improve errors message when empty .cz.json found +- **init**: poetry detection +- bump decli which is type hinted + +### Refactor + +- **commit**: change type of write_message_to_file to path + +## v3.1.1 (2023-04-28) + +### Fix + +- bump changelog for prerelease without commits + +## v3.1.0 (2023-04-25) + +### Feat + +- make `major_version_zero` customizable by third parties + +## v3.0.1 (2023-04-23) + +### Fix + +- typo in hook + +### Refactor + +- set default_install_hook_types + +## v3.0.0 (2023-04-23) + +### BREAKING CHANGE + +- Plugins are now exposed as `commitizen.plugin` entrypoints +- Python 3.6 is not officially supported anymore. Please migrate from 3.6 to 3.7 or greater. + +### Feat + +- **init**: add new settings +- add semver support through version provider new api (#686) +- **changelog**: add merge_prereleases flag +- **providers**: add a `scm` version provider +- **providers**: add support for some JSON-based version providers (NPM, Composer) +- **providers**: add support for some TOML-based versions (PEP621, Poetry, Cargo) +- **providers**: add a `commitizen.provider` endpoint for alternative versions providers +- **plugins**: Switch to an importlib.metadata.EntryPoint-based plugin loading + +### Fix + +- **init**: welcome message +- small corrections and clean up +- major version zero message +- update dependencies +- **commands/changelog**: use topological order for commit ordering +- **excepthook**: ensure traceback can only be a `TracebackType` or `None` + +## v2.42.1 (2023-02-25) + +### Fix + +- **bump**: fixed environment variables in bump hooks + +## v2.42.0 (2023-02-11) + +### Feat + +- **bump**: support prereleases with start offset + +## v2.41.0 (2023-02-08) + +### Feat + +- **bump**: added support for running arbitrary hooks during bump + +## v2.40.0 (2023-01-23) + +### Feat + +- **yaml_config**: add explicit_start for yaml output + +## v2.39.1 (2022-12-31) + +### Fix + +- filter git diff from commit message + +## v2.39.0 (2022-12-31) + +### Feat + +- **init**: allow user to select which type of pre commit hooks to install + +### Fix + +- **init**: space between `--hook-type` options +- **init**: report error when hook installation failed + +### Refactor + +- **init**: `_install_pre_commit_hook` raise error when failed + +## v2.38.0 (2022-12-12) + +### Feat + +- **poetry**: relax packaging version + +## v2.37.1 (2022-11-30) + +### Fix + +- **changelog**: allow rev range lookups without a tag format + +## v2.37.0 (2022-10-28) + +### Feat + +- add major-version-zero option to support initial package development + +## v2.36.0 (2022-10-28) + +### Feat + +- **scripts**: remove `venv/bin/` +- **scripts**: add error message to `test` + +### Fix + +- **scripts/test**: MinGW64 workaround +- **scripts/test**: use double-quotes +- **scripts**: pydocstyle and cz +- **bump.py**: use `sys.stdin.isatty()` +- **scripts**: use cross-platform POSIX +- **scripts**: use portable shebang +- **pythonpackage.yml**: undo indent reformatting +- **pythonpackage.yml**: use `bash` + +## v2.35.0 (2022-09-23) + +### Feat + +- allow fixup! and squash! in commit messages + +## v2.34.0 (2022-09-19) + +### Feat + +- **bump**: support optional manual version argument + +### Fix + +- **bump**: fix type hint +- **bump**: fix typos + +## v2.33.1 (2022-09-16) + +### Fix + +- **bump.py**: `CHANGELOG.md` gets git added and committed correctly + +## v2.33.0 (2022-09-15) + +### Feat + +- add functionality for dev-releases + +## v2.32.7 (2022-09-14) + +### Fix + +- **README.md**: fix pre-commit install command + +## v2.32.6 (2022-09-14) + +### Fix + +- **bump**: log git commit stderr and stdout during bump + +## v2.32.5 (2022-09-10) + +### Fix + +- **command_changelog**: Fixed issue #561 cz bump could not find the latest version tag with custom tag_format + +## v2.32.4 (2022-09-08) + +### Refactor + +- **bump**: Remove a redundant join call + +## v2.32.3 (2022-09-07) + +### Fix + +- **bump**: Search for version number line by line + +## v2.32.2 (2022-08-22) + +### Fix + +- **bump**: Support regexes containing colons + +## v2.32.1 (2022-08-21) + +### Fix + +- **git**: Improves error checking in get_tags +- **git**: improves git error checking in get_commits + +### Refactor + +- **git**: test the git log parser behaves properly when the repository has no commits +- **changelog**: fixes logic issue made evident by latest fix(git) commit + +## v2.32.0 (2022-08-21) + +### Feat + +- **pre-commit**: Add commitizen-branch hook + +## v2.31.0 (2022-08-14) + +### Feat + +- new file + +### Fix + +- **pyproject.toml**: remove test added configurations +- **changelog**: use defaults.change_type_order in conventional commit +- capitalize types in default change_type_order + +## v2.30.0 (2022-08-14) + +### Feat + +- Determine newline to write with Git + +## v2.29.6 (2022-08-13) + +### Fix + +- **cmd**: improve character encoding detection for sub-commands + +## v2.29.5 (2022-08-07) + +### Fix + +- **git**: use "git tag -v" return_code to check whether a tag is signed + +## v2.29.4 (2022-08-05) + +### Refactor + +- **tool**: use charset_normalizer instead of chardet + +## v2.29.3 (2022-08-02) + +### Refactor + +- **changelog**: removes unused code. duplicates are found in changelog_parser + +## v2.29.2 (2022-07-27) + +### Fix + +- **bump**: send changelog to stdout when `dry-run` is paired with `changelog-to-stdout` + +## v2.29.1 (2022-07-26) + +### Fix + +- **Check**: process empty commit message +- **ConventionalCommitsCz**: cz's schema validates the whole commit message now + +### Refactor + +- **Check**: remove the extra preprocessing of commit message file + +## v2.29.0 (2022-07-22) + +### Feat + +- use chardet to get correct encoding +- **bump**: add signed tag support for bump command + +### Fix + +- avoid that pytest overrides existing gpg config +- **test**: set git to work with gpg + +## v2.28.1 (2022-07-22) + +### Fix + +- **changelog**: skip non-compliant commit subjects when building changelog + +## v2.28.0 (2022-07-03) + +### Feat + +- **bump**: make increment option case insensitive + +## v2.27.1 (2022-05-22) + +### Fix + +- **pre-commit**: Use new --allow-abort option +- **pre-commit**: Confine hook to commit-msg stage +- **pre-commit**: Set min pre-commit to v1.4.3 +- **pre-commit**: Don't require serial execution + +## v2.27.0 (2022-05-16) + +### Feat + +- **bump**: let it respect pre-commit reformats when bumping + +## v2.26.0 (2022-05-14) + +### Feat + +- **check**: Add --allow-abort option + +## v2.25.0 (2022-05-10) + +### Feat + +- **changelog**: Improve whitespace in changelog + +### Refactor + +- **changelog**: Simplify incremental_build + +## v2.24.0 (2022-04-15) + +### Feat + +- add --no-raise to avoid raising error codes + +### Fix + +- change error code for NoneIncrementExit + +## v2.23.0 (2022-03-29) + +### Feat + +- **customize.py**: adding support for commit_parser, changelog_pattern, change_type_map + +## v2.22.0 (2022-03-29) + +### Feat + +- **changelog**: add support for single version and version range + +### Refactor + +- speed up testing and wait for tags +- **git**: use date as a function in GitTag to easily patch + +## v2.21.2 (2022-02-22) + +### Fix + +- remove type ignore + +## v2.21.1 (2022-02-22) + +### Refactor + +- Switch to issue forms +- Switch to issue forms +- Switch to issue forms + +## v2.21.0 (2022-02-17) + +### Feat + +- skip merge messages that start with Pull request + +## v2.20.5 (2022-02-07) + +### Fix + +- Ignore packages that are not plugins + +### Refactor + +- iter_modules only accepts str + +## v2.20.4 (2022-01-17) + +### Fix + +- **bump**: raise non zero error code when there's no eligible commit to bump + +## v2.20.3 (2021-12-20) + +### Fix + +- **check**: filter out comment message when checking + +## v2.20.2 (2021-12-14) + +### Fix + +- **poetry**: add typing-exteions to dev + +## v2.20.1 (2021-12-14) + +### Fix + +- import TypedDict from type_extensions for backward compatibility + +### Refactor + +- **conventional_commits**: remove duplicate patterns and import from defaults +- **config**: add CzSettings and Questions TypedDict +- **defaults**: add Settings typeddict +- **defaults**: move bump_map, bump_pattern, commit_parser from defaults to ConventionalCommitsCz + +## v2.20.0 (2021-10-06) + +### Feat + +- **cli.py**: add shortcut for signoff command +- add signoff parameter to commit command + +## v2.19.0 (2021-09-27) + +### Feat + +- utility for showing system information + +## v2.18.2 (2021-09-27) + +### Fix + +- **cli**: handle argparse different behavior after python 3.9 + +## v2.18.1 (2021-09-12) + +### Fix + +- **commit**: correct the stage checker before committing + +## v2.18.0 (2021-08-13) + +### Feat + +- **prompt**: add keyboard shortcuts with config option + +### Refactor + +- **shortcuts**: move check for shortcut config setting to apply to any list select + +## v2.17.13 (2021-07-14) + +## v2.17.12 (2021-07-06) + +### Fix + +- **git.py**: ensure signed commits in changelog when git config log.showsignature=true + +## v2.17.11 (2021-06-24) + +### Fix + +- correct indentation for json config for better readability + +## v2.17.10 (2021-06-22) + +### Fix + +- add support for jinja2 v3 + +## v2.17.9 (2021-06-11) + +### Fix + +- **changelog**: generating changelog after a pre-release + +## v2.17.8 (2021-05-28) + +### Fix + +- **changelog**: annotated tags not generating proper changelog + +## v2.17.7 (2021-05-26) + +### Fix + +- **bump**: fix error due to bumping version file without eol through regex +- **bump**: fix offset error due to partially match + +## v2.17.6 (2021-05-06) + +### Fix + +- **cz/conventional_commits**: optionally expect '!' right before ':' in schema_pattern + +## v2.17.5 (2021-05-06) + +## v2.17.4 (2021-04-22) + +### Fix + +- version update in a docker-compose.yaml file + +## v2.17.3 (2021-04-19) + +### Fix + +- fix multiple versions bumps when version changes the string size + +## v2.17.2 (2021-04-10) + +### Fix + +- **bump**: replace all occurrences that match regex +- **wip**: add test for current breaking change + +## v2.17.1 (2021-04-08) + +### Fix + +- **commands/init**: fix toml config format error + +## v2.17.0 (2021-04-02) + +### Feat + +- Support versions on random positions + +## v2.16.0 (2021-03-08) + +### Feat + +- **bump**: send incremental changelog to stdout and bump output to stderr + +## v2.15.3 (2021-02-26) + +### Fix + +- add utf-8 encode when write toml file + +## v2.15.2 (2021-02-24) + +### Fix + +- **git**: fix get_commits deliminator + +## v2.15.1 (2021-02-21) + +### Fix + +- **config**: change read mode from `r` to `rb` + +## v2.15.0 (2021-02-21) + +### Feat + +- **changelog**: add support for multiline BREAKING paragraph + +## v2.14.2 (2021-02-06) + +### Fix + +- **git**: handle the empty commit and empty email cases + +## v2.14.1 (2021-02-02) + +### Fix + +- remove yaml warnings when using '.cz.yaml' + +## v2.14.0 (2021-01-20) + +### Feat + +- **#271**: enable creation of annotated tags when bumping + +## v2.13.0 (2021-01-01) + +### Feat + +- **#319**: add optional change_type_order + +### Refactor + +- raise an InvalidConfigurationError +- **#323**: address PR feedback +- move expected COMMITS_TREE to global + +## v2.12.1 (2020-12-30) + +### Fix + +- read commit_msg_file with utf-8 + +## v2.12.0 (2020-12-30) + +### Feat + +- **deps**: Update and relax tomlkit version requirement + +## v2.11.1 (2020-12-16) + +### Fix + +- **commit**: attach user info to backup for permission denied issue + +## v2.11.0 (2020-12-10) + +### Feat + +- add yaml as a config option +- **config**: add support for the new class YAMLConfig at the root of the confi internal package +- **init**: add support for yaml config file at init + +### Fix + +- **YAMLConfig**: add a TypeError exception to handle in _parse_settings method + +## v2.10.0 (2020-12-02) + +### Feat + +- **commitizen/cli**: add the integration with argcomplete + +## v2.9.0 (2020-12-02) + +### Feat + +- **Init**: add the json config support as an option at Init +- **commitizen/config/json_config**: add json support for configuration + +### Fix + +- **json_config**: fix the emtpy_config_content method + +## v2.8.2 (2020-11-21) + +### Fix + +- support `!` in cz check command + +## v2.8.1 (2020-11-21) + +### Fix + +- prevent prerelease from creating a bump when there are no commits + +## v2.8.0 (2020-11-15) + +### Feat + +- allow files-only to set config version and create changelog + +## v2.7.0 (2020-11-14) + +### Feat + +- **bump**: add flag `--local-version` that supports bumping only the local version instead of the public + +## v2.6.0 (2020-11-04) + +### Feat + +- **commands/bump**: add config option to create changelog on bump + +## v2.5.0 (2020-11-04) + +### Feat + +- **commands/changelog**: add config file options for start_rev and incremental + +## v2.4.2 (2020-10-26) + +### Fix + +- **init.py**: mypy error (types) +- **commands/bump**: Add NoneIncrementExit to fix git fatal error when creating existing tag + +### Refactor + +- **commands/bump**: Remove comment and changed ... for pass + +## v2.4.1 (2020-10-04) + +### Fix + +- **cz_customize**: make schema_pattern customiziable through config for cz_customize + +## v2.4.0 (2020-09-18) + +### Feat + +- **cz_check**: cz check can read commit message from pipe + +## v2.3.1 (2020-09-07) + +### Fix + +- conventional commit schema + +## v2.3.0 (2020-09-03) + +### Feat + +- **cli**: rewrite cli instructions to be more succinct about what they require + +### Fix + +- **cli**: add guideline for subject input +- **cli**: wrap the word enter with brackets + +## v2.2.0 (2020-08-31) + +### Feat + +- **cz_check**: cz check can read from a string input + +## v2.1.0 (2020-08-06) + +### Feat + +- **cz_check**: Add rev to all displayed ill-formatted commits +- **cz_check**: Update to show all ill-formatted commits + +### Refactor + +- **cz_check**: Refactor _get_commits to return GitCommit instead of dict + +## v2.0.2 (2020-08-03) + +### Fix + +- **git**: use double quotation mark in get_tags + +## v2.0.1 (2020-08-02) + +### Fix + +- **commands/changelog**: add exception message when failing to find an incremental revision +- **commands/bump**: display message variable properly + +## v2.0.0 (2020-07-26) + +### BREAKING CHANGE + +- setup.cfg, .cz and .cz.cfg are no longer supported +- Use "cz version" instead +- "cz --debug" will no longer work + #47 + +### Feat + +- **init**: enable setting up pre-commit hook through "cz init" + +### Fix + +- add missing `pyyaml` dependency +- **cli**: make command required for commitizen + +### Refactor + +- **config**: drop "files" configure support. Please use "version_files" instead +- **config**: remove ini configuration support +- **cli**: remove "--version" argument + +## v1.25.0 (2020-07-26) + +### Feat + +- **conventional_commits**: use and proper support for conventional commits v1.0.0 + +## v1.24.0 (2020-07-26) + +### Feat + +- add author and author_email to git commit + +## v1.23.4 (2020-07-26) + +### Refactor + +- **changelog**: remove pkg_resources dependency + +## v1.23.3 (2020-07-25) + +### Fix + +- **commands/bump**: use `return_code` in commands used by bump +- **commands/commit**: use return_code to raise commit error, not stderr + +### Refactor + +- **cmd**: add return code to Command + +## v1.23.2 (2020-07-25) + +### Fix + +- **bump**: add changelog file into stage when running `cz bump --changelog` + +## v1.23.1 (2020-07-14) + +### Fix + +- Raise NotAGitProjectError only in git related command + +## v1.23.0 (2020-06-14) + +### Feat + +- **cli**: enable displaying all traceback for CommitizenException when --debug flag is used + +### Refactor + +- **exception**: rename MissingConfigError as MissingCzCustomizeConfigError +- **exception**: Rename CommitFailedError and TagFailedError with Bump prefix +- **commands/init**: add test case and remove unaccessible code +- **exception**: move output message related to exception into exception +- **exception**: implement message handling mechanism for CommitizenException +- **cli**: do not show traceback if the raised exception is CommitizenException +- introduce DryRunExit, ExpectedExit, NoCommandFoundError, InvalidCommandArgumentError +- use custom exception for error handling +- **error_codes**: remove unused NO_COMMIT_MSG error code + +## v1.22.3 (2020-06-10) + +## v1.22.2 (2020-05-29) + +### Fix + +- **changelog**: empty lines at the beginning of the CHANGELOG + +## v1.22.1 (2020-05-23) + +### Fix + +- **templates**: remove trailing space in keep_a_changelog + +## v1.22.0 (2020-05-13) + +### Feat + +- **changelog**: add support for `changelog_hook` when changelog finishes the generation +- **changelog**: add support for `message_hook` method +- **changelog**: add support for modifying the change_type in the title of the changelog + +### Fix + +- **changelog**: rename `message_hook` -> `changelog_message_builder_hook` + +## v1.21.0 (2020-05-09) + +### Feat + +- **commands/bump**: add "--check-consistency" optional + +## v1.20.0 (2020-05-06) + +### Feat + +- **bump**: add optional --no-verify argument for bump command + +## v1.19.3 (2020-05-04) + +### Fix + +- **docs**: change old url woile.github.io to commitizen-tools.github.io +- **changelog**: generate today's date when using an unreleased_version + +## v1.19.2 (2020-05-03) + +### Fix + +- **changelog**: sort the commits properly to their version + +## v1.19.1 (2020-05-03) + +### Fix + +- **commands/check**: Show warning if no commit to check when running `cz check --rev-range` + +### Refactor + +- **cli**: add explicit category for deprecation warnings + +## v1.19.0 (2020-05-02) + +### Feat + +- **changelog**: add support for any commit rule system +- **changelog**: add incremental flag +- **commands/changelog**: make changelog_file an option in config +- **commands/changelog**: exit when there is no commit exists +- **commands/changelog**: add --start-rev argument to `cz changelog` +- **changelog**: generate changelog based on git log +- **commands/changelog**: generate changelog_tree from all past commits +- **cz/conventinal_commits**: add changelog_map, changelog_pattern and implement process_commit +- **cz/base**: add default process_commit for processing commit message +- **changelog**: changelog tree generation from markdown + +### Fix + +- **git**: missing dependency removed +- **changelog**: check get_metadata for existing changelog file +- **cz/conventional_commits**: fix schema_pattern break due to rebase +- **changelog_template**: fix list format +- **commitizen/cz**: set changelog_map, changelog_pattern to none as default +- **commands/changelog**: remove --skip-merge argument +- **cli**: add changelog arguments + +### Refactor + +- **changelog**: use functions from changelog.py +- **changelog**: rename category to change_type to fit 'keep a changelog' +- **templates**: rename as "keep_a_changelog_template.j2" +- **templates**: remove unneeded __init__ file +- **cli**: reorder commands +- **templates**: move changelog_template from cz to templates +- **tests/utils**: move create_file_and_commit to tests/utils +- **commands/changelog**: remove redundant if statement +- **commands/changelog**: use jinja2 template instead of string concatenation to build changelog + +## v1.18.3 (2020-04-22) + +### Refactor + +- **commands/init**: fix typo + +## v1.18.2 (2020-04-22) + +### Fix + +- **git**: fix returned value for GitCommit.message when body is empty + +### Refactor + +- **git**: replace GitCommit.message code with one-liner + +## v1.18.1 (2020-04-16) + +### Fix + +- **config**: display ini config deprecation warning only when commitizen config is inside + +## v1.18.0 (2020-04-13) + +### Feat + +- **bump**: support for ! as BREAKING change in commit message + +### Fix + +- **cz/customize**: add error handling when customize detail is not set + +### Refactor + +- **cz/customize**: remove unused mypy ignore +- **mypy**: fix mypy check by checking version.pre exists +- **cz**: add type annotation to registry +- **commands/check**: fix type annotation +- **config/base**: use Dict to replace dict in base_config +- **cz/base**: fix config type used in base cz +- **cz**: add type annotation for each function in cz +- **config**: fix mypy warning for _conf + +## v1.17.1 (2020-03-24) + +### Fix + +- **commands/check**: add help text for check command without argument + +### Refactor + +- **cli**: fix typo + +## v1.17.0 (2020-03-15) + +### Feat + +- **commands/check**: add --rev-range argument for checking commits within some range + +### Fix + +- **bump**: fix bump find_increment error + +### Refactor + +- **cz/connventional_commit**: use \S to check scope +- **git**: remove unnecessary dot between git range +- **tests/bump**: use parameterize to group similliar tests + +## v1.16.4 (2020-03-03) + +### Fix + +- **commands/init**: fix clean up file when initialize commitizen config + +### Refactor + +- **defaults**: split config files into long term support and deprecated ones + +## v1.16.3 (2020-02-20) + +### Fix + +- replace README.rst with docs/README.md in config files + +### Refactor + +- **docs**: remove README.rst and use docs/README.md + +## v1.16.2 (2020-02-01) + +### Fix + +- **commands/check**: add bump into valid commit message of convention commit pattern + +## v1.16.1 (2020-02-01) + +### Fix + +- **pre-commit**: set pre-commit check stage to commit-msg + +## v1.16.0 (2020-01-21) + +### Feat + +- **git**: get_commits default from first_commit + +### Refactor + +- **commands/bump**: rename parameter into bump_setting to distinguish bump_setting and argument +- **git**: rename get tag function to distinguish return str and GitTag +- **cmd**: reimplement how cmd is run +- **git**: Use GitCommit, GitTag object to store commit and git information +- **git**: make arguments other then start and end in get_commit keyword arguments +- **git**: Change get_commits into returning commits instead of lines of messages + +## v1.15.1 (2020-01-20) + +### Fix + +- **cli**: fix --version not functional + +### Refactor + +- **tests/commands/bump**: use tmp_dir to replace self implemented tmp dir behavior +- **test_bump_command**: rename camel case variables +- **tests/commands/check**: use pytest fixture tmpdir replace self implemented contextmanager +- **test/commands/other**: replace unit test style mock with mocker fixture +- **tests/commands**: separate command unit tests into modules +- **tests/commands**: make commands related tests a module + +## v1.15.0 (2020-01-20) + +### Feat + +- **config**: look up configuration in git project root +- **git**: add find_git_project_root + +### Fix + +- **git**: remove breakline in the return value of find_git_project_root + +### Refactor + +- **git**: make find_git_project_root return None if it's not a git project +- **config/base_config**: make set_key not implemented +- **error_codes**: move all the error_codes to a module +- **config**: replace string type path with pathlib.Path + +## v1.14.2 (2020-01-14) + +### Fix + +- **github_workflow/pythonpublish**: use peaceiris/actions-gh-pages@v2 to publish docs + +## v1.14.1 (2020-01-11) + +### Fix + +- **cli**: fix the way default handled for name argument +- **cli**: fix name cannot be overwritten through config in newly refactored config design + +## v1.14.0 (2020-01-06) + +### Feat + +- **pre-commit-hooks**: add pre-commit hook + +### Refactor + +- **pre-commit-hooks**: add metadata for the check hook + +## v1.13.1 (2019-12-31) + +### Fix + +- **github_workflow/pythonpackage**: set git config for unit testing +- **scripts/test**: ensure the script fails once the first failure happens + +## v1.13.0 (2019-12-30) + +### Feat + +- add project version to command init + +## v1.12.0 (2019-12-30) + +### Feat + +- new init command + +## v1.10.3 (2019-12-29) + +### Refactor + +- **commands/bump**: use "version_files" internally +- **config**: set "files" to alias of "version_files" + +## v1.10.2 (2019-12-27) + +### Fix + +- **config**: handle empty config file +- **config**: fix load global_conf even if it doesn't exist +- **config/ini_config**: replace outdated _parse_ini_settings with _parse_settings + +### Refactor + +- new config system where each config type has its own class +- **config**: add type annotation to config property +- **config**: fix wrongly type annotated functions +- **config/ini_config**: move deprecation warning into class initialization +- **config**: use add_path instead of directly assigning _path +- **all**: replace all the _settings invoke with settings.update +- **cz/customize**: remove unnecessary statement "raise NotImplementedError("Not Implemented yet")" +- **config**: move default settings back to defaults +- **config**: Make config a class and each type of config (e.g., toml, ini) a child class + +## v1.10.1 (2019-12-10) + +### Fix + +- **cli**: overwrite "name" only when it's not given +- **config**: fix typo + +## v1.10.0 (2019-11-28) + +### Feat + +- support for different commitizens in `cz check` +- **bump**: new argument --files-only + +## v1.9.2 (2019-11-23) + +### Fix + +- **commands/check.py**: --commit-msg-file is now a required argument + +## v1.9.1 (2019-11-23) + +### Fix + +- **cz/exceptions**: exception AnswerRequiredException not caught (#89) + +## v1.9.0 (2019-11-22) + +### Feat + +- **Commands/check**: enforce the project to always use conventional commits +- **config**: add deprecation warning for loading config from ini files +- **cz/customize**: add jinja support to enhance template flexibility +- **cz/filters**: add required_validator and multiple_line_breaker +- **Commands/commit**: add ยด--dry-runยด flag to the Commit command +- **cz/cz_customize**: implement info to support info and info_path +- **cz/cz_customize**: enable bump_pattern bump_map customization +- **cz/cz_customize**: implement customizable cz +- new 'git-cz' entrypoint + +### Fix + +- commit dry-run doesn't require staging to be clean +- **scripts**: add back the delete poetry prefix +- correct typo to spell "convention" +- removing folder in windows throwing a PermissionError +- **test_cli**: testing the version command + +### Refactor + +- **config**: remove has_pyproject which is no longer used +- **cz/customize**: make jinja2 a custom requirement. if not installed use string.Template instead +- **cz/utils**: rename filters as utils +- **cli**: add back --version and remove subcommand required constraint + +## v1.8.0 (2019-11-12) + +### Feat + +- **cz**: add a base exception for cz customization +- **commands/commit**: abort commit if there is nothing to commit +- **git**: add is_staging_clean to check if there is any file in git staging + +### Fix + +- **commands/commit**: catch exception raised by customization cz +- **cli**: handle the exception that command is not given +- **cli**: enforce subcommand as required + +### Refactor + +- **cz/conventional_commit**: make NoSubjectException inherit CzException and add error message +- **command/version**: use out.write instead of out.line +- **command**: make version a command instead of an argument + +## v1.7.0 (2019-11-08) + +### Feat + +- **config**: update style instead of overwrite +- **config**: parse style in config +- **commit**: make style configurable for commit command + +### Fix + +- **cz**: fix bug in BaseCommitizen.style +- **cz**: fix merge_style usage error +- **cz**: remove breakpoint + +### Refactor + +- **cz**: change the color of default style + +## v1.6.0 (2019-11-05) + +### Feat + +- **commit**: new retry argument to execute previous commit again + +## v1.5.1 (2019-06-04) + +### Fix + +- #28 allows poetry add on py36 envs + +## v1.5.0 (2019-05-11) + +### Feat + +- **bump**: it is now possible to specify a pattern in the files attr to replace the version + +## v1.4.0 (2019-04-26) + +### Feat + +- added argument yes to bump in order to accept questions + +### Fix + +- **bump**: handle commit and create tag failure + +## v1.3.0 (2019-04-24) + +### Feat + +- **bump**: new commit message template + +## v1.2.1 (2019-04-21) + +### Fix + +- **bump**: prefixes like docs, build, etc no longer generate a PATCH + +## v1.2.0 (2019-04-19) + +### Feat + +- custom cz plugins now support bumping version + +## v1.1.1 (2019-04-18) + +### Fix + +- **bump**: commit message now fits better with semver +- conventional commit 'breaking change' in body instead of title + +### Refactor + +- changed stdout statements +- **schema**: command logic removed from commitizen base +- **info**: command logic removed from commitizen base +- **example**: command logic removed from commitizen base +- **commit**: moved most of the commit logic to the commit command + +## v1.1.0 (2019-04-14) + +### Feat + +- new working bump command +- create version tag +- update given files with new version +- **config**: new set key, used to set version to cfg +- support for pyproject.toml +- first semantic version bump implementation + +### Fix + +- removed all from commit +- fix config file not working + +### Refactor + +- added commands folder, better integration with decli + +## v1.0.0 (2019-03-01) + +### Refactor + +- removed delegator, added decli and many tests + +## 1.0.0b2 (2019-01-18) + +## v1.0.0b1 (2019-01-17) + +### Feat + +- py3 only, tests and conventional commits 1.0 + +## v0.9.11 (2018-12-17) + +### Fix + +- **config**: load config reads in order without failing if there is no commitizen section + +## v0.9.10 (2018-09-22) + +### Fix + +- parse scope (this is my punishment for not having tests) + +## v0.9.9 (2018-09-22) + +### Fix + +- parse scope empty + +## v0.9.8 (2018-09-22) + +### Fix + +- **scope**: parse correctly again + +## v0.9.7 (2018-09-22) + +### Fix + +- **scope**: parse correctly + +## v0.9.6 (2018-09-19) + +### Fix + +- **manifest**: included missing files + +### Refactor + +- **conventionalCommit**: moved filters to questions instead of message + +## v0.9.5 (2018-08-24) + +### Fix + +- **config**: home path for python versions between 3.0 and 3.5 + +## v0.9.4 (2018-08-02) + +### Feat + +- **cli**: added version + +## v0.9.3 (2018-07-28) + +### Feat + +- **committer**: conventional commit is a bit more intelligent now + +## v0.9.2 (2017-11-11) + +## v0.9.1 (2017-11-11) + +### Fix + +- **setup.py**: future is now required for every python version + +## v0.9.0 (2017-11-08) + +### Refactor + +- python 2 support + +## v0.8.6 (2017-11-08) + +## v0.8.5 (2017-11-08) + +## v0.8.4 (2017-11-08) + +## v0.8.3 (2017-11-08) + +## v0.8.2 (2017-10-08) + +## v0.8.1 (2017-10-08) + +## v0.8.0 (2017-10-08) + +### Feat + +- **cz**: jira smart commits + +## v0.7.0 (2017-10-08) + +### Refactor + +- **cli**: renamed all to ls command +- **cz**: renamed angular cz to conventional changelog cz + +## v0.6.0 (2017-10-08) + +### Feat + +- info command for angular + +## v0.5.0 (2017-10-07) + +## v0.4.0 (2017-10-07) + +## v0.3.0 (2017-10-07) + +## v0.2.0 (2017-10-07) + +### Feat + +- **config**: new loads from ~/.cz and working project .cz .cz.cfg and setup.cfg +- package discovery + +### Refactor + +- **cz_angular**: improved messages diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..05cf267 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Santiago + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..4c07f07 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include .bumpversion.cfg +include LICENSE +include commitizen/cz/*.txt +global-exclude *.py[cod] __pycache__ *.so *.dylib diff --git a/commitizen/__init__.py b/commitizen/__init__.py new file mode 100644 index 0000000..f16def4 --- /dev/null +++ b/commitizen/__init__.py @@ -0,0 +1,29 @@ +import logging +import logging.config + +from colorama import init # type: ignore + +from commitizen.cz.base import BaseCommitizen + +init() + + +LOGGING = { + "version": 1, + "disable_existing_loggers": True, + "formatters": {"standard": {"format": "%(message)s"}}, + "handlers": { + "default": { + "level": "DEBUG", + "formatter": "standard", + "class": "logging.StreamHandler", + } + }, + "loggers": { + "commitizen": {"handlers": ["default"], "level": "INFO", "propagate": True} + }, +} + +logging.config.dictConfig(LOGGING) + +__all__ = ["BaseCommitizen"] diff --git a/commitizen/__main__.py b/commitizen/__main__.py new file mode 100644 index 0000000..7e0a360 --- /dev/null +++ b/commitizen/__main__.py @@ -0,0 +1,4 @@ +from commitizen.cli import main + +if __name__ == "__main__": + main() diff --git a/commitizen/__version__.py b/commitizen/__version__.py new file mode 100644 index 0000000..db01fb2 --- /dev/null +++ b/commitizen/__version__.py @@ -0,0 +1 @@ +__version__ = "4.6.0" diff --git a/commitizen/bump.py b/commitizen/bump.py new file mode 100644 index 0000000..adfab64 --- /dev/null +++ b/commitizen/bump.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import os +import re +from collections import OrderedDict +from glob import iglob +from logging import getLogger +from string import Template +from typing import cast + +from commitizen.defaults import MAJOR, MINOR, PATCH, bump_message, encoding +from commitizen.exceptions import CurrentVersionNotFoundError +from commitizen.git import GitCommit, smart_open +from commitizen.version_schemes import Increment, Version + +VERSION_TYPES = [None, PATCH, MINOR, MAJOR] + +logger = getLogger("commitizen") + + +def find_increment( + commits: list[GitCommit], regex: str, increments_map: dict | OrderedDict +) -> Increment | None: + if isinstance(increments_map, dict): + increments_map = OrderedDict(increments_map) + + # Most important cases are major and minor. + # Everything else will be considered patch. + select_pattern = re.compile(regex) + increment: str | None = None + + for commit in commits: + for message in commit.message.split("\n"): + result = select_pattern.search(message) + + if result: + found_keyword = result.group(1) + new_increment = None + for match_pattern in increments_map.keys(): + if re.match(match_pattern, found_keyword): + new_increment = increments_map[match_pattern] + break + + if new_increment is None: + logger.debug( + f"no increment needed for '{found_keyword}' in '{message}'" + ) + + if VERSION_TYPES.index(increment) < VERSION_TYPES.index(new_increment): + logger.debug( + f"increment detected is '{new_increment}' due to '{found_keyword}' in '{message}'" + ) + increment = new_increment + + if increment == MAJOR: + break + + return cast(Increment, increment) + + +def update_version_in_files( + current_version: str, + new_version: str, + files: list[str], + *, + check_consistency: bool = False, + encoding: str = encoding, +) -> list[str]: + """Change old version to the new one in every file given. + + Note that this version is not the tag formatted one. + So for example, your tag could look like `v1.0.0` while your version in + the package like `1.0.0`. + + Returns the list of updated files. + """ + # TODO: separate check step and write step + updated = [] + for path, regex in files_and_regexs(files, current_version): + current_version_found, version_file = _bump_with_regex( + path, + current_version, + new_version, + regex, + encoding=encoding, + ) + + if check_consistency and not current_version_found: + raise CurrentVersionNotFoundError( + f"Current version {current_version} is not found in {path}.\n" + "The version defined in commitizen configuration and the ones in " + "version_files are possibly inconsistent." + ) + + # Write the file out again + with smart_open(path, "w", encoding=encoding) as file: + file.write(version_file) + updated.append(path) + return updated + + +def files_and_regexs(patterns: list[str], version: str) -> list[tuple[str, str]]: + """ + Resolve all distinct files with their regexp from a list of glob patterns with optional regexp + """ + out = [] + for pattern in patterns: + drive, tail = os.path.splitdrive(pattern) + path, _, regex = tail.partition(":") + filepath = drive + path + if not regex: + regex = _version_to_regex(version) + + for path in iglob(filepath): + out.append((path, regex)) + return sorted(list(set(out))) + + +def _bump_with_regex( + version_filepath: str, + current_version: str, + new_version: str, + regex: str, + encoding: str = encoding, +) -> tuple[bool, str]: + current_version_found = False + lines = [] + pattern = re.compile(regex) + with open(version_filepath, encoding=encoding) as f: + for line in f: + if pattern.search(line): + bumped_line = line.replace(current_version, new_version) + if bumped_line != line: + current_version_found = True + lines.append(bumped_line) + else: + lines.append(line) + return current_version_found, "".join(lines) + + +def _version_to_regex(version: str) -> str: + return version.replace(".", r"\.").replace("+", r"\+") + + +def create_commit_message( + current_version: Version | str, + new_version: Version | str, + message_template: str | None = None, +) -> str: + if message_template is None: + message_template = bump_message + t = Template(message_template) + return t.safe_substitute(current_version=current_version, new_version=new_version) diff --git a/commitizen/changelog.py b/commitizen/changelog.py new file mode 100644 index 0000000..704efe6 --- /dev/null +++ b/commitizen/changelog.py @@ -0,0 +1,353 @@ +"""Design + +## Metadata CHANGELOG.md + +1. Identify irrelevant information (possible: changelog title, first paragraph) +2. Identify Unreleased area +3. Identify latest version (to be able to write on top of it) + +## Parse git log + +1. get commits between versions +2. filter commits with the current cz rules +3. parse commit information +4. yield tree nodes +5. format tree nodes +6. produce full tree +7. generate changelog + +Extra: +- [x] Generate full or partial changelog +- [x] Include in tree from file all the extra comments added manually +- [x] Add unreleased value +- [x] hook after message is parsed (add extra information like hyperlinks) +- [x] hook after changelog is generated (api calls) +- [x] add support for change_type maps +""" + +from __future__ import annotations + +import re +from collections import OrderedDict, defaultdict +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import date +from typing import TYPE_CHECKING + +from jinja2 import ( + BaseLoader, + ChoiceLoader, + Environment, + FileSystemLoader, + Template, +) + +from commitizen.cz.base import ChangelogReleaseHook +from commitizen.exceptions import InvalidConfigurationError, NoCommitsFoundError +from commitizen.git import GitCommit, GitTag +from commitizen.tags import TagRules + +if TYPE_CHECKING: + from commitizen.cz.base import MessageBuilderHook + + +@dataclass +class Metadata: + """ + Metadata extracted from the changelog produced by a plugin + """ + + unreleased_start: int | None = None + unreleased_end: int | None = None + latest_version: str | None = None + latest_version_position: int | None = None + latest_version_tag: str | None = None + + def __post_init__(self): + if self.latest_version and not self.latest_version_tag: + # Test syntactic sugar + # latest version tag is optional if same as latest version + self.latest_version_tag = self.latest_version + + +def get_commit_tag(commit: GitCommit, tags: list[GitTag]) -> GitTag | None: + return next((tag for tag in tags if tag.rev == commit.rev), None) + + +def generate_tree_from_commits( + commits: list[GitCommit], + tags: list[GitTag], + commit_parser: str, + changelog_pattern: str, + unreleased_version: str | None = None, + change_type_map: dict[str, str] | None = None, + changelog_message_builder_hook: MessageBuilderHook | None = None, + changelog_release_hook: ChangelogReleaseHook | None = None, + rules: TagRules | None = None, +) -> Iterable[dict]: + pat = re.compile(changelog_pattern) + map_pat = re.compile(commit_parser, re.MULTILINE) + body_map_pat = re.compile(commit_parser, re.MULTILINE | re.DOTALL) + current_tag: GitTag | None = None + rules = rules or TagRules() + + # Check if the latest commit is not tagged + if commits: + latest_commit = commits[0] + current_tag = get_commit_tag(latest_commit, tags) + + current_tag_name: str = unreleased_version or "Unreleased" + current_tag_date: str = "" + if unreleased_version is not None: + current_tag_date = date.today().isoformat() + if current_tag is not None and current_tag.name: + current_tag_name = current_tag.name + current_tag_date = current_tag.date + + changes: dict = defaultdict(list) + used_tags: list = [current_tag] + for commit in commits: + commit_tag = get_commit_tag(commit, tags) + + if ( + commit_tag + and commit_tag not in used_tags + and rules.include_in_changelog(commit_tag) + ): + used_tags.append(commit_tag) + release = { + "version": current_tag_name, + "date": current_tag_date, + "changes": changes, + } + if changelog_release_hook: + release = changelog_release_hook(release, commit_tag) + yield release + current_tag_name = commit_tag.name + current_tag_date = commit_tag.date + changes = defaultdict(list) + + matches = pat.match(commit.message) + if not matches: + continue + + # Process subject from commit message + if message := map_pat.match(commit.message): + process_commit_message( + changelog_message_builder_hook, + message, + commit, + changes, + change_type_map, + ) + + # Process body from commit message + body_parts = commit.body.split("\n\n") + for body_part in body_parts: + if message := body_map_pat.match(body_part): + process_commit_message( + changelog_message_builder_hook, + message, + commit, + changes, + change_type_map, + ) + + release = { + "version": current_tag_name, + "date": current_tag_date, + "changes": changes, + } + if changelog_release_hook: + release = changelog_release_hook(release, commit_tag) + yield release + + +def process_commit_message( + hook: MessageBuilderHook | None, + parsed: re.Match[str], + commit: GitCommit, + changes: dict[str | None, list], + change_type_map: dict[str, str] | None = None, +): + message: dict = { + "sha1": commit.rev, + "parents": commit.parents, + "author": commit.author, + "author_email": commit.author_email, + **parsed.groupdict(), + } + + if processed := hook(message, commit) if hook else message: + messages = [processed] if isinstance(processed, dict) else processed + for msg in messages: + change_type = msg.pop("change_type", None) + if change_type_map: + change_type = change_type_map.get(change_type, change_type) + changes[change_type].append(msg) + + +def order_changelog_tree(tree: Iterable, change_type_order: list[str]) -> Iterable: + if len(set(change_type_order)) != len(change_type_order): + raise InvalidConfigurationError( + f"Change types contain duplicates types ({change_type_order})" + ) + + sorted_tree = [] + for entry in tree: + ordered_change_types = change_type_order + sorted( + set(entry["changes"].keys()) - set(change_type_order) + ) + changes = [ + (ct, entry["changes"][ct]) + for ct in ordered_change_types + if ct in entry["changes"] + ] + sorted_tree.append({**entry, **{"changes": OrderedDict(changes)}}) + return sorted_tree + + +def get_changelog_template(loader: BaseLoader, template: str) -> Template: + loader = ChoiceLoader( + [ + FileSystemLoader("."), + loader, + ] + ) + env = Environment(loader=loader, trim_blocks=True) + return env.get_template(template) + + +def render_changelog( + tree: Iterable, + loader: BaseLoader, + template: str, + **kwargs, +) -> str: + jinja_template = get_changelog_template(loader, template) + changelog: str = jinja_template.render(tree=tree, **kwargs) + return changelog + + +def incremental_build( + new_content: str, lines: list[str], metadata: Metadata +) -> list[str]: + """Takes the original lines and updates with new_content. + + The metadata governs how to remove the old unreleased section and where to place the + new content. + + Args: + lines: The lines from the changelog + new_content: This should be placed somewhere in the lines + metadata: Information about the changelog + + Returns: + Updated lines + """ + unreleased_start = metadata.unreleased_start + unreleased_end = metadata.unreleased_end + latest_version_position = metadata.latest_version_position + skip = False + output_lines: list[str] = [] + for index, line in enumerate(lines): + if index == unreleased_start: + skip = True + elif index == unreleased_end: + skip = False + if ( + latest_version_position is None + or isinstance(latest_version_position, int) + and isinstance(unreleased_end, int) + and latest_version_position > unreleased_end + ): + continue + + if skip: + continue + + if index == latest_version_position: + output_lines.extend([new_content, "\n"]) + + output_lines.append(line) + if not isinstance(latest_version_position, int): + if output_lines and output_lines[-1].strip(): + # Ensure at least one blank line between existing and new content. + output_lines.append("\n") + output_lines.append(new_content) + return output_lines + + +def get_smart_tag_range( + tags: list[GitTag], newest: str, oldest: str | None = None +) -> list[GitTag]: + """Smart because it finds the N+1 tag. + + This is because we need to find until the next tag + """ + accumulator = [] + keep = False + if not oldest: + oldest = newest + for index, tag in enumerate(tags): + if tag.name == newest: + keep = True + if keep: + accumulator.append(tag) + if tag.name == oldest: + keep = False + try: + accumulator.append(tags[index + 1]) + except IndexError: + pass + break + return accumulator + + +def get_oldest_and_newest_rev( + tags: list[GitTag], + version: str, + rules: TagRules, +) -> tuple[str | None, str | None]: + """Find the tags for the given version. + + `version` may come in different formats: + - `0.1.0..0.4.0`: as a range + - `0.3.0`: as a single version + """ + oldest: str | None = None + newest: str | None = None + try: + oldest, newest = version.split("..") + except ValueError: + newest = version + if not (newest_tag := rules.find_tag_for(tags, newest)): + raise NoCommitsFoundError("Could not find a valid revision range.") + + oldest_tag = None + oldest_tag_name = None + if oldest: + if not (oldest_tag := rules.find_tag_for(tags, oldest)): + raise NoCommitsFoundError("Could not find a valid revision range.") + oldest_tag_name = oldest_tag.name + + tags_range = get_smart_tag_range( + tags, newest=newest_tag.name, oldest=oldest_tag_name + ) + if not tags_range: + raise NoCommitsFoundError("Could not find a valid revision range.") + + oldest_rev: str | None = tags_range[-1].name + newest_rev = newest_tag.name + + # check if it's the first tag created + # and it's also being requested as part of the range + if oldest_rev == tags[-1].name and oldest_rev == oldest_tag_name: + return None, newest_rev + + # when they are the same, and it's also the + # first tag created + if oldest_rev == newest_rev: + return None, newest_rev + + return oldest_rev, newest_rev diff --git a/commitizen/changelog_formats/__init__.py b/commitizen/changelog_formats/__init__.py new file mode 100644 index 0000000..782bfb2 --- /dev/null +++ b/commitizen/changelog_formats/__init__.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import sys +from typing import ClassVar, Protocol + +if sys.version_info >= (3, 10): + from importlib import metadata +else: + import importlib_metadata as metadata + +from commitizen.changelog import Metadata +from commitizen.config.base_config import BaseConfig +from commitizen.exceptions import ChangelogFormatUnknown + +CHANGELOG_FORMAT_ENTRYPOINT = "commitizen.changelog_format" +TEMPLATE_EXTENSION = "j2" + + +class ChangelogFormat(Protocol): + extension: ClassVar[str] + """Standard known extension associated with this format""" + + alternative_extensions: ClassVar[set[str]] + """Known alternatives extensions for this format""" + + config: BaseConfig + + def __init__(self, config: BaseConfig): + self.config = config + + @property + def ext(self) -> str: + """Dotted version of extensions, as in `pathlib` and `os` modules""" + return f".{self.extension}" + + @property + def template(self) -> str: + """Expected template name for this format""" + return f"CHANGELOG.{self.extension}.{TEMPLATE_EXTENSION}" + + @property + def default_changelog_file(self) -> str: + return f"CHANGELOG.{self.extension}" + + def get_metadata(self, filepath: str) -> Metadata: + """ + Extract the changelog metadata. + """ + raise NotImplementedError + + +KNOWN_CHANGELOG_FORMATS: dict[str, type[ChangelogFormat]] = { + ep.name: ep.load() + for ep in metadata.entry_points(group=CHANGELOG_FORMAT_ENTRYPOINT) +} + + +def get_changelog_format( + config: BaseConfig, filename: str | None = None +) -> ChangelogFormat: + """ + Get a format from its name + + :raises FormatUnknown: if a non-empty name is provided but cannot be found in the known formats + """ + name: str | None = config.settings.get("changelog_format") + format: type[ChangelogFormat] | None = guess_changelog_format(filename) + + if name and name in KNOWN_CHANGELOG_FORMATS: + format = KNOWN_CHANGELOG_FORMATS[name] + + if not format: + raise ChangelogFormatUnknown(f"Unknown changelog format '{name}'") + + return format(config) + + +def guess_changelog_format(filename: str | None) -> type[ChangelogFormat] | None: + """ + Try guessing the file format from the filename. + + Algorithm is basic, extension-based, and won't work + for extension-less file names like `CHANGELOG` or `NEWS`. + """ + if not filename or not isinstance(filename, str): + return None + for format in KNOWN_CHANGELOG_FORMATS.values(): + if filename.endswith(f".{format.extension}"): + return format + for alt_extension in format.alternative_extensions: + if filename.endswith(f".{alt_extension}"): + return format + return None diff --git a/commitizen/changelog_formats/asciidoc.py b/commitizen/changelog_formats/asciidoc.py new file mode 100644 index 0000000..ed3e860 --- /dev/null +++ b/commitizen/changelog_formats/asciidoc.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from .base import BaseFormat + +if TYPE_CHECKING: + from commitizen.tags import VersionTag + + +class AsciiDoc(BaseFormat): + extension = "adoc" + + RE_TITLE = re.compile(r"^(?P=+) (?P.*)$") + + def parse_version_from_title(self, line: str) -> VersionTag | None: + m = self.RE_TITLE.match(line) + if not m: + return None + # Capture last match as AsciiDoc use postfixed URL labels + return self.tag_rules.search_version(m.group("title"), last=True) + + def parse_title_level(self, line: str) -> int | None: + m = self.RE_TITLE.match(line) + if not m: + return None + return len(m.group("level")) diff --git a/commitizen/changelog_formats/base.py b/commitizen/changelog_formats/base.py new file mode 100644 index 0000000..f69cf8f --- /dev/null +++ b/commitizen/changelog_formats/base.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import os +from abc import ABCMeta +from typing import IO, Any, ClassVar + +from commitizen.changelog import Metadata +from commitizen.config.base_config import BaseConfig +from commitizen.tags import TagRules, VersionTag +from commitizen.version_schemes import get_version_scheme + +from . import ChangelogFormat + + +class BaseFormat(ChangelogFormat, metaclass=ABCMeta): + """ + Base class to extend to implement a changelog file format. + """ + + extension: ClassVar[str] = "" + alternative_extensions: ClassVar[set[str]] = set() + + def __init__(self, config: BaseConfig): + # Constructor needs to be redefined because `Protocol` prevent instantiation by default + # See: https://bugs.python.org/issue44807 + self.config = config + self.encoding = self.config.settings["encoding"] + self.tag_format = self.config.settings["tag_format"] + self.tag_rules = TagRules( + scheme=get_version_scheme(self.config.settings), + tag_format=self.tag_format, + legacy_tag_formats=self.config.settings["legacy_tag_formats"], + ignored_tag_formats=self.config.settings["ignored_tag_formats"], + ) + + def get_metadata(self, filepath: str) -> Metadata: + if not os.path.isfile(filepath): + return Metadata() + + with open(filepath, encoding=self.encoding) as changelog_file: + return self.get_metadata_from_file(changelog_file) + + def get_metadata_from_file(self, file: IO[Any]) -> Metadata: + meta = Metadata() + unreleased_level: int | None = None + for index, line in enumerate(file): + line = line.strip().lower() + + unreleased: int | None = None + if "unreleased" in line: + unreleased = self.parse_title_level(line) + # Try to find beginning and end lines of the unreleased block + if unreleased: + meta.unreleased_start = index + unreleased_level = unreleased + continue + elif unreleased_level and self.parse_title_level(line) == unreleased_level: + meta.unreleased_end = index + + # Try to find the latest release done + parsed = self.parse_version_from_title(line) + if parsed: + meta.latest_version = parsed.version + meta.latest_version_tag = parsed.tag + meta.latest_version_position = index + break # there's no need for more info + if meta.unreleased_start is not None and meta.unreleased_end is None: + meta.unreleased_end = index + + return meta + + def parse_version_from_title(self, line: str) -> VersionTag | None: + """ + Extract the version from a title line if any + """ + raise NotImplementedError( + "Default `get_metadata_from_file` requires `parse_version_from_changelog` to be implemented" + ) + + def parse_title_level(self, line: str) -> int | None: + """ + Get the title level/type of a line if any + """ + raise NotImplementedError( + "Default `get_metadata_from_file` requires `parse_title_type_of_line` to be implemented" + ) diff --git a/commitizen/changelog_formats/markdown.py b/commitizen/changelog_formats/markdown.py new file mode 100644 index 0000000..e3d30fe --- /dev/null +++ b/commitizen/changelog_formats/markdown.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from .base import BaseFormat + +if TYPE_CHECKING: + from commitizen.tags import VersionTag + + +class Markdown(BaseFormat): + extension = "md" + + alternative_extensions = {"markdown", "mkd"} + + RE_TITLE = re.compile(r"^(?P<level>#+) (?P<title>.*)$") + + def parse_version_from_title(self, line: str) -> VersionTag | None: + m = self.RE_TITLE.match(line) + if not m: + return None + return self.tag_rules.search_version(m.group("title")) + + def parse_title_level(self, line: str) -> int | None: + m = self.RE_TITLE.match(line) + if not m: + return None + return len(m.group("level")) diff --git a/commitizen/changelog_formats/restructuredtext.py b/commitizen/changelog_formats/restructuredtext.py new file mode 100644 index 0000000..b7e4e10 --- /dev/null +++ b/commitizen/changelog_formats/restructuredtext.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import sys +from itertools import zip_longest +from typing import IO, TYPE_CHECKING, Any, Union + +from commitizen.changelog import Metadata + +from .base import BaseFormat + +if TYPE_CHECKING: + # TypeAlias is Python 3.10+ but backported in typing-extensions + if sys.version_info >= (3, 10): + from typing import TypeAlias + else: + from typing_extensions import TypeAlias + + +# Can't use `|` operator and native type because of https://bugs.python.org/issue42233 only fixed in 3.10 +TitleKind: TypeAlias = Union[str, tuple[str, str]] + + +class RestructuredText(BaseFormat): + extension = "rst" + + def get_metadata_from_file(self, file: IO[Any]) -> Metadata: + """ + RestructuredText section titles are not one-line-based, + they spread on 2 or 3 lines and levels are not predefined + but determined byt their occurrence order. + + It requires its own algorithm. + + For a more generic approach, you need to rely on `docutils`. + """ + meta = Metadata() + unreleased_title_kind: TitleKind | None = None + in_overlined_title = False + lines = file.readlines() + for index, (first, second, third) in enumerate( + zip_longest(lines, lines[1:], lines[2:], fillvalue="") + ): + first = first.strip().lower() + second = second.strip().lower() + third = third.strip().lower() + title: str | None = None + kind: TitleKind | None = None + if self.is_overlined_title(first, second, third): + title = second + kind = (first[0], third[0]) + in_overlined_title = True + elif not in_overlined_title and self.is_underlined_title(first, second): + title = first + kind = second[0] + else: + in_overlined_title = False + + if title: + if "unreleased" in title: + unreleased_title_kind = kind + meta.unreleased_start = index + continue + elif unreleased_title_kind and unreleased_title_kind == kind: + meta.unreleased_end = index + # Try to find the latest release done + if version := self.tag_rules.search_version(title): + meta.latest_version = version[0] + meta.latest_version_tag = version[1] + meta.latest_version_position = index + break + if meta.unreleased_start is not None and meta.unreleased_end is None: + meta.unreleased_end = ( + meta.latest_version_position if meta.latest_version else index + 1 + ) + + return meta + + def is_overlined_title(self, first: str, second: str, third: str) -> bool: + return ( + len(first) >= len(second) + and len(first) == len(third) + and all(char == first[0] for char in first[1:]) + and first[0] == third[0] + and self.is_underlined_title(second, third) + ) + + def is_underlined_title(self, first: str, second: str) -> bool: + return ( + len(second) >= len(first) + and not second.isalnum() + and all(char == second[0] for char in second[1:]) + ) diff --git a/commitizen/changelog_formats/textile.py b/commitizen/changelog_formats/textile.py new file mode 100644 index 0000000..6693e5e --- /dev/null +++ b/commitizen/changelog_formats/textile.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from .base import BaseFormat + +if TYPE_CHECKING: + from commitizen.tags import VersionTag + + +class Textile(BaseFormat): + extension = "textile" + + RE_TITLE = re.compile(r"^h(?P<level>\d)\. (?P<title>.*)$") + + def parse_version_from_title(self, line: str) -> VersionTag | None: + if not self.RE_TITLE.match(line): + return None + return self.tag_rules.search_version(line) + + def parse_title_level(self, line: str) -> int | None: + m = self.RE_TITLE.match(line) + if not m: + return None + return int(m.group("level")) diff --git a/commitizen/cli.py b/commitizen/cli.py new file mode 100644 index 0000000..72d8243 --- /dev/null +++ b/commitizen/cli.py @@ -0,0 +1,660 @@ +from __future__ import annotations + +import argparse +import logging +import sys +from collections.abc import Sequence +from copy import deepcopy +from functools import partial +from pathlib import Path +from types import TracebackType +from typing import Any + +import argcomplete +from decli import cli + +from commitizen import commands, config, out, version_schemes +from commitizen.exceptions import ( + CommitizenException, + ExitCode, + ExpectedExit, + InvalidCommandArgumentError, + NoCommandFoundError, +) + +logger = logging.getLogger(__name__) + + +class ParseKwargs(argparse.Action): + """ + Parse arguments in the for `key=value`. + + Quoted strings are automatically unquoted. + Can be submitted multiple times: + + ex: + -k key=value -k double-quotes="value" -k single-quotes='value' + + will result in + + namespace["opt"] == { + "key": "value", + "double-quotes": "value", + "single-quotes": "value", + } + """ + + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + kwarg: str | Sequence[Any] | None, + option_string: str | None = None, + ): + if not isinstance(kwarg, str): + return + if "=" not in kwarg: + raise InvalidCommandArgumentError( + f"Option {option_string} expect a key=value format" + ) + kwargs = getattr(namespace, self.dest, None) or {} + key, value = kwarg.split("=", 1) + if not key: + raise InvalidCommandArgumentError( + f"Option {option_string} expect a key=value format" + ) + kwargs[key] = value.strip("'\"") + setattr(namespace, self.dest, kwargs) + + +tpl_arguments = ( + { + "name": ["--template", "-t"], + "help": ( + "changelog template file name (relative to the current working directory)" + ), + }, + { + "name": ["--extra", "-e"], + "action": ParseKwargs, + "dest": "extras", + "metavar": "EXTRA", + "help": "a changelog extra variable (in the form 'key=value')", + }, +) + +data = { + "prog": "cz", + "description": ( + "Commitizen is a cli tool to generate conventional commits.\n" + "For more information about the topic go to " + "https://conventionalcommits.org/" + ), + "formatter_class": argparse.RawDescriptionHelpFormatter, + "arguments": [ + { + "name": "--config", + "help": "the path of configuration file", + }, + {"name": "--debug", "action": "store_true", "help": "use debug mode"}, + { + "name": ["-n", "--name"], + "help": "use the given commitizen (default: cz_conventional_commits)", + }, + { + "name": ["-nr", "--no-raise"], + "type": str, + "required": False, + "help": "comma separated error codes that won't rise error, e.g: cz -nr 1,2,3 bump. See codes at https://commitizen-tools.github.io/commitizen/exit_codes/", + }, + ], + "subcommands": { + "title": "commands", + "required": True, + "commands": [ + { + "name": ["init"], + "description": "init commitizen configuration", + "help": "init commitizen configuration", + "func": commands.Init, + }, + { + "name": ["commit", "c"], + "description": "create new commit", + "help": "create new commit", + "func": commands.Commit, + "arguments": [ + { + "name": ["--retry"], + "action": "store_true", + "help": "retry last commit", + }, + { + "name": ["--no-retry"], + "action": "store_true", + "default": False, + "help": "skip retry if retry_after_failure is set to true", + }, + { + "name": "--dry-run", + "action": "store_true", + "help": "show output to stdout, no commit, no modified files", + }, + { + "name": "--write-message-to-file", + "type": Path, + "metavar": "FILE_PATH", + "help": "write message to file before committing (can be combined with --dry-run)", + }, + { + "name": ["-s", "--signoff"], + "action": "store_true", + "help": "sign off the commit", + }, + { + "name": ["-a", "--all"], + "action": "store_true", + "help": "Tell the command to automatically stage files that have been modified and deleted, but new files you have not told Git about are not affected.", + }, + { + "name": ["-e", "--edit"], + "action": "store_true", + "default": False, + "help": "edit the commit message before committing", + }, + { + "name": ["-l", "--message-length-limit"], + "type": int, + "default": 0, + "help": "length limit of the commit message; 0 for no limit", + }, + { + "name": ["--"], + "action": "store_true", + "dest": "double_dash", + "help": "Positional arguments separator (recommended)", + }, + ], + }, + { + "name": "ls", + "description": "show available commitizens", + "help": "show available commitizens", + "func": commands.ListCz, + }, + { + "name": "example", + "description": "show commit example", + "help": "show commit example", + "func": commands.Example, + }, + { + "name": "info", + "description": "show information about the cz", + "help": "show information about the cz", + "func": commands.Info, + }, + { + "name": "schema", + "description": "show commit schema", + "help": "show commit schema", + "func": commands.Schema, + }, + { + "name": "bump", + "description": "bump semantic version based on the git log", + "help": "bump semantic version based on the git log", + "func": commands.Bump, + "arguments": [ + { + "name": "--dry-run", + "action": "store_true", + "help": "show output to stdout, no commit, no modified files", + }, + { + "name": "--files-only", + "action": "store_true", + "help": "bump version in the files from the config", + }, + { + "name": "--local-version", + "action": "store_true", + "help": "bump only the local version portion", + }, + { + "name": ["--changelog", "-ch"], + "action": "store_true", + "default": False, + "help": "generate the changelog for the newest version", + }, + { + "name": ["--no-verify"], + "action": "store_true", + "default": False, + "help": "this option bypasses the pre-commit and commit-msg hooks", + }, + { + "name": "--yes", + "action": "store_true", + "help": "accept automatically questions done", + }, + { + "name": "--tag-format", + "help": ( + "the format used to tag the commit and read it, " + "use it in existing projects, " + "wrap around simple quotes" + ), + }, + { + "name": "--bump-message", + "help": ( + "template used to create the release commit, " + "useful when working with CI" + ), + }, + { + "name": ["--prerelease", "-pr"], + "help": "choose type of prerelease", + "choices": ["alpha", "beta", "rc"], + }, + { + "name": ["--devrelease", "-d"], + "help": "specify non-negative integer for dev. release", + "type": int, + }, + { + "name": ["--increment"], + "help": "manually specify the desired increment", + "choices": ["MAJOR", "MINOR", "PATCH"], + "type": str.upper, + }, + { + "name": ["--increment-mode"], + "choices": ["linear", "exact"], + "default": "linear", + "help": ( + "set the method by which the new version is chosen. " + "'linear' (default) guesses the next version based on typical linear version progression, " + "such that bumping of a pre-release with lower precedence than the current pre-release " + "phase maintains the current phase of higher precedence. " + "'exact' applies the changes that have been specified (or determined from the commit log) " + "without interpretation, such that the increment and pre-release are always honored" + ), + }, + { + "name": ["--check-consistency", "-cc"], + "help": ( + "check consistency among versions defined in " + "commitizen configuration and version_files" + ), + "action": "store_true", + }, + { + "name": ["--annotated-tag", "-at"], + "help": "create annotated tag instead of lightweight one", + "action": "store_true", + }, + { + "name": ["--annotated-tag-message", "-atm"], + "help": "create annotated tag message", + "type": str, + }, + { + "name": ["--gpg-sign", "-s"], + "help": "sign tag instead of lightweight one", + "default": False, + "action": "store_true", + }, + { + "name": ["--changelog-to-stdout"], + "action": "store_true", + "default": False, + "help": "Output changelog to the stdout", + }, + { + "name": ["--git-output-to-stderr"], + "action": "store_true", + "default": False, + "help": "Redirect git output to stderr", + }, + { + "name": ["--retry"], + "action": "store_true", + "default": False, + "help": "retry commit if it fails the 1st time", + }, + { + "name": ["--major-version-zero"], + "action": "store_true", + "default": None, + "help": "keep major version at zero, even for breaking changes", + }, + *deepcopy(tpl_arguments), + { + "name": "--file-name", + "help": "file name of changelog (default: 'CHANGELOG.md')", + }, + { + "name": ["--prerelease-offset"], + "type": int, + "default": None, + "help": "start pre-releases with this offset", + }, + { + "name": ["--version-scheme"], + "help": "choose version scheme", + "default": None, + "choices": version_schemes.KNOWN_SCHEMES, + }, + { + "name": ["--version-type"], + "help": "Deprecated, use --version-scheme", + "default": None, + "choices": version_schemes.KNOWN_SCHEMES, + }, + { + "name": "manual_version", + "type": str, + "nargs": "?", + "help": "bump to the given version (e.g: 1.5.3)", + "metavar": "MANUAL_VERSION", + }, + { + "name": ["--build-metadata"], + "help": "Add additional build-metadata to the version-number", + "default": None, + }, + { + "name": ["--get-next"], + "action": "store_true", + "help": "Determine the next version and write to stdout", + "default": False, + }, + { + "name": ["--allow-no-commit"], + "default": False, + "help": "bump version without eligible commits", + "action": "store_true", + }, + ], + }, + { + "name": ["changelog", "ch"], + "description": ( + "generate changelog (note that it will overwrite existing file)" + ), + "help": ( + "generate changelog (note that it will overwrite existing file)" + ), + "func": commands.Changelog, + "arguments": [ + { + "name": "--dry-run", + "action": "store_true", + "default": False, + "help": "show changelog to stdout", + }, + { + "name": "--file-name", + "help": "file name of changelog (default: 'CHANGELOG.md')", + }, + { + "name": "--unreleased-version", + "help": ( + "set the value for the new version (use the tag value), " + "instead of using unreleased" + ), + }, + { + "name": "--incremental", + "action": "store_true", + "default": False, + "help": ( + "generates changelog from last created version, " + "useful if the changelog has been manually modified" + ), + }, + { + "name": "rev_range", + "type": str, + "nargs": "?", + "help": "generates changelog for the given version (e.g: 1.5.3) or version range (e.g: 1.5.3..1.7.9)", + }, + { + "name": "--start-rev", + "default": None, + "help": ( + "start rev of the changelog. " + "If not set, it will generate changelog from the start" + ), + }, + { + "name": "--merge-prerelease", + "action": "store_true", + "default": False, + "help": ( + "collect all changes from prereleases into next non-prerelease. " + "If not set, it will include prereleases in the changelog" + ), + }, + { + "name": ["--version-scheme"], + "help": "choose version scheme", + "default": None, + "choices": version_schemes.KNOWN_SCHEMES, + }, + { + "name": "--export-template", + "default": None, + "help": "Export the changelog template into this file instead of rendering it", + }, + *deepcopy(tpl_arguments), + ], + }, + { + "name": ["check"], + "description": "validates that a commit message matches the commitizen schema", + "help": "validates that a commit message matches the commitizen schema", + "func": commands.Check, + "arguments": [ + { + "name": "--commit-msg-file", + "help": ( + "ask for the name of the temporal file that contains " + "the commit message. " + "Using it in a git hook script: MSG_FILE=$1" + ), + "exclusive_group": "group1", + }, + { + "name": "--rev-range", + "help": "a range of git rev to check. e.g, master..HEAD", + "exclusive_group": "group1", + }, + { + "name": ["-m", "--message"], + "help": "commit message that needs to be checked", + "exclusive_group": "group1", + }, + { + "name": ["--allow-abort"], + "action": "store_true", + "default": False, + "help": "allow empty commit messages, which typically abort a commit", + }, + { + "name": ["--allowed-prefixes"], + "nargs": "*", + "help": "allowed commit message prefixes. " + "If the message starts by one of these prefixes, " + "the message won't be checked against the regex", + }, + { + "name": ["-l", "--message-length-limit"], + "type": int, + "default": 0, + "help": "length limit of the commit message; 0 for no limit", + }, + ], + }, + { + "name": ["version"], + "description": ( + "get the version of the installed commitizen or the current project" + " (default: installed commitizen)" + ), + "help": ( + "get the version of the installed commitizen or the current project" + " (default: installed commitizen)" + ), + "func": commands.Version, + "arguments": [ + { + "name": ["-r", "--report"], + "help": "get system information for reporting bugs", + "action": "store_true", + "exclusive_group": "group1", + }, + { + "name": ["-p", "--project"], + "help": "get the version of the current project", + "action": "store_true", + "exclusive_group": "group1", + }, + { + "name": ["-c", "--commitizen"], + "help": "get the version of the installed commitizen", + "action": "store_true", + "exclusive_group": "group1", + }, + { + "name": ["-v", "--verbose"], + "help": ( + "get the version of both the installed commitizen " + "and the current project" + ), + "action": "store_true", + "exclusive_group": "group1", + }, + ], + }, + ], + }, +} + +original_excepthook = sys.excepthook + + +def commitizen_excepthook( + type, value, traceback, debug=False, no_raise: list[int] | None = None +): + traceback = traceback if isinstance(traceback, TracebackType) else None + if not no_raise: + no_raise = [] + if isinstance(value, CommitizenException): + if value.message: + value.output_method(value.message) + if debug: + original_excepthook(type, value, traceback) + exit_code = value.exit_code + if exit_code in no_raise: + exit_code = ExitCode.EXPECTED_EXIT + sys.exit(exit_code) + else: + original_excepthook(type, value, traceback) + + +commitizen_debug_excepthook = partial(commitizen_excepthook, debug=True) + +sys.excepthook = commitizen_excepthook + + +def parse_no_raise(comma_separated_no_raise: str) -> list[int]: + """Convert the given string to exit codes. + + Receives digits and strings and outputs the parsed integer which + represents the exit code found in exceptions. + """ + no_raise_items: list[str] = comma_separated_no_raise.split(",") + no_raise_codes = [] + for item in no_raise_items: + if item.isdecimal(): + no_raise_codes.append(int(item)) + continue + try: + exit_code = ExitCode[item.strip()] + except KeyError: + out.warn(f"WARN: no_raise key `{item}` does not exist. Skipping.") + continue + else: + no_raise_codes.append(exit_code.value) + return no_raise_codes + + +def main(): + parser = cli(data) + argcomplete.autocomplete(parser) + # Show help if no arg provided + if len(sys.argv) == 1: + parser.print_help(sys.stderr) + raise ExpectedExit() + + # This is for the command required constraint in 2.0 + try: + args, unknown_args = parser.parse_known_args() + except (TypeError, SystemExit) as e: + # https://github.com/commitizen-tools/commitizen/issues/429 + # argparse raises TypeError when non exist command is provided on Python < 3.9 + # but raise SystemExit with exit code == 2 on Python 3.9 + if isinstance(e, TypeError) or (isinstance(e, SystemExit) and e.code == 2): + raise NoCommandFoundError() + raise e + + arguments = vars(args) + if unknown_args: + # Raise error for extra-args without -- separation + if "--" not in unknown_args: + raise InvalidCommandArgumentError( + f"Invalid commitizen arguments were found: `{' '.join(unknown_args)}`. " + "Please use -- separator for extra git args" + ) + # Raise error for extra-args before -- + elif unknown_args[0] != "--": + pos = unknown_args.index("--") + raise InvalidCommandArgumentError( + f"Invalid commitizen arguments were found before -- separator: `{' '.join(unknown_args[:pos])}`. " + ) + # Log warning for -- without any extra args + elif len(unknown_args) == 1: + logger.warning( + "\nWARN: Incomplete commit command: received -- separator without any following git arguments\n" + ) + extra_args = " ".join(unknown_args[1:]) + arguments["extra_cli_args"] = extra_args + + if args.config: + conf = config.read_cfg(args.config) + else: + conf = config.read_cfg() + + if args.name: + conf.update({"name": args.name}) + elif not args.name and not conf.path: + conf.update({"name": "cz_conventional_commits"}) + + if args.debug: + logging.getLogger("commitizen").setLevel(logging.DEBUG) + sys.excepthook = commitizen_debug_excepthook + elif args.no_raise: + no_raise_exit_codes = parse_no_raise(args.no_raise) + no_raise_debug_excepthook = partial( + commitizen_excepthook, no_raise=no_raise_exit_codes + ) + sys.excepthook = no_raise_debug_excepthook + + args.func(conf, arguments)() + + +if __name__ == "__main__": + main() diff --git a/commitizen/cmd.py b/commitizen/cmd.py new file mode 100644 index 0000000..ba48ac7 --- /dev/null +++ b/commitizen/cmd.py @@ -0,0 +1,50 @@ +import os +import subprocess +from typing import NamedTuple + +from charset_normalizer import from_bytes + +from commitizen.exceptions import CharacterSetDecodeError + + +class Command(NamedTuple): + out: str + err: str + stdout: bytes + stderr: bytes + return_code: int + + +def _try_decode(bytes_: bytes) -> str: + try: + return bytes_.decode("utf-8") + except UnicodeDecodeError: + charset_match = from_bytes(bytes_).best() + if charset_match is None: + raise CharacterSetDecodeError() + try: + return bytes_.decode(charset_match.encoding) + except UnicodeDecodeError as e: + raise CharacterSetDecodeError() from e + + +def run(cmd: str, env=None) -> Command: + if env is not None: + env = {**os.environ, **env} + process = subprocess.Popen( + cmd, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=subprocess.PIPE, + env=env, + ) + stdout, stderr = process.communicate() + return_code = process.returncode + return Command( + _try_decode(stdout), + _try_decode(stderr), + stdout, + stderr, + return_code, + ) diff --git a/commitizen/commands/__init__.py b/commitizen/commands/__init__.py new file mode 100644 index 0000000..806e384 --- /dev/null +++ b/commitizen/commands/__init__.py @@ -0,0 +1,23 @@ +from .bump import Bump +from .changelog import Changelog +from .check import Check +from .commit import Commit +from .example import Example +from .info import Info +from .init import Init +from .list_cz import ListCz +from .schema import Schema +from .version import Version + +__all__ = ( + "Bump", + "Check", + "Commit", + "Changelog", + "Example", + "Info", + "ListCz", + "Schema", + "Version", + "Init", +) diff --git a/commitizen/commands/bump.py b/commitizen/commands/bump.py new file mode 100644 index 0000000..6085309 --- /dev/null +++ b/commitizen/commands/bump.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +import warnings +from logging import getLogger +from typing import cast + +import questionary + +from commitizen import bump, factory, git, hooks, out +from commitizen.changelog_formats import get_changelog_format +from commitizen.commands.changelog import Changelog +from commitizen.config import BaseConfig +from commitizen.defaults import Settings +from commitizen.exceptions import ( + BumpCommitFailedError, + BumpTagFailedError, + DryRunExit, + ExpectedExit, + GetNextExit, + InvalidManualVersion, + NoCommitsFoundError, + NoneIncrementExit, + NoPatternMapError, + NotAGitProjectError, + NotAllowed, + NoVersionSpecifiedError, +) +from commitizen.providers import get_provider +from commitizen.tags import TagRules +from commitizen.version_schemes import ( + Increment, + InvalidVersion, + Prerelease, + get_version_scheme, +) + +logger = getLogger("commitizen") + + +class Bump: + """Show prompt for the user to create a guided commit.""" + + def __init__(self, config: BaseConfig, arguments: dict): + if not git.is_git_project(): + raise NotAGitProjectError() + + self.config: BaseConfig = config + self.encoding = config.settings["encoding"] + self.arguments: dict = arguments + self.bump_settings: dict = { + **config.settings, + **{ + key: arguments[key] + for key in [ + "tag_format", + "prerelease", + "increment", + "increment_mode", + "bump_message", + "gpg_sign", + "annotated_tag", + "annotated_tag_message", + "major_version_zero", + "prerelease_offset", + "template", + "file_name", + ] + if arguments[key] is not None + }, + } + self.cz = factory.commiter_factory(self.config) + self.changelog_flag = arguments["changelog"] + self.changelog_config = self.config.settings.get("update_changelog_on_bump") + self.changelog_to_stdout = arguments["changelog_to_stdout"] + self.git_output_to_stderr = arguments["git_output_to_stderr"] + self.no_verify = arguments["no_verify"] + self.check_consistency = arguments["check_consistency"] + self.retry = arguments["retry"] + self.pre_bump_hooks = self.config.settings["pre_bump_hooks"] + self.post_bump_hooks = self.config.settings["post_bump_hooks"] + deprecated_version_type = arguments.get("version_type") + if deprecated_version_type: + warnings.warn( + DeprecationWarning( + "`--version-type` parameter is deprecated and will be removed in commitizen 4. " + "Please use `--version-scheme` instead" + ) + ) + self.scheme = get_version_scheme( + self.config.settings, arguments["version_scheme"] or deprecated_version_type + ) + self.file_name = arguments["file_name"] or self.config.settings.get( + "changelog_file" + ) + self.changelog_format = get_changelog_format(self.config, self.file_name) + + self.template = ( + arguments["template"] + or self.config.settings.get("template") + or self.changelog_format.template + ) + self.extras = arguments["extras"] + + def is_initial_tag( + self, current_tag: git.GitTag | None, is_yes: bool = False + ) -> bool: + """Check if reading the whole git tree up to HEAD is needed.""" + is_initial = False + if not current_tag: + if is_yes: + is_initial = True + else: + out.info("No tag matching configuration could not be found.") + out.info( + "Possible causes:\n" + "- version in configuration is not the current version\n" + "- tag_format or legacy_tag_formats is missing, check them using 'git tag --list'\n" + ) + is_initial = questionary.confirm("Is this the first tag created?").ask() + return is_initial + + def find_increment(self, commits: list[git.GitCommit]) -> Increment | None: + # Update the bump map to ensure major version doesn't increment. + is_major_version_zero: bool = self.bump_settings["major_version_zero"] + # self.cz.bump_map = defaults.bump_map_major_version_zero + bump_map = ( + self.cz.bump_map_major_version_zero + if is_major_version_zero + else self.cz.bump_map + ) + bump_pattern = self.cz.bump_pattern + + if not bump_map or not bump_pattern: + raise NoPatternMapError( + f"'{self.config.settings['name']}' rule does not support bump" + ) + increment = bump.find_increment( + commits, regex=bump_pattern, increments_map=bump_map + ) + return increment + + def __call__(self) -> None: # noqa: C901 + """Steps executed to bump.""" + provider = get_provider(self.config) + + try: + current_version = self.scheme(provider.get_version()) + except TypeError: + raise NoVersionSpecifiedError() + + bump_commit_message: str = self.bump_settings["bump_message"] + version_files: list[str] = self.bump_settings["version_files"] + major_version_zero: bool = self.bump_settings["major_version_zero"] + prerelease_offset: int = self.bump_settings["prerelease_offset"] + + dry_run: bool = self.arguments["dry_run"] + is_yes: bool = self.arguments["yes"] + increment: Increment | None = self.arguments["increment"] + prerelease: Prerelease | None = self.arguments["prerelease"] + devrelease: int | None = self.arguments["devrelease"] + is_files_only: bool | None = self.arguments["files_only"] + is_local_version: bool = self.arguments["local_version"] + manual_version = self.arguments["manual_version"] + build_metadata = self.arguments["build_metadata"] + increment_mode: str = self.arguments["increment_mode"] + get_next: bool = self.arguments["get_next"] + allow_no_commit: bool | None = self.arguments["allow_no_commit"] + + if manual_version: + if increment: + raise NotAllowed("--increment cannot be combined with MANUAL_VERSION") + + if prerelease: + raise NotAllowed("--prerelease cannot be combined with MANUAL_VERSION") + + if devrelease is not None: + raise NotAllowed("--devrelease cannot be combined with MANUAL_VERSION") + + if is_local_version: + raise NotAllowed( + "--local-version cannot be combined with MANUAL_VERSION" + ) + + if build_metadata: + raise NotAllowed( + "--build-metadata cannot be combined with MANUAL_VERSION" + ) + + if major_version_zero: + raise NotAllowed( + "--major-version-zero cannot be combined with MANUAL_VERSION" + ) + + if get_next: + raise NotAllowed("--get-next cannot be combined with MANUAL_VERSION") + + if major_version_zero: + if not current_version.release[0] == 0: + raise NotAllowed( + f"--major-version-zero is meaningless for current version {current_version}" + ) + + if build_metadata: + if is_local_version: + raise NotAllowed( + "--local-version cannot be combined with --build-metadata" + ) + + if get_next: + # if trying to use --get-next, we should not allow --changelog or --changelog-to-stdout + if self.changelog_flag or bool(self.changelog_to_stdout): + raise NotAllowed( + "--changelog or --changelog-to-stdout is not allowed with --get-next" + ) + # --get-next is a special case, taking precedence over config for 'update_changelog_on_bump' + self.changelog_config = False + # Setting dry_run to prevent any unwanted changes to the repo or files + self.dry_run = True + else: + # If user specified changelog_to_stdout, they probably want the + # changelog to be generated as well, this is the most intuitive solution + self.changelog_flag = ( + self.changelog_flag + or bool(self.changelog_to_stdout) + or self.changelog_config + ) + + rules = TagRules.from_settings(cast(Settings, self.bump_settings)) + current_tag = rules.find_tag_for(git.get_tags(), current_version) + current_tag_version = getattr( + current_tag, "name", rules.normalize_tag(current_version) + ) + + is_initial = self.is_initial_tag(current_tag, is_yes) + + if manual_version: + try: + new_version = self.scheme(manual_version) + except InvalidVersion as exc: + raise InvalidManualVersion( + "[INVALID_MANUAL_VERSION]\n" + f"Invalid manual version: '{manual_version}'" + ) from exc + else: + if increment is None: + if current_tag: + commits = git.get_commits(current_tag.name) + else: + commits = git.get_commits() + + # No commits, there is no need to create an empty tag. + # Unless we previously had a prerelease. + if ( + not commits + and not current_version.is_prerelease + and not allow_no_commit + ): + raise NoCommitsFoundError( + "[NO_COMMITS_FOUND]\nNo new commits found." + ) + + increment = self.find_increment(commits) + + # It may happen that there are commits, but they are not eligible + # for an increment, this generates a problem when using prerelease (#281) + if prerelease and increment is None and not current_version.is_prerelease: + raise NoCommitsFoundError( + "[NO_COMMITS_FOUND]\n" + "No commits found to generate a pre-release.\n" + "To avoid this error, manually specify the type of increment with `--increment`" + ) + + # we create an empty PATCH increment for empty tag + if increment is None and allow_no_commit: + increment = "PATCH" + + new_version = current_version.bump( + increment, + prerelease=prerelease, + prerelease_offset=prerelease_offset, + devrelease=devrelease, + is_local_version=is_local_version, + build_metadata=build_metadata, + exact_increment=increment_mode == "exact", + ) + + new_tag_version = rules.normalize_tag(new_version) + message = bump.create_commit_message( + current_version, new_version, bump_commit_message + ) + + if get_next: + if increment is None and new_tag_version == current_tag_version: + raise NoneIncrementExit( + "[NO_COMMITS_TO_BUMP]\n" + "The commits found are not eligible to be bumped" + ) + + out.write(str(new_version)) + raise GetNextExit() + + # Report found information + information = f"{message}\ntag to create: {new_tag_version}\n" + if increment: + information += f"increment detected: {increment}\n" + + if self.changelog_to_stdout: + # When the changelog goes to stdout, we want to send + # the bump information to stderr, this way the + # changelog output can be captured + out.diagnostic(information) + else: + out.write(information) + + if increment is None and new_tag_version == current_tag_version: + raise NoneIncrementExit( + "[NO_COMMITS_TO_BUMP]\nThe commits found are not eligible to be bumped" + ) + + files: list[str] = [] + if self.changelog_flag: + args = { + "unreleased_version": new_tag_version, + "template": self.template, + "extras": self.extras, + "incremental": True, + "dry_run": dry_run, + } + if self.changelog_to_stdout: + changelog_cmd = Changelog(self.config, {**args, "dry_run": True}) + try: + changelog_cmd() + except DryRunExit: + pass + + args["file_name"] = self.file_name + changelog_cmd = Changelog(self.config, args) + changelog_cmd() + files.append(changelog_cmd.file_name) + + # Do not perform operations over files or git. + if dry_run: + raise DryRunExit() + + files.extend( + bump.update_version_in_files( + str(current_version), + str(new_version), + version_files, + check_consistency=self.check_consistency, + encoding=self.encoding, + ) + ) + + provider.set_version(str(new_version)) + + if self.pre_bump_hooks: + hooks.run( + self.pre_bump_hooks, + _env_prefix="CZ_PRE_", + is_initial=is_initial, + current_version=str(current_version), + current_tag_version=current_tag_version, + new_version=new_version.public, + new_tag_version=new_tag_version, + message=message, + increment=increment, + changelog_file_name=changelog_cmd.file_name + if self.changelog_flag + else None, + ) + + if is_files_only: + raise ExpectedExit() + + # FIXME: check if any changes have been staged + git.add(*files) + c = git.commit(message, args=self._get_commit_args()) + if self.retry and c.return_code != 0 and self.changelog_flag: + # Maybe pre-commit reformatted some files? Retry once + logger.debug("1st git.commit error: %s", c.err) + logger.info("1st commit attempt failed; retrying once") + git.add(*files) + c = git.commit(message, args=self._get_commit_args()) + if c.return_code != 0: + err = c.err.strip() or c.out + raise BumpCommitFailedError(f'2nd git.commit error: "{err}"') + + if c.out: + if self.git_output_to_stderr: + out.diagnostic(c.out) + else: + out.write(c.out) + if c.err: + if self.git_output_to_stderr: + out.diagnostic(c.err) + else: + out.write(c.err) + + c = git.tag( + new_tag_version, + signed=self.bump_settings.get("gpg_sign", False) + or bool(self.config.settings.get("gpg_sign", False)), + annotated=self.bump_settings.get("annotated_tag", False) + or bool(self.config.settings.get("annotated_tag", False)) + or bool(self.bump_settings.get("annotated_tag_message", False)), + msg=self.bump_settings.get("annotated_tag_message", None), + # TODO: also get from self.config.settings? + ) + if c.return_code != 0: + raise BumpTagFailedError(c.err) + + if self.post_bump_hooks: + hooks.run( + self.post_bump_hooks, + _env_prefix="CZ_POST_", + was_initial=is_initial, + previous_version=str(current_version), + previous_tag_version=current_tag_version, + current_version=new_version.public, + current_tag_version=new_tag_version, + message=message, + increment=increment, + changelog_file_name=changelog_cmd.file_name + if self.changelog_flag + else None, + ) + + # TODO: For v3 output this only as diagnostic and remove this if + if self.changelog_to_stdout: + out.diagnostic("Done!") + else: + out.success("Done!") + + def _get_commit_args(self) -> str: + commit_args = ["-a"] + if self.no_verify: + commit_args.append("--no-verify") + return " ".join(commit_args) diff --git a/commitizen/commands/changelog.py b/commitizen/commands/changelog.py new file mode 100644 index 0000000..80a7265 --- /dev/null +++ b/commitizen/commands/changelog.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import os.path +from difflib import SequenceMatcher +from operator import itemgetter +from pathlib import Path +from typing import Callable, cast + +from commitizen import changelog, defaults, factory, git, out +from commitizen.changelog_formats import get_changelog_format +from commitizen.config import BaseConfig +from commitizen.cz.base import ChangelogReleaseHook, MessageBuilderHook +from commitizen.cz.utils import strip_local_version +from commitizen.exceptions import ( + DryRunExit, + NoCommitsFoundError, + NoPatternMapError, + NoRevisionError, + NotAGitProjectError, + NotAllowed, +) +from commitizen.git import GitTag, smart_open +from commitizen.tags import TagRules +from commitizen.version_schemes import get_version_scheme + + +class Changelog: + """Generate a changelog based on the commit history.""" + + def __init__(self, config: BaseConfig, args): + if not git.is_git_project(): + raise NotAGitProjectError() + + self.config: BaseConfig = config + self.encoding = self.config.settings["encoding"] + self.cz = factory.commiter_factory(self.config) + + self.start_rev = args.get("start_rev") or self.config.settings.get( + "changelog_start_rev" + ) + self.file_name = args.get("file_name") or cast( + str, self.config.settings.get("changelog_file") + ) + if not isinstance(self.file_name, str): + raise NotAllowed( + "Changelog file name is broken.\n" + "Check the flag `--file-name` in the terminal " + f"or the setting `changelog_file` in {self.config.path}" + ) + self.changelog_format = get_changelog_format(self.config, self.file_name) + + self.incremental = args["incremental"] or self.config.settings.get( + "changelog_incremental" + ) + self.dry_run = args["dry_run"] + + self.scheme = get_version_scheme( + self.config.settings, args.get("version_scheme") + ) + + current_version = ( + args.get("current_version", config.settings.get("version")) or "" + ) + self.current_version = self.scheme(current_version) if current_version else None + + self.unreleased_version = args["unreleased_version"] + self.change_type_map = ( + self.config.settings.get("change_type_map") or self.cz.change_type_map + ) + self.change_type_order = ( + self.config.settings.get("change_type_order") + or self.cz.change_type_order + or defaults.change_type_order + ) + self.rev_range = args.get("rev_range") + self.tag_format: str = ( + args.get("tag_format") or self.config.settings["tag_format"] + ) + self.tag_rules = TagRules( + scheme=self.scheme, + tag_format=self.tag_format, + legacy_tag_formats=self.config.settings["legacy_tag_formats"], + ignored_tag_formats=self.config.settings["ignored_tag_formats"], + merge_prereleases=args.get("merge_prerelease") + or self.config.settings["changelog_merge_prerelease"], + ) + + self.template = ( + args.get("template") + or self.config.settings.get("template") + or self.changelog_format.template + ) + self.extras = args.get("extras") or {} + self.export_template_to = args.get("export_template") + + def _find_incremental_rev(self, latest_version: str, tags: list[GitTag]) -> str: + """Try to find the 'start_rev'. + + We use a similarity approach. We know how to parse the version from the markdown + changelog, but not the whole tag, we don't even know how's the tag made. + + This 'smart' function tries to find a similarity between the found version number + and the available tag. + + The SIMILARITY_THRESHOLD is an empirical value, it may have to be adjusted based + on our experience. + """ + SIMILARITY_THRESHOLD = 0.89 + tag_ratio = map( + lambda tag: ( + SequenceMatcher( + None, latest_version, strip_local_version(tag.name) + ).ratio(), + tag, + ), + tags, + ) + try: + score, tag = max(tag_ratio, key=itemgetter(0)) + except ValueError: + raise NoRevisionError() + if score < SIMILARITY_THRESHOLD: + raise NoRevisionError() + start_rev = tag.name + return start_rev + + def write_changelog( + self, changelog_out: str, lines: list[str], changelog_meta: changelog.Metadata + ): + changelog_hook: Callable | None = self.cz.changelog_hook + with smart_open(self.file_name, "w", encoding=self.encoding) as changelog_file: + partial_changelog: str | None = None + if self.incremental: + new_lines = changelog.incremental_build( + changelog_out, lines, changelog_meta + ) + changelog_out = "".join(new_lines) + partial_changelog = changelog_out + + if changelog_hook: + changelog_out = changelog_hook(changelog_out, partial_changelog) + + changelog_file.write(changelog_out) + + def export_template(self): + tpl = changelog.get_changelog_template(self.cz.template_loader, self.template) + src = Path(tpl.filename) + Path(self.export_template_to).write_text(src.read_text()) + + def __call__(self): + commit_parser = self.cz.commit_parser + changelog_pattern = self.cz.changelog_pattern + start_rev = self.start_rev + unreleased_version = self.unreleased_version + changelog_meta = changelog.Metadata() + change_type_map: dict | None = self.change_type_map + changelog_message_builder_hook: MessageBuilderHook | None = ( + self.cz.changelog_message_builder_hook + ) + changelog_release_hook: ChangelogReleaseHook | None = ( + self.cz.changelog_release_hook + ) + + if self.export_template_to: + return self.export_template() + + if not changelog_pattern or not commit_parser: + raise NoPatternMapError( + f"'{self.config.settings['name']}' rule does not support changelog" + ) + + if self.incremental and self.rev_range: + raise NotAllowed("--incremental cannot be combined with a rev_range") + + # Don't continue if no `file_name` specified. + assert self.file_name + + tags = self.tag_rules.get_version_tags(git.get_tags(), warn=True) + end_rev = "" + if self.incremental: + changelog_meta = self.changelog_format.get_metadata(self.file_name) + if changelog_meta.latest_version: + start_rev = self._find_incremental_rev( + strip_local_version(changelog_meta.latest_version_tag), tags + ) + if self.rev_range: + start_rev, end_rev = changelog.get_oldest_and_newest_rev( + tags, + self.rev_range, + self.tag_rules, + ) + commits = git.get_commits(start=start_rev, end=end_rev, args="--topo-order") + if not commits and ( + self.current_version is None or not self.current_version.is_prerelease + ): + raise NoCommitsFoundError("No commits found") + tree = changelog.generate_tree_from_commits( + commits, + tags, + commit_parser, + changelog_pattern, + unreleased_version, + change_type_map=change_type_map, + changelog_message_builder_hook=changelog_message_builder_hook, + changelog_release_hook=changelog_release_hook, + rules=self.tag_rules, + ) + if self.change_type_order: + tree = changelog.order_changelog_tree(tree, self.change_type_order) + + extras = self.cz.template_extras.copy() + extras.update(self.config.settings["extras"]) + extras.update(self.extras) + changelog_out = changelog.render_changelog( + tree, loader=self.cz.template_loader, template=self.template, **extras + ) + changelog_out = changelog_out.lstrip("\n") + + # Dry_run is executed here to avoid checking and reading the files + if self.dry_run: + changelog_hook: Callable | None = self.cz.changelog_hook + if changelog_hook: + changelog_out = changelog_hook(changelog_out, "") + out.write(changelog_out) + raise DryRunExit() + + lines = [] + if self.incremental and os.path.isfile(self.file_name): + with open(self.file_name, encoding=self.encoding) as changelog_file: + lines = changelog_file.readlines() + + self.write_changelog(changelog_out, lines, changelog_meta) diff --git a/commitizen/commands/check.py b/commitizen/commands/check.py new file mode 100644 index 0000000..e22155c --- /dev/null +++ b/commitizen/commands/check.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import os +import re +import sys +from typing import Any + +from commitizen import factory, git, out +from commitizen.config import BaseConfig +from commitizen.exceptions import ( + InvalidCommandArgumentError, + InvalidCommitMessageError, + NoCommitsFoundError, +) + + +class Check: + """Check if the current commit msg matches the commitizen format.""" + + def __init__(self, config: BaseConfig, arguments: dict[str, Any], cwd=os.getcwd()): + """Initial check command. + + Args: + config: The config object required for the command to perform its action + arguments: All the flags provided by the user + cwd: Current work directory + """ + self.commit_msg_file: str | None = arguments.get("commit_msg_file") + self.commit_msg: str | None = arguments.get("message") + self.rev_range: str | None = arguments.get("rev_range") + self.allow_abort: bool = bool( + arguments.get("allow_abort", config.settings["allow_abort"]) + ) + self.max_msg_length: int = arguments.get("message_length_limit", 0) + + # we need to distinguish between None and [], which is a valid value + + allowed_prefixes = arguments.get("allowed_prefixes") + self.allowed_prefixes: list[str] = ( + allowed_prefixes + if allowed_prefixes is not None + else config.settings["allowed_prefixes"] + ) + + self._valid_command_argument() + + self.config: BaseConfig = config + self.encoding = config.settings["encoding"] + self.cz = factory.commiter_factory(self.config) + + def _valid_command_argument(self): + num_exclusive_args_provided = sum( + arg is not None + for arg in (self.commit_msg_file, self.commit_msg, self.rev_range) + ) + if num_exclusive_args_provided == 0 and not sys.stdin.isatty(): + self.commit_msg = sys.stdin.read() + elif num_exclusive_args_provided != 1: + raise InvalidCommandArgumentError( + "Only one of --rev-range, --message, and --commit-msg-file is permitted by check command! " + "See 'cz check -h' for more information" + ) + + def __call__(self): + """Validate if commit messages follows the conventional pattern. + + Raises: + InvalidCommitMessageError: if the commit provided not follows the conventional pattern + """ + commits = self._get_commits() + if not commits: + raise NoCommitsFoundError(f"No commit found with range: '{self.rev_range}'") + + pattern = self.cz.schema_pattern() + ill_formated_commits = [ + commit + for commit in commits + if not self.validate_commit_message(commit.message, pattern) + ] + displayed_msgs_content = "\n".join( + [ + f'commit "{commit.rev}": "{commit.message}"' + for commit in ill_formated_commits + ] + ) + if displayed_msgs_content: + raise InvalidCommitMessageError( + "commit validation: failed!\n" + "please enter a commit message in the commitizen format.\n" + f"{displayed_msgs_content}\n" + f"pattern: {pattern}" + ) + out.success("Commit validation: successful!") + + def _get_commits(self): + msg = None + # Get commit message from file (--commit-msg-file) + if self.commit_msg_file is not None: + # Enter this branch if commit_msg_file is "". + with open(self.commit_msg_file, encoding=self.encoding) as commit_file: + msg = commit_file.read() + # Get commit message from command line (--message) + elif self.commit_msg is not None: + msg = self.commit_msg + if msg is not None: + msg = self._filter_comments(msg) + return [git.GitCommit(rev="", title="", body=msg)] + + # Get commit messages from git log (--rev-range) + if self.rev_range: + return git.get_commits(end=self.rev_range) + return git.get_commits() + + @staticmethod + def _filter_comments(msg: str) -> str: + """Filter the commit message by removing comments. + + When using `git commit --verbose`, we exclude the diff that is going to + generated, like the following example: + + ```bash + ... + # ------------------------ >8 ------------------------ + # Do not modify or remove the line above. + # Everything below it will be ignored. + diff --git a/... b/... + ... + ``` + + Args: + msg: The commit message to filter. + + Returns: + The filtered commit message without comments. + """ + + lines = [] + for line in msg.split("\n"): + if "# ------------------------ >8 ------------------------" in line: + break + if not line.startswith("#"): + lines.append(line) + return "\n".join(lines) + + def validate_commit_message(self, commit_msg: str, pattern: str) -> bool: + if not commit_msg: + return self.allow_abort + + if any(map(commit_msg.startswith, self.allowed_prefixes)): + return True + if self.max_msg_length: + msg_len = len(commit_msg.partition("\n")[0].strip()) + if msg_len > self.max_msg_length: + return False + return bool(re.match(pattern, commit_msg)) diff --git a/commitizen/commands/commit.py b/commitizen/commands/commit.py new file mode 100644 index 0000000..abecb3b --- /dev/null +++ b/commitizen/commands/commit.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import contextlib +import os +import shutil +import subprocess +import tempfile + +import questionary + +from commitizen import factory, git, out +from commitizen.config import BaseConfig +from commitizen.cz.exceptions import CzException +from commitizen.cz.utils import get_backup_file_path +from commitizen.exceptions import ( + CommitError, + CommitMessageLengthExceededError, + CustomError, + DryRunExit, + NoAnswersError, + NoCommitBackupError, + NotAGitProjectError, + NotAllowed, + NothingToCommitError, +) +from commitizen.git import smart_open + + +class Commit: + """Show prompt for the user to create a guided commit.""" + + def __init__(self, config: BaseConfig, arguments: dict): + if not git.is_git_project(): + raise NotAGitProjectError() + + self.config: BaseConfig = config + self.encoding = config.settings["encoding"] + self.cz = factory.commiter_factory(self.config) + self.arguments = arguments + self.temp_file: str = get_backup_file_path() + + def read_backup_message(self) -> str | None: + # Check the commit backup file exists + if not os.path.isfile(self.temp_file): + return None + + # Read commit message from backup + with open(self.temp_file, encoding=self.encoding) as f: + return f.read().strip() + + def prompt_commit_questions(self) -> str: + # Prompt user for the commit message + cz = self.cz + questions = cz.questions() + for question in filter(lambda q: q["type"] == "list", questions): + question["use_shortcuts"] = self.config.settings["use_shortcuts"] + try: + answers = questionary.prompt(questions, style=cz.style) + except ValueError as err: + root_err = err.__context__ + if isinstance(root_err, CzException): + raise CustomError(root_err.__str__()) + raise err + + if not answers: + raise NoAnswersError() + + message = cz.message(answers) + message_len = len(message.partition("\n")[0].strip()) + message_length_limit: int = self.arguments.get("message_length_limit", 0) + if 0 < message_length_limit < message_len: + raise CommitMessageLengthExceededError( + f"Length of commit message exceeds limit ({message_len}/{message_length_limit})" + ) + + return message + + def manual_edit(self, message: str) -> str: + editor = git.get_core_editor() + if editor is None: + raise RuntimeError("No 'editor' value given and no default available.") + exec_path = shutil.which(editor) + if exec_path is None: + raise RuntimeError(f"Editor '{editor}' not found.") + with tempfile.NamedTemporaryFile(mode="w", delete=False) as file: + file.write(message) + file_path = file.name + argv = [exec_path, file_path] + subprocess.call(argv) + with open(file_path) as temp_file: + message = temp_file.read().strip() + file.unlink() + return message + + def __call__(self): + extra_args: str = self.arguments.get("extra_cli_args", "") + + allow_empty: bool = "--allow-empty" in extra_args + + dry_run: bool = self.arguments.get("dry_run") + write_message_to_file: bool = self.arguments.get("write_message_to_file") + manual_edit: bool = self.arguments.get("edit") + + is_all: bool = self.arguments.get("all") + if is_all: + c = git.add("-u") + + if git.is_staging_clean() and not (dry_run or allow_empty): + raise NothingToCommitError("No files added to staging!") + + if write_message_to_file is not None and write_message_to_file.is_dir(): + raise NotAllowed(f"{write_message_to_file} is a directory") + + retry: bool = self.arguments.get("retry") + no_retry: bool = self.arguments.get("no_retry") + retry_after_failure: bool = self.config.settings.get("retry_after_failure") + + if retry: + m = self.read_backup_message() + if m is None: + raise NoCommitBackupError() + elif retry_after_failure and not no_retry: + m = self.read_backup_message() + if m is None: + m = self.prompt_commit_questions() + else: + m = self.prompt_commit_questions() + + if manual_edit: + m = self.manual_edit(m) + + out.info(f"\n{m}\n") + + if write_message_to_file: + with smart_open(write_message_to_file, "w", encoding=self.encoding) as file: + file.write(m) + + if dry_run: + raise DryRunExit() + + always_signoff: bool = self.config.settings["always_signoff"] + signoff: bool = self.arguments.get("signoff") + + if signoff: + out.warn( + "signoff mechanic is deprecated, please use `cz commit -- -s` instead." + ) + + if always_signoff or signoff: + extra_args = f"{extra_args} -s".strip() + + c = git.commit(m, args=extra_args) + + if c.return_code != 0: + out.error(c.err) + + # Create commit backup + with smart_open(self.temp_file, "w", encoding=self.encoding) as f: + f.write(m) + + raise CommitError() + + if "nothing added" in c.out or "no changes added to commit" in c.out: + out.error(c.out) + else: + with contextlib.suppress(FileNotFoundError): + os.remove(self.temp_file) + out.write(c.err) + out.write(c.out) + out.success("Commit successful!") diff --git a/commitizen/commands/example.py b/commitizen/commands/example.py new file mode 100644 index 0000000..e7abe7b --- /dev/null +++ b/commitizen/commands/example.py @@ -0,0 +1,13 @@ +from commitizen import factory, out +from commitizen.config import BaseConfig + + +class Example: + """Show an example so people understands the rules.""" + + def __init__(self, config: BaseConfig, *args): + self.config: BaseConfig = config + self.cz = factory.commiter_factory(self.config) + + def __call__(self): + out.write(self.cz.example()) diff --git a/commitizen/commands/info.py b/commitizen/commands/info.py new file mode 100644 index 0000000..afac979 --- /dev/null +++ b/commitizen/commands/info.py @@ -0,0 +1,13 @@ +from commitizen import factory, out +from commitizen.config import BaseConfig + + +class Info: + """Show in depth explanation of your rules.""" + + def __init__(self, config: BaseConfig, *args): + self.config: BaseConfig = config + self.cz = factory.commiter_factory(self.config) + + def __call__(self): + out.write(self.cz.info()) diff --git a/commitizen/commands/init.py b/commitizen/commands/init.py new file mode 100644 index 0000000..df872ec --- /dev/null +++ b/commitizen/commands/init.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +import os +import shutil +from typing import Any + +import questionary +import yaml + +from commitizen import cmd, factory, out +from commitizen.__version__ import __version__ +from commitizen.config import BaseConfig, JsonConfig, TomlConfig, YAMLConfig +from commitizen.cz import registry +from commitizen.defaults import DEFAULT_SETTINGS, config_files +from commitizen.exceptions import InitFailedError, NoAnswersError +from commitizen.git import get_latest_tag_name, get_tag_names, smart_open +from commitizen.version_schemes import KNOWN_SCHEMES, Version, get_version_scheme + + +class ProjectInfo: + """Discover information about the current folder.""" + + @property + def has_pyproject(self) -> bool: + return os.path.isfile("pyproject.toml") + + @property + def has_uv_lock(self) -> bool: + return os.path.isfile("uv.lock") + + @property + def has_setup(self) -> bool: + return os.path.isfile("setup.py") + + @property + def has_pre_commit_config(self) -> bool: + return os.path.isfile(".pre-commit-config.yaml") + + @property + def is_python_uv(self) -> bool: + return self.has_pyproject and self.has_uv_lock + + @property + def is_python_poetry(self) -> bool: + if not self.has_pyproject: + return False + with open("pyproject.toml") as f: + return "[tool.poetry]" in f.read() + + @property + def is_python(self) -> bool: + return self.has_pyproject or self.has_setup + + @property + def is_rust_cargo(self) -> bool: + return os.path.isfile("Cargo.toml") + + @property + def is_npm_package(self) -> bool: + return os.path.isfile("package.json") + + @property + def is_php_composer(self) -> bool: + return os.path.isfile("composer.json") + + @property + def latest_tag(self) -> str | None: + return get_latest_tag_name() + + def tags(self) -> list | None: + """Not a property, only use if necessary""" + if self.latest_tag is None: + return None + return get_tag_names() + + @property + def is_pre_commit_installed(self) -> bool: + return bool(shutil.which("pre-commit")) + + +class Init: + def __init__(self, config: BaseConfig, *args): + self.config: BaseConfig = config + self.encoding = config.settings["encoding"] + self.cz = factory.commiter_factory(self.config) + self.project_info = ProjectInfo() + + def __call__(self): + if self.config.path: + out.line(f"Config file {self.config.path} already exists") + return + + out.info("Welcome to commitizen!\n") + out.line( + "Answer the questions to configure your project.\n" + "For further configuration visit:\n" + "\n" + "https://commitizen-tools.github.io/commitizen/config/" + "\n" + ) + + # Collect information + try: + config_path = self._ask_config_path() # select + cz_name = self._ask_name() # select + version_provider = self._ask_version_provider() # select + tag = self._ask_tag() # confirm & select + version_scheme = self._ask_version_scheme() # select + version = get_version_scheme(self.config.settings, version_scheme)(tag) + tag_format = self._ask_tag_format(tag) # confirm & text + update_changelog_on_bump = self._ask_update_changelog_on_bump() # confirm + major_version_zero = self._ask_major_version_zero(version) # confirm + except KeyboardInterrupt: + raise InitFailedError("Stopped by user") + + # Initialize configuration + if "toml" in config_path: + self.config = TomlConfig(data="", path=config_path) + elif "json" in config_path: + self.config = JsonConfig(data="{}", path=config_path) + elif "yaml" in config_path: + self.config = YAMLConfig(data="", path=config_path) + values_to_add = {} + values_to_add["name"] = cz_name + values_to_add["tag_format"] = tag_format + values_to_add["version_scheme"] = version_scheme + + if version_provider == "commitizen": + values_to_add["version"] = version.public + else: + values_to_add["version_provider"] = version_provider + + if update_changelog_on_bump: + values_to_add["update_changelog_on_bump"] = update_changelog_on_bump + + if major_version_zero: + values_to_add["major_version_zero"] = major_version_zero + + # Collect hook data + hook_types = questionary.checkbox( + "What types of pre-commit hook you want to install? (Leave blank if you don't want to install)", + choices=[ + questionary.Choice("commit-msg", checked=False), + questionary.Choice("pre-push", checked=False), + ], + ).unsafe_ask() + if hook_types: + try: + self._install_pre_commit_hook(hook_types) + except InitFailedError as e: + raise InitFailedError(f"Failed to install pre-commit hook.\n{e}") + + # Create and initialize config + self.config.init_empty_config_content() + self._update_config_file(values_to_add) + + out.write("\nYou can bump the version running:\n") + out.info("\tcz bump\n") + out.success("Configuration complete ๐Ÿš€") + + def _ask_config_path(self) -> str: + default_path = ".cz.toml" + if self.project_info.has_pyproject: + default_path = "pyproject.toml" + + name: str = questionary.select( + "Please choose a supported config file: ", + choices=config_files, + default=default_path, + style=self.cz.style, + ).unsafe_ask() + return name + + def _ask_name(self) -> str: + name: str = questionary.select( + "Please choose a cz (commit rule): (default: cz_conventional_commits)", + choices=list(registry.keys()), + default="cz_conventional_commits", + style=self.cz.style, + ).unsafe_ask() + return name + + def _ask_tag(self) -> str: + latest_tag = self.project_info.latest_tag + if not latest_tag: + out.error("No Existing Tag. Set tag to v0.0.1") + return "0.0.1" + + is_correct_tag = questionary.confirm( + f"Is {latest_tag} the latest tag?", style=self.cz.style, default=False + ).unsafe_ask() + if not is_correct_tag: + tags = self.project_info.tags() + if not tags: + out.error("No Existing Tag. Set tag to v0.0.1") + return "0.0.1" + + # the latest tag is most likely with the largest number. Thus list the tags in reverse order makes more sense + sorted_tags = sorted(tags, reverse=True) + latest_tag = questionary.select( + "Please choose the latest tag: ", + choices=sorted_tags, + style=self.cz.style, + ).unsafe_ask() + + if not latest_tag: + raise NoAnswersError("Tag is required!") + return latest_tag + + def _ask_tag_format(self, latest_tag) -> str: + is_correct_format = False + if latest_tag.startswith("v"): + tag_format = r"v$version" + is_correct_format = questionary.confirm( + f'Is "{tag_format}" the correct tag format?', style=self.cz.style + ).unsafe_ask() + + default_format = DEFAULT_SETTINGS["tag_format"] + if not is_correct_format: + tag_format = questionary.text( + f'Please enter the correct version format: (default: "{default_format}")', + style=self.cz.style, + ).unsafe_ask() + + if not tag_format: + tag_format = default_format + return tag_format + + def _ask_version_provider(self) -> str: + """Ask for setting: version_provider""" + + OPTS = { + "commitizen": "commitizen: Fetch and set version in commitizen config (default)", + "cargo": "cargo: Get and set version from Cargo.toml:project.version field", + "composer": "composer: Get and set version from composer.json:project.version field", + "npm": "npm: Get and set version from package.json:project.version field", + "pep621": "pep621: Get and set version from pyproject.toml:project.version field", + "poetry": "poetry: Get and set version from pyproject.toml:tool.poetry.version field", + "uv": "uv: Get and Get and set version from pyproject.toml and uv.lock", + "scm": "scm: Fetch the version from git and does not need to set it back", + } + + default_val = "commitizen" + if self.project_info.is_python: + if self.project_info.is_python_poetry: + default_val = "poetry" + elif self.project_info.is_python_uv: + default_val = "uv" + else: + default_val = "pep621" + elif self.project_info.is_rust_cargo: + default_val = "cargo" + elif self.project_info.is_npm_package: + default_val = "npm" + elif self.project_info.is_php_composer: + default_val = "composer" + + choices = [ + questionary.Choice(title=title, value=value) + for value, title in OPTS.items() + ] + default = next(filter(lambda x: x.value == default_val, choices)) + version_provider: str = questionary.select( + "Choose the source of the version:", + choices=choices, + style=self.cz.style, + default=default, + ).unsafe_ask() + return version_provider + + def _ask_version_scheme(self) -> str: + """Ask for setting: version_scheme""" + default = "semver" + if self.project_info.is_python: + default = "pep440" + + scheme: str = questionary.select( + "Choose version scheme: ", + choices=list(KNOWN_SCHEMES), + style=self.cz.style, + default=default, + ).unsafe_ask() + return scheme + + def _ask_major_version_zero(self, version: Version) -> bool: + """Ask for setting: major_version_zero""" + if version.major > 0: + return False + major_version_zero: bool = questionary.confirm( + "Keep major version zero (0.x) during breaking changes", + default=True, + auto_enter=True, + ).unsafe_ask() + return major_version_zero + + def _ask_update_changelog_on_bump(self) -> bool: + "Ask for setting: update_changelog_on_bump" + update_changelog_on_bump: bool = questionary.confirm( + "Create changelog automatically on bump", + default=True, + auto_enter=True, + ).unsafe_ask() + return update_changelog_on_bump + + def _exec_install_pre_commit_hook(self, hook_types: list[str]): + cmd_str = self._gen_pre_commit_cmd(hook_types) + c = cmd.run(cmd_str) + if c.return_code != 0: + err_msg = ( + f"Error running {cmd_str}." + "Outputs are attached below:\n" + f"stdout: {c.out}\n" + f"stderr: {c.err}" + ) + raise InitFailedError(err_msg) + + def _gen_pre_commit_cmd(self, hook_types: list[str]) -> str: + """Generate pre-commit command according to given hook types""" + if not hook_types: + raise ValueError("At least 1 hook type should be provided.") + cmd_str = "pre-commit install " + " ".join( + f"--hook-type {ty}" for ty in hook_types + ) + return cmd_str + + def _install_pre_commit_hook(self, hook_types: list[str] | None = None): + pre_commit_config_filename = ".pre-commit-config.yaml" + cz_hook_config = { + "repo": "https://github.com/commitizen-tools/commitizen", + "rev": f"v{__version__}", + "hooks": [ + {"id": "commitizen"}, + {"id": "commitizen-branch", "stages": ["push"]}, + ], + } + + config_data = {} + if not self.project_info.has_pre_commit_config: + # .pre-commit-config.yaml does not exist + config_data["repos"] = [cz_hook_config] + else: + with open( + pre_commit_config_filename, encoding=self.encoding + ) as config_file: + yaml_data = yaml.safe_load(config_file) + if yaml_data: + config_data = yaml_data + + if "repos" in config_data: + for pre_commit_hook in config_data["repos"]: + if "commitizen" in pre_commit_hook["repo"]: + out.write("commitizen already in pre-commit config") + break + else: + config_data["repos"].append(cz_hook_config) + else: + # .pre-commit-config.yaml exists but there's no "repos" key + config_data["repos"] = [cz_hook_config] + + with smart_open( + pre_commit_config_filename, "w", encoding=self.encoding + ) as config_file: + yaml.safe_dump(config_data, stream=config_file) + + if not self.project_info.is_pre_commit_installed: + raise InitFailedError("pre-commit is not installed in current environment.") + if hook_types is None: + hook_types = ["commit-msg", "pre-push"] + self._exec_install_pre_commit_hook(hook_types) + out.write("commitizen pre-commit hook is now installed in your '.git'\n") + + def _update_config_file(self, values: dict[str, Any]): + for key, value in values.items(): + self.config.set_key(key, value) diff --git a/commitizen/commands/list_cz.py b/commitizen/commands/list_cz.py new file mode 100644 index 0000000..9970186 --- /dev/null +++ b/commitizen/commands/list_cz.py @@ -0,0 +1,13 @@ +from commitizen import out +from commitizen.config import BaseConfig +from commitizen.cz import registry + + +class ListCz: + """List currently installed rules.""" + + def __init__(self, config: BaseConfig, *args): + self.config: BaseConfig = config + + def __call__(self): + out.write("\n".join(registry.keys())) diff --git a/commitizen/commands/schema.py b/commitizen/commands/schema.py new file mode 100644 index 0000000..0940648 --- /dev/null +++ b/commitizen/commands/schema.py @@ -0,0 +1,13 @@ +from commitizen import factory, out +from commitizen.config import BaseConfig + + +class Schema: + """Show structure of the rule.""" + + def __init__(self, config: BaseConfig, *args): + self.config: BaseConfig = config + self.cz = factory.commiter_factory(self.config) + + def __call__(self): + out.write(self.cz.schema()) diff --git a/commitizen/commands/version.py b/commitizen/commands/version.py new file mode 100644 index 0000000..45d553c --- /dev/null +++ b/commitizen/commands/version.py @@ -0,0 +1,39 @@ +import platform +import sys + +from commitizen import out +from commitizen.__version__ import __version__ +from commitizen.config import BaseConfig +from commitizen.providers import get_provider + + +class Version: + """Get the version of the installed commitizen or the current project.""" + + def __init__(self, config: BaseConfig, *args): + self.config: BaseConfig = config + self.parameter = args[0] + self.operating_system = platform.system() + self.python_version = sys.version + + def __call__(self): + if self.parameter.get("report"): + out.write(f"Commitizen Version: {__version__}") + out.write(f"Python Version: {self.python_version}") + out.write(f"Operating System: {self.operating_system}") + elif self.parameter.get("project"): + version = get_provider(self.config).get_version() + if version: + out.write(f"{version}") + else: + out.error("No project information in this project.") + elif self.parameter.get("verbose"): + out.write(f"Installed Commitizen Version: {__version__}") + version = get_provider(self.config).get_version() + if version: + out.write(f"Project Version: {version}") + else: + out.error("No project information in this project.") + else: + # if no argument is given, show installed commitizen version + out.write(f"{__version__}") diff --git a/commitizen/config/__init__.py b/commitizen/config/__init__.py new file mode 100644 index 0000000..f3720bb --- /dev/null +++ b/commitizen/config/__init__.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from pathlib import Path + +from commitizen import defaults, git +from commitizen.exceptions import ConfigFileIsEmpty, ConfigFileNotFound + +from .base_config import BaseConfig +from .json_config import JsonConfig +from .toml_config import TomlConfig +from .yaml_config import YAMLConfig + + +def read_cfg(filepath: str | None = None) -> BaseConfig: + conf = BaseConfig() + + if filepath is not None: + if not Path(filepath).exists(): + raise ConfigFileNotFound() + + cfg_paths = (path for path in (Path(filepath),)) + else: + git_project_root = git.find_git_project_root() + cfg_search_paths = [Path(".")] + if git_project_root: + cfg_search_paths.append(git_project_root) + + cfg_paths = ( + path / Path(filename) + for path in cfg_search_paths + for filename in defaults.config_files + ) + + for filename in cfg_paths: + if not filename.exists(): + continue + + _conf: TomlConfig | JsonConfig | YAMLConfig + + with open(filename, "rb") as f: + data: bytes = f.read() + + if "toml" in filename.suffix: + _conf = TomlConfig(data=data, path=filename) + elif "json" in filename.suffix: + _conf = JsonConfig(data=data, path=filename) + elif "yaml" in filename.suffix: + _conf = YAMLConfig(data=data, path=filename) + + if filepath is not None and _conf.is_empty_config: + raise ConfigFileIsEmpty() + elif _conf.is_empty_config: + continue + else: + conf = _conf + break + + return conf diff --git a/commitizen/config/base_config.py b/commitizen/config/base_config.py new file mode 100644 index 0000000..478691a --- /dev/null +++ b/commitizen/config/base_config.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from pathlib import Path + +from commitizen.defaults import DEFAULT_SETTINGS, Settings + + +class BaseConfig: + def __init__(self): + self._settings: Settings = DEFAULT_SETTINGS.copy() + self.encoding = self.settings["encoding"] + self._path: Path | None = None + + @property + def settings(self) -> Settings: + return self._settings + + @property + def path(self) -> Path | None: + return self._path + + def set_key(self, key, value): + """Set or update a key in the conf. + + For now only strings are supported. + We use to update the version number. + """ + raise NotImplementedError() + + def update(self, data: Settings) -> None: + self._settings.update(data) + + def add_path(self, path: str | Path) -> None: + self._path = Path(path) + + def _parse_setting(self, data: bytes | str) -> None: + raise NotImplementedError() diff --git a/commitizen/config/json_config.py b/commitizen/config/json_config.py new file mode 100644 index 0000000..b6a07f4 --- /dev/null +++ b/commitizen/config/json_config.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from commitizen.exceptions import InvalidConfigurationError +from commitizen.git import smart_open + +from .base_config import BaseConfig + + +class JsonConfig(BaseConfig): + def __init__(self, *, data: bytes | str, path: Path | str): + super().__init__() + self.is_empty_config = False + self.add_path(path) + self._parse_setting(data) + + def init_empty_config_content(self): + with smart_open(self.path, "a", encoding=self.encoding) as json_file: + json.dump({"commitizen": {}}, json_file) + + def set_key(self, key, value): + """Set or update a key in the conf. + + For now only strings are supported. + We use to update the version number. + """ + with open(self.path, "rb") as f: + parser = json.load(f) + + parser["commitizen"][key] = value + with smart_open(self.path, "w", encoding=self.encoding) as f: + json.dump(parser, f, indent=2) + return self + + def _parse_setting(self, data: bytes | str) -> None: + """We expect to have a section in .cz.json looking like + + ``` + { + "commitizen": { + "name": "cz_conventional_commits" + } + } + ``` + """ + try: + doc = json.loads(data) + except json.JSONDecodeError as e: + raise InvalidConfigurationError(f"Failed to parse {self.path}: {e}") + + try: + self.settings.update(doc["commitizen"]) + except KeyError: + self.is_empty_config = True diff --git a/commitizen/config/toml_config.py b/commitizen/config/toml_config.py new file mode 100644 index 0000000..813389c --- /dev/null +++ b/commitizen/config/toml_config.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from tomlkit import exceptions, parse, table + +from commitizen.exceptions import InvalidConfigurationError + +from .base_config import BaseConfig + + +class TomlConfig(BaseConfig): + def __init__(self, *, data: bytes | str, path: Path | str): + super().__init__() + self.is_empty_config = False + self.add_path(path) + self._parse_setting(data) + + def init_empty_config_content(self): + if os.path.isfile(self.path): + with open(self.path, "rb") as input_toml_file: + parser = parse(input_toml_file.read()) + else: + parser = parse("") + + with open(self.path, "wb") as output_toml_file: + if parser.get("tool") is None: + parser["tool"] = table() + parser["tool"]["commitizen"] = table() + output_toml_file.write(parser.as_string().encode(self.encoding)) + + def set_key(self, key, value): + """Set or update a key in the conf. + + For now only strings are supported. + We use to update the version number. + """ + with open(self.path, "rb") as f: + parser = parse(f.read()) + + parser["tool"]["commitizen"][key] = value + with open(self.path, "wb") as f: + f.write(parser.as_string().encode(self.encoding)) + return self + + def _parse_setting(self, data: bytes | str) -> None: + """We expect to have a section in pyproject looking like + + ``` + [tool.commitizen] + name = "cz_conventional_commits" + ``` + """ + try: + doc = parse(data) + except exceptions.ParseError as e: + raise InvalidConfigurationError(f"Failed to parse {self.path}: {e}") + + try: + self.settings.update(doc["tool"]["commitizen"]) # type: ignore + except exceptions.NonExistentKey: + self.is_empty_config = True diff --git a/commitizen/config/yaml_config.py b/commitizen/config/yaml_config.py new file mode 100644 index 0000000..2bb6fe3 --- /dev/null +++ b/commitizen/config/yaml_config.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +from commitizen.exceptions import InvalidConfigurationError +from commitizen.git import smart_open + +from .base_config import BaseConfig + + +class YAMLConfig(BaseConfig): + def __init__(self, *, data: bytes | str, path: Path | str): + super().__init__() + self.is_empty_config = False + self.add_path(path) + self._parse_setting(data) + + def init_empty_config_content(self): + with smart_open(self.path, "a", encoding=self.encoding) as json_file: + yaml.dump({"commitizen": {}}, json_file, explicit_start=True) + + def _parse_setting(self, data: bytes | str) -> None: + """We expect to have a section in cz.yaml looking like + + ``` + commitizen: + name: cz_conventional_commits + ``` + """ + import yaml.scanner + + try: + doc = yaml.safe_load(data) + except yaml.YAMLError as e: + raise InvalidConfigurationError(f"Failed to parse {self.path}: {e}") + + try: + self.settings.update(doc["commitizen"]) + except (KeyError, TypeError): + self.is_empty_config = True + + def set_key(self, key, value): + """Set or update a key in the conf. + + For now only strings are supported. + We use to update the version number. + """ + with open(self.path, "rb") as yaml_file: + parser = yaml.load(yaml_file, Loader=yaml.FullLoader) + + parser["commitizen"][key] = value + with smart_open(self.path, "w", encoding=self.encoding) as yaml_file: + yaml.dump(parser, yaml_file, explicit_start=True) + + return self diff --git a/commitizen/cz/__init__.py b/commitizen/cz/__init__.py new file mode 100644 index 0000000..cb17fe3 --- /dev/null +++ b/commitizen/cz/__init__.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import importlib +import pkgutil +import sys +import warnings +from collections.abc import Iterable + +if sys.version_info >= (3, 10): + from importlib import metadata +else: + import importlib_metadata as metadata + +from commitizen.cz.base import BaseCommitizen + + +def discover_plugins( + path: Iterable[str] | None = None, +) -> dict[str, type[BaseCommitizen]]: + """Discover commitizen plugins on the path + + Args: + path (Path, optional): If provided, 'path' should be either None or a list of paths to look for + modules in. If path is None, all top-level modules on sys.path.. Defaults to None. + + Returns: + Dict[str, Type[BaseCommitizen]]: Registry with found plugins + """ + for _, name, _ in pkgutil.iter_modules(path): + if name.startswith("cz_"): + mod = importlib.import_module(name) + if hasattr(mod, "discover_this"): + warnings.warn( + UserWarning( + f"Legacy plugin '{name}' has been ignored: please expose it the 'commitizen.plugin' entrypoint" + ) + ) + + return { + ep.name: ep.load() for ep in metadata.entry_points(group="commitizen.plugin") + } + + +registry: dict[str, type[BaseCommitizen]] = discover_plugins() diff --git a/commitizen/cz/base.py b/commitizen/cz/base.py new file mode 100644 index 0000000..43455a7 --- /dev/null +++ b/commitizen/cz/base.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from collections.abc import Iterable +from typing import Any, Callable, Protocol + +from jinja2 import BaseLoader, PackageLoader +from prompt_toolkit.styles import Style, merge_styles + +from commitizen import git +from commitizen.config.base_config import BaseConfig +from commitizen.defaults import Questions + + +class MessageBuilderHook(Protocol): + def __call__( + self, message: dict[str, Any], commit: git.GitCommit + ) -> dict[str, Any] | Iterable[dict[str, Any]] | None: ... + + +class ChangelogReleaseHook(Protocol): + def __call__( + self, release: dict[str, Any], tag: git.GitTag | None + ) -> dict[str, Any]: ... + + +class BaseCommitizen(metaclass=ABCMeta): + bump_pattern: str | None = None + bump_map: dict[str, str] | None = None + bump_map_major_version_zero: dict[str, str] | None = None + default_style_config: list[tuple[str, str]] = [ + ("qmark", "fg:#ff9d00 bold"), + ("question", "bold"), + ("answer", "fg:#ff9d00 bold"), + ("pointer", "fg:#ff9d00 bold"), + ("highlighted", "fg:#ff9d00 bold"), + ("selected", "fg:#cc5454"), + ("separator", "fg:#cc5454"), + ("instruction", ""), + ("text", ""), + ("disabled", "fg:#858585 italic"), + ] + + # The whole subject will be parsed as message by default + # This allows supporting changelog for any rule system. + # It can be modified per rule + commit_parser: str | None = r"(?P<message>.*)" + changelog_pattern: str | None = r".*" + change_type_map: dict[str, str] | None = None + change_type_order: list[str] | None = None + + # Executed per message parsed by the commitizen + changelog_message_builder_hook: MessageBuilderHook | None = None + + # Executed only at the end of the changelog generation + changelog_hook: Callable[[str, str | None], str] | None = None + + # Executed for each release in the changelog + changelog_release_hook: ChangelogReleaseHook | None = None + + # Plugins can override templates and provide extra template data + template_loader: BaseLoader = PackageLoader("commitizen", "templates") + template_extras: dict[str, Any] = {} + + def __init__(self, config: BaseConfig) -> None: + self.config = config + if not self.config.settings.get("style"): + self.config.settings.update({"style": BaseCommitizen.default_style_config}) + + @abstractmethod + def questions(self) -> Questions: + """Questions regarding the commit message.""" + + @abstractmethod + def message(self, answers: dict) -> str: + """Format your git message.""" + + @property + def style(self): + return merge_styles( + [ + Style(BaseCommitizen.default_style_config), + Style(self.config.settings["style"]), + ] + ) + + def example(self) -> str: + """Example of the commit message.""" + raise NotImplementedError("Not Implemented yet") + + def schema(self) -> str: + """Schema definition of the commit message.""" + raise NotImplementedError("Not Implemented yet") + + def schema_pattern(self) -> str: + """Regex matching the schema used for message validation.""" + raise NotImplementedError("Not Implemented yet") + + def info(self) -> str: + """Information about the standardized commit message.""" + raise NotImplementedError("Not Implemented yet") + + def process_commit(self, commit: str) -> str: + """Process commit for changelog. + + If not overwritten, it returns the first line of commit. + """ + return commit.split("\n")[0] diff --git a/commitizen/cz/conventional_commits/__init__.py b/commitizen/cz/conventional_commits/__init__.py new file mode 100644 index 0000000..52624d2 --- /dev/null +++ b/commitizen/cz/conventional_commits/__init__.py @@ -0,0 +1 @@ +from .conventional_commits import ConventionalCommitsCz # noqa: F401 diff --git a/commitizen/cz/conventional_commits/conventional_commits.py b/commitizen/cz/conventional_commits/conventional_commits.py new file mode 100644 index 0000000..c7b8825 --- /dev/null +++ b/commitizen/cz/conventional_commits/conventional_commits.py @@ -0,0 +1,212 @@ +import os +import re + +from commitizen import defaults +from commitizen.cz.base import BaseCommitizen +from commitizen.cz.utils import multiple_line_breaker, required_validator +from commitizen.defaults import Questions + +__all__ = ["ConventionalCommitsCz"] + + +def parse_scope(text): + if not text: + return "" + + scope = text.strip().split() + if len(scope) == 1: + return scope[0] + + return "-".join(scope) + + +def parse_subject(text): + if isinstance(text, str): + text = text.strip(".").strip() + + return required_validator(text, msg="Subject is required.") + + +class ConventionalCommitsCz(BaseCommitizen): + bump_pattern = defaults.bump_pattern + bump_map = defaults.bump_map + bump_map_major_version_zero = defaults.bump_map_major_version_zero + commit_parser = r"^((?P<change_type>feat|fix|refactor|perf|BREAKING CHANGE)(?:\((?P<scope>[^()\r\n]*)\)|\()?(?P<breaking>!)?|\w+!):\s(?P<message>.*)?" # noqa + change_type_map = { + "feat": "Feat", + "fix": "Fix", + "refactor": "Refactor", + "perf": "Perf", + } + changelog_pattern = defaults.bump_pattern + + def questions(self) -> Questions: + questions: Questions = [ + { + "type": "list", + "name": "prefix", + "message": "Select the type of change you are committing", + "choices": [ + { + "value": "fix", + "name": "fix: A bug fix. Correlates with PATCH in SemVer", + "key": "x", + }, + { + "value": "feat", + "name": "feat: A new feature. Correlates with MINOR in SemVer", + "key": "f", + }, + { + "value": "docs", + "name": "docs: Documentation only changes", + "key": "d", + }, + { + "value": "style", + "name": ( + "style: Changes that do not affect the " + "meaning of the code (white-space, formatting," + " missing semi-colons, etc)" + ), + "key": "s", + }, + { + "value": "refactor", + "name": ( + "refactor: A code change that neither fixes " + "a bug nor adds a feature" + ), + "key": "r", + }, + { + "value": "perf", + "name": "perf: A code change that improves performance", + "key": "p", + }, + { + "value": "test", + "name": ("test: Adding missing or correcting existing tests"), + "key": "t", + }, + { + "value": "build", + "name": ( + "build: Changes that affect the build system or " + "external dependencies (example scopes: pip, docker, npm)" + ), + "key": "b", + }, + { + "value": "ci", + "name": ( + "ci: Changes to CI configuration files and " + "scripts (example scopes: GitLabCI)" + ), + "key": "c", + }, + ], + }, + { + "type": "input", + "name": "scope", + "message": ( + "What is the scope of this change? (class or file name): (press [enter] to skip)\n" + ), + "filter": parse_scope, + }, + { + "type": "input", + "name": "subject", + "filter": parse_subject, + "message": ( + "Write a short and imperative summary of the code changes: (lower case and no period)\n" + ), + }, + { + "type": "input", + "name": "body", + "message": ( + "Provide additional contextual information about the code changes: (press [enter] to skip)\n" + ), + "filter": multiple_line_breaker, + }, + { + "type": "confirm", + "message": "Is this a BREAKING CHANGE? Correlates with MAJOR in SemVer", + "name": "is_breaking_change", + "default": False, + }, + { + "type": "input", + "name": "footer", + "message": ( + "Footer. Information about Breaking Changes and " + "reference issues that this commit closes: (press [enter] to skip)\n" + ), + }, + ] + return questions + + def message(self, answers: dict) -> str: + prefix = answers["prefix"] + scope = answers["scope"] + subject = answers["subject"] + body = answers["body"] + footer = answers["footer"] + is_breaking_change = answers["is_breaking_change"] + + if scope: + scope = f"({scope})" + if body: + body = f"\n\n{body}" + if is_breaking_change: + footer = f"BREAKING CHANGE: {footer}" + if footer: + footer = f"\n\n{footer}" + + message = f"{prefix}{scope}: {subject}{body}{footer}" + + return message + + def example(self) -> str: + return ( + "fix: correct minor typos in code\n" + "\n" + "see the issue for details on the typos fixed\n" + "\n" + "closes issue #12" + ) + + def schema(self) -> str: + return ( + "<type>(<scope>): <subject>\n" + "<BLANK LINE>\n" + "<body>\n" + "<BLANK LINE>\n" + "(BREAKING CHANGE: )<footer>" + ) + + def schema_pattern(self) -> str: + PATTERN = ( + r"(?s)" # To explicitly make . match new line + r"(build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert|bump)" # type + r"(\(\S+\))?!?:" # scope + r"( [^\n\r]+)" # subject + r"((\n\n.*)|(\s*))?$" + ) + return PATTERN + + def info(self) -> str: + dir_path = os.path.dirname(os.path.realpath(__file__)) + filepath = os.path.join(dir_path, "conventional_commits_info.txt") + with open(filepath, encoding=self.config.settings["encoding"]) as f: + content = f.read() + return content + + def process_commit(self, commit: str) -> str: + pat = re.compile(self.schema_pattern()) + m = re.match(pat, commit) + if m is None: + return "" + return m.group(3).strip() diff --git a/commitizen/cz/conventional_commits/conventional_commits_info.txt b/commitizen/cz/conventional_commits/conventional_commits_info.txt new file mode 100644 index 0000000..a076e4f --- /dev/null +++ b/commitizen/cz/conventional_commits/conventional_commits_info.txt @@ -0,0 +1,31 @@ +The commit contains the following structural elements, to communicate +intent to the consumers of your library: + +fix: a commit of the type fix patches a bug in your codebase +(this correlates with PATCH in semantic versioning). + +feat: a commit of the type feat introduces a new feature to the codebase +(this correlates with MINOR in semantic versioning). + +BREAKING CHANGE: a commit that has the text BREAKING CHANGE: at the beginning of +its optional body or footer section introduces a breaking API change +(correlating with MAJOR in semantic versioning). +A BREAKING CHANGE can be part of commits of any type. + +Others: commit types other than fix: and feat: are allowed, +like chore:, docs:, style:, refactor:, perf:, test:, and others. + +We also recommend improvement for commits that improve a current +implementation without adding a new feature or fixing a bug. + +Notice these types are not mandated by the conventional commits specification, +and have no implicit effect in semantic versioning (unless they include a BREAKING CHANGE). + +A scope may be provided to a commitโ€™s type, to provide additional contextual +information and is contained within parenthesis, e.g., feat(parser): add ability to parse arrays. + +<type>[optional scope]: <description> + +[optional body] + +[optional footer] diff --git a/commitizen/cz/customize/__init__.py b/commitizen/cz/customize/__init__.py new file mode 100644 index 0000000..c5af001 --- /dev/null +++ b/commitizen/cz/customize/__init__.py @@ -0,0 +1 @@ +from .customize import CustomizeCommitsCz # noqa diff --git a/commitizen/cz/customize/customize.py b/commitizen/cz/customize/customize.py new file mode 100644 index 0000000..d53ae29 --- /dev/null +++ b/commitizen/cz/customize/customize.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from jinja2 import Template +else: + try: + from jinja2 import Template + except ImportError: + from string import Template + + +from commitizen import defaults +from commitizen.config import BaseConfig +from commitizen.cz.base import BaseCommitizen +from commitizen.defaults import Questions +from commitizen.exceptions import MissingCzCustomizeConfigError + +__all__ = ["CustomizeCommitsCz"] + + +class CustomizeCommitsCz(BaseCommitizen): + bump_pattern = defaults.bump_pattern + bump_map = defaults.bump_map + bump_map_major_version_zero = defaults.bump_map_major_version_zero + change_type_order = defaults.change_type_order + + def __init__(self, config: BaseConfig): + super().__init__(config) + + if "customize" not in self.config.settings: + raise MissingCzCustomizeConfigError() + self.custom_settings = self.config.settings["customize"] + + custom_bump_pattern = self.custom_settings.get("bump_pattern") + if custom_bump_pattern: + self.bump_pattern = custom_bump_pattern + + custom_bump_map = self.custom_settings.get("bump_map") + if custom_bump_map: + self.bump_map = custom_bump_map + + custom_bump_map_major_version_zero = self.custom_settings.get( + "bump_map_major_version_zero" + ) + if custom_bump_map_major_version_zero: + self.bump_map_major_version_zero = custom_bump_map_major_version_zero + + custom_change_type_order = self.custom_settings.get("change_type_order") + if custom_change_type_order: + self.change_type_order = custom_change_type_order + + commit_parser = self.custom_settings.get("commit_parser") + if commit_parser: + self.commit_parser = commit_parser + + changelog_pattern = self.custom_settings.get("changelog_pattern") + if changelog_pattern: + self.changelog_pattern = changelog_pattern + + change_type_map = self.custom_settings.get("change_type_map") + if change_type_map: + self.change_type_map = change_type_map + + def questions(self) -> Questions: + return self.custom_settings.get("questions", [{}]) + + def message(self, answers: dict) -> str: + message_template = Template(self.custom_settings.get("message_template", "")) + if getattr(Template, "substitute", None): + return message_template.substitute(**answers) # type: ignore + else: + return message_template.render(**answers) + + def example(self) -> str: + return self.custom_settings.get("example") or "" + + def schema_pattern(self) -> str: + return self.custom_settings.get("schema_pattern") or "" + + def schema(self) -> str: + return self.custom_settings.get("schema") or "" + + def info(self) -> str: + info_path = self.custom_settings.get("info_path") + info = self.custom_settings.get("info") + if info_path: + with open(info_path, encoding=self.config.settings["encoding"]) as f: + content = f.read() + return content + elif info: + return info + return "" diff --git a/commitizen/cz/customize/customize_info.txt b/commitizen/cz/customize/customize_info.txt new file mode 100644 index 0000000..e69de29 diff --git a/commitizen/cz/exceptions.py b/commitizen/cz/exceptions.py new file mode 100644 index 0000000..d74f39a --- /dev/null +++ b/commitizen/cz/exceptions.py @@ -0,0 +1,4 @@ +class CzException(Exception): ... + + +class AnswerRequiredError(CzException): ... diff --git a/commitizen/cz/jira/__init__.py b/commitizen/cz/jira/__init__.py new file mode 100644 index 0000000..c4a98c0 --- /dev/null +++ b/commitizen/cz/jira/__init__.py @@ -0,0 +1,3 @@ +from .jira import JiraSmartCz + +__all__ = ["JiraSmartCz"] diff --git a/commitizen/cz/jira/jira.py b/commitizen/cz/jira/jira.py new file mode 100644 index 0000000..b8fd056 --- /dev/null +++ b/commitizen/cz/jira/jira.py @@ -0,0 +1,81 @@ +import os + +from commitizen.cz.base import BaseCommitizen +from commitizen.defaults import Questions + +__all__ = ["JiraSmartCz"] + + +class JiraSmartCz(BaseCommitizen): + def questions(self) -> Questions: + questions = [ + { + "type": "input", + "name": "message", + "message": "Git commit message (required):\n", + # 'validate': RequiredValidator, + "filter": lambda x: x.strip(), + }, + { + "type": "input", + "name": "issues", + "message": "Jira Issue ID(s) separated by spaces (required):\n", + # 'validate': RequiredValidator, + "filter": lambda x: x.strip(), + }, + { + "type": "input", + "name": "workflow", + "message": "Workflow command (testing, closed, etc.) (optional):\n", + "filter": lambda x: "#" + x.strip().replace(" ", "-") if x else "", + }, + { + "type": "input", + "name": "time", + "message": "Time spent (i.e. 3h 15m) (optional):\n", + "filter": lambda x: "#time " + x if x else "", + }, + { + "type": "input", + "name": "comment", + "message": "Jira comment (optional):\n", + "filter": lambda x: "#comment " + x if x else "", + }, + ] + return questions + + def message(self, answers: dict) -> str: + return " ".join( + filter( + bool, + [ + answers["message"], + answers["issues"], + answers["workflow"], + answers["time"], + answers["comment"], + ], + ) + ) + + def example(self) -> str: + return ( + "JRA-34 #comment corrected indent issue\n" + "JRA-35 #time 1w 2d 4h 30m Total work logged\n" + "JRA-123 JRA-234 JRA-345 #resolve\n" + "JRA-123 JRA-234 JRA-345 #resolve #time 2d 5h #comment Task completed " + "ahead of schedule" + ) + + def schema(self) -> str: + return "<ignored text> <ISSUE_KEY> <ignored text> #<COMMAND> <optional COMMAND_ARGUMENTS>" # noqa + + def schema_pattern(self) -> str: + return r".*[A-Z]{2,}\-[0-9]+( #| .* #).+( #.+)*" + + def info(self) -> str: + dir_path = os.path.dirname(os.path.realpath(__file__)) + filepath = os.path.join(dir_path, "jira_info.txt") + with open(filepath, encoding=self.config.settings["encoding"]) as f: + content = f.read() + return content diff --git a/commitizen/cz/jira/jira_info.txt b/commitizen/cz/jira/jira_info.txt new file mode 100644 index 0000000..f32bc12 --- /dev/null +++ b/commitizen/cz/jira/jira_info.txt @@ -0,0 +1,84 @@ +Smart Commits allow repository committers to perform actions such as transitioning JIRA Software +issues or creating Crucible code reviews by embedding specific commands into their commit messages. + +You can: +* comment on issues +* record time tracking information against issues +* transition issues to any status defined in the JIRA Software project's workflow. + +There are other actions available if you use Crucible. + +Each Smart Commit message must not span more than one line (i.e. you cannot use a carriage return in +the command), but you can add multiple commands to the same line. See this example below. + +Smart Commit commands +--------------------- + +The basic command line syntax for a Smart Commit message is: + +<ignored text> <ISSUE_KEY> <ignored text> #<COMMAND> <optional COMMAND_ARGUMENTS> + +Any text between the issue key and the Smart Commit command is ignored. + +There are three Smart Commit commands you can use in your commit messages: +* comment +* time +* transition + + +Comment +------- + +Description + +Adds a comment to a JIRA Software issue. + +Syntax + +<ignored text> ISSUE_KEY <ignored text> #comment <comment_string> + +Example + +JRA-34 #comment corrected indent issue + +Notes + +The committer's email address must match the email address of a single JIRA Software user with +permission to comment on issues in that particular project. + + +Time +---- + +Description + +Records time tracking information against an issue. + +Syntax + +<ignored text> ISSUE_KEY <ignored text> #time <value>w <value>d <value>h <value>m <comment_string> + +Example + +JRA-34 #time 1w 2d 4h 30m Total work logged + + +Workflow transitions +-------------------- + +Description + +Transitions a JIRA Software issue to a particular workflow state. + +Syntax + +<ignored text> ISSUE_KEY <ignored text> #<transition_name> <comment_string> + +Example + +JRA-090 #close Fixed this today + + +More information +---------------- +https://confluence.atlassian.com/fisheye/using-smart-commits-298976812.html diff --git a/commitizen/cz/utils.py b/commitizen/cz/utils.py new file mode 100644 index 0000000..7bc8967 --- /dev/null +++ b/commitizen/cz/utils.py @@ -0,0 +1,32 @@ +import os +import re +import tempfile + +from commitizen import git +from commitizen.cz import exceptions + + +def required_validator(answer, msg=None): + if not answer: + raise exceptions.AnswerRequiredError(msg) + return answer + + +def multiple_line_breaker(answer, sep="|"): + return "\n".join(line.strip() for line in answer.split(sep) if line) + + +def strip_local_version(version: str) -> str: + return re.sub(r"\+.+", "", version) + + +def get_backup_file_path() -> str: + project_root = git.find_git_project_root() + + if project_root is None: + project = "" + else: + project = project_root.as_posix().replace("/", "%") + + user = os.environ.get("USER", "") + return os.path.join(tempfile.gettempdir(), f"cz.commit%{user}%{project}.backup") diff --git a/commitizen/defaults.py b/commitizen/defaults.py new file mode 100644 index 0000000..0b78e1b --- /dev/null +++ b/commitizen/defaults.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import pathlib +from collections import OrderedDict +from collections.abc import Iterable, MutableMapping, Sequence +from typing import Any, TypedDict + +# Type +Questions = Iterable[MutableMapping[str, Any]] + + +class CzSettings(TypedDict, total=False): + bump_pattern: str + bump_map: OrderedDict[str, str] + bump_map_major_version_zero: OrderedDict[str, str] + change_type_order: list[str] + + questions: Questions + example: str | None + schema_pattern: str | None + schema: str | None + info_path: str | pathlib.Path + info: str + message_template: str + commit_parser: str | None + changelog_pattern: str | None + change_type_map: dict[str, str] | None + + +class Settings(TypedDict, total=False): + name: str + version: str | None + version_files: list[str] + version_provider: str | None + version_scheme: str | None + version_type: str | None + tag_format: str + legacy_tag_formats: Sequence[str] + ignored_tag_formats: Sequence[str] + bump_message: str | None + retry_after_failure: bool + allow_abort: bool + allowed_prefixes: list[str] + changelog_file: str + changelog_format: str | None + changelog_incremental: bool + changelog_start_rev: str | None + changelog_merge_prerelease: bool + update_changelog_on_bump: bool + use_shortcuts: bool + style: list[tuple[str, str]] + customize: CzSettings + major_version_zero: bool + pre_bump_hooks: list[str] | None + post_bump_hooks: list[str] | None + prerelease_offset: int + encoding: str + always_signoff: bool + template: str | None + extras: dict[str, Any] + + +name: str = "cz_conventional_commits" +config_files: list[str] = [ + "pyproject.toml", + ".cz.toml", + ".cz.json", + "cz.json", + ".cz.yaml", + "cz.yaml", + "cz.toml", +] +encoding: str = "utf-8" + +DEFAULT_SETTINGS: Settings = { + "name": name, + "version": None, + "version_files": [], + "version_provider": "commitizen", + "version_scheme": None, + "tag_format": "$version", # example v$version + "legacy_tag_formats": [], + "ignored_tag_formats": [], + "bump_message": None, # bumped v$current_version to $new_version + "retry_after_failure": False, + "allow_abort": False, + "allowed_prefixes": [ + "Merge", + "Revert", + "Pull request", + "fixup!", + "squash!", + ], + "changelog_file": "CHANGELOG.md", + "changelog_format": None, # default guessed from changelog_file + "changelog_incremental": False, + "changelog_start_rev": None, + "changelog_merge_prerelease": False, + "update_changelog_on_bump": False, + "use_shortcuts": False, + "major_version_zero": False, + "pre_bump_hooks": [], + "post_bump_hooks": [], + "prerelease_offset": 0, + "encoding": encoding, + "always_signoff": False, + "template": None, # default provided by plugin + "extras": {}, +} + +MAJOR = "MAJOR" +MINOR = "MINOR" +PATCH = "PATCH" + +CHANGELOG_FORMAT = "markdown" + +bump_pattern = r"^((BREAKING[\-\ ]CHANGE|\w+)(\(.+\))?!?):" +bump_map = OrderedDict( + ( + (r"^.+!$", MAJOR), + (r"^BREAKING[\-\ ]CHANGE", MAJOR), + (r"^feat", MINOR), + (r"^fix", PATCH), + (r"^refactor", PATCH), + (r"^perf", PATCH), + ) +) +bump_map_major_version_zero = OrderedDict( + ( + (r"^.+!$", MINOR), + (r"^BREAKING[\-\ ]CHANGE", MINOR), + (r"^feat", MINOR), + (r"^fix", PATCH), + (r"^refactor", PATCH), + (r"^perf", PATCH), + ) +) +change_type_order = ["BREAKING CHANGE", "Feat", "Fix", "Refactor", "Perf"] +bump_message = "bump: version $current_version โ†’ $new_version" + + +def get_tag_regexes( + version_regex: str, +) -> dict[str, str]: + regexs = { + "version": version_regex, + "major": r"(?P<major>\d+)", + "minor": r"(?P<minor>\d+)", + "patch": r"(?P<patch>\d+)", + "prerelease": r"(?P<prerelease>\w+\d+)?", + "devrelease": r"(?P<devrelease>\.dev\d+)?", + } + return { + **{f"${k}": v for k, v in regexs.items()}, + **{f"${{{k}}}": v for k, v in regexs.items()}, + } diff --git a/commitizen/exceptions.py b/commitizen/exceptions.py new file mode 100644 index 0000000..29733b6 --- /dev/null +++ b/commitizen/exceptions.py @@ -0,0 +1,213 @@ +import enum + +from commitizen import out + + +class ExitCode(enum.IntEnum): + EXPECTED_EXIT = 0 + NO_COMMITIZEN_FOUND = 1 + NOT_A_GIT_PROJECT = 2 + NO_COMMITS_FOUND = 3 + NO_VERSION_SPECIFIED = 4 + NO_PATTERN_MAP = 5 + BUMP_COMMIT_FAILED = 6 + BUMP_TAG_FAILED = 7 + NO_ANSWERS = 8 + COMMIT_ERROR = 9 + NO_COMMIT_BACKUP = 10 + NOTHING_TO_COMMIT = 11 + CUSTOM_ERROR = 12 + NO_COMMAND_FOUND = 13 + INVALID_COMMIT_MSG = 14 + MISSING_CZ_CUSTOMIZE_CONFIG = 15 + NO_REVISION = 16 + CURRENT_VERSION_NOT_FOUND = 17 + INVALID_COMMAND_ARGUMENT = 18 + INVALID_CONFIGURATION = 19 + NOT_ALLOWED = 20 + NO_INCREMENT = 21 + UNRECOGNIZED_CHARACTERSET_ENCODING = 22 + GIT_COMMAND_ERROR = 23 + INVALID_MANUAL_VERSION = 24 + INIT_FAILED = 25 + RUN_HOOK_FAILED = 26 + VERSION_PROVIDER_UNKNOWN = 27 + VERSION_SCHEME_UNKNOWN = 28 + CHANGELOG_FORMAT_UNKNOWN = 29 + CONFIG_FILE_NOT_FOUND = 30 + CONFIG_FILE_IS_EMPTY = 31 + COMMIT_MESSAGE_LENGTH_LIMIT_EXCEEDED = 32 + + +class CommitizenException(Exception): + def __init__(self, *args, **kwargs): + self.output_method = kwargs.get("output_method") or out.error + self.exit_code: ExitCode = self.__class__.exit_code + if args: + self.message = args[0] + elif hasattr(self.__class__, "message"): + self.message = self.__class__.message + else: + self.message = "" + + def __str__(self): + return self.message + + +class ExpectedExit(CommitizenException): + exit_code = ExitCode.EXPECTED_EXIT + + def __init__(self, *args, **kwargs): + output_method = kwargs.get("output_method") or out.write + kwargs["output_method"] = output_method + super().__init__(*args, **kwargs) + + +class DryRunExit(ExpectedExit): + pass + + +class GetNextExit(ExpectedExit): + pass + + +class NoneIncrementExit(CommitizenException): + exit_code = ExitCode.NO_INCREMENT + + +class NoCommitizenFoundException(CommitizenException): + exit_code = ExitCode.NO_COMMITIZEN_FOUND + + +class NotAGitProjectError(CommitizenException): + exit_code = ExitCode.NOT_A_GIT_PROJECT + message = "fatal: not a git repository (or any of the parent directories): .git" + + +class MissingCzCustomizeConfigError(CommitizenException): + exit_code = ExitCode.MISSING_CZ_CUSTOMIZE_CONFIG + message = "fatal: customize is not set in configuration file." + + +class NoCommitsFoundError(CommitizenException): + exit_code = ExitCode.NO_COMMITS_FOUND + + +class NoVersionSpecifiedError(CommitizenException): + exit_code = ExitCode.NO_VERSION_SPECIFIED + message = ( + "[NO_VERSION_SPECIFIED]\n" + "Check if current version is specified in config file, like:\n" + "version = 0.4.3\n" + ) + + +class NoPatternMapError(CommitizenException): + exit_code = ExitCode.NO_PATTERN_MAP + + +class BumpCommitFailedError(CommitizenException): + exit_code = ExitCode.BUMP_COMMIT_FAILED + + +class BumpTagFailedError(CommitizenException): + exit_code = ExitCode.BUMP_TAG_FAILED + + +class CurrentVersionNotFoundError(CommitizenException): + exit_code = ExitCode.CURRENT_VERSION_NOT_FOUND + + +class NoAnswersError(CommitizenException): + exit_code = ExitCode.NO_ANSWERS + + +class CommitError(CommitizenException): + exit_code = ExitCode.COMMIT_ERROR + + +class NoCommitBackupError(CommitizenException): + exit_code = ExitCode.NO_COMMIT_BACKUP + message = "No commit backup found" + + +class NothingToCommitError(CommitizenException): + exit_code = ExitCode.NOTHING_TO_COMMIT + + +class CustomError(CommitizenException): + exit_code = ExitCode.CUSTOM_ERROR + + +class InvalidCommitMessageError(CommitizenException): + exit_code = ExitCode.INVALID_COMMIT_MSG + + +class NoRevisionError(CommitizenException): + exit_code = ExitCode.NO_REVISION + message = "No tag found to do an incremental changelog" + + +class NoCommandFoundError(CommitizenException): + exit_code = ExitCode.NO_COMMAND_FOUND + message = "Command is required" + + +class InvalidCommandArgumentError(CommitizenException): + exit_code = ExitCode.INVALID_COMMAND_ARGUMENT + + +class InvalidConfigurationError(CommitizenException): + exit_code = ExitCode.INVALID_CONFIGURATION + + +class NotAllowed(CommitizenException): + exit_code = ExitCode.NOT_ALLOWED + + +class CharacterSetDecodeError(CommitizenException): + exit_code = ExitCode.UNRECOGNIZED_CHARACTERSET_ENCODING + + +class GitCommandError(CommitizenException): + exit_code = ExitCode.GIT_COMMAND_ERROR + + +class InvalidManualVersion(CommitizenException): + exit_code = ExitCode.INVALID_MANUAL_VERSION + + +class InitFailedError(CommitizenException): + exit_code = ExitCode.INIT_FAILED + + +class RunHookError(CommitizenException): + exit_code = ExitCode.RUN_HOOK_FAILED + + +class VersionProviderUnknown(CommitizenException): + exit_code = ExitCode.VERSION_PROVIDER_UNKNOWN + + +class VersionSchemeUnknown(CommitizenException): + exit_code = ExitCode.VERSION_SCHEME_UNKNOWN + + +class ChangelogFormatUnknown(CommitizenException): + exit_code = ExitCode.CHANGELOG_FORMAT_UNKNOWN + message = "Unknown changelog format identifier" + + +class ConfigFileNotFound(CommitizenException): + exit_code = ExitCode.CONFIG_FILE_NOT_FOUND + message = "Cannot found the config file, please check your file path again." + + +class ConfigFileIsEmpty(CommitizenException): + exit_code = ExitCode.CONFIG_FILE_IS_EMPTY + message = "Config file is empty, please check your file path again." + + +class CommitMessageLengthExceededError(CommitizenException): + exit_code = ExitCode.COMMIT_MESSAGE_LENGTH_LIMIT_EXCEEDED + message = "Length of commit message exceeds the given limit." diff --git a/commitizen/factory.py b/commitizen/factory.py new file mode 100644 index 0000000..09af5fd --- /dev/null +++ b/commitizen/factory.py @@ -0,0 +1,19 @@ +from commitizen import BaseCommitizen +from commitizen.config import BaseConfig +from commitizen.cz import registry +from commitizen.exceptions import NoCommitizenFoundException + + +def commiter_factory(config: BaseConfig) -> BaseCommitizen: + """Return the correct commitizen existing in the registry.""" + name: str = config.settings["name"] + try: + _cz = registry[name](config) + except KeyError: + msg_error = ( + "The committer has not been found in the system.\n\n" + f"Try running 'pip install {name}'\n" + ) + raise NoCommitizenFoundException(msg_error) + else: + return _cz diff --git a/commitizen/git.py b/commitizen/git.py new file mode 100644 index 0000000..19ca46b --- /dev/null +++ b/commitizen/git.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +import os +from enum import Enum +from os import linesep +from pathlib import Path +from tempfile import NamedTemporaryFile + +from commitizen import cmd, out +from commitizen.exceptions import GitCommandError + +UNIX_EOL = "\n" +WINDOWS_EOL = "\r\n" + + +class EOLTypes(Enum): + """The EOL type from `git config core.eol`.""" + + LF = "lf" + CRLF = "crlf" + NATIVE = "native" + + def get_eol_for_open(self) -> str: + """Get the EOL character for `open()`.""" + map = { + EOLTypes.CRLF: WINDOWS_EOL, + EOLTypes.LF: UNIX_EOL, + EOLTypes.NATIVE: linesep, + } + + return map[self] + + +class GitObject: + rev: str + name: str + date: str + + def __eq__(self, other) -> bool: + if not hasattr(other, "rev"): + return False + return self.rev == other.rev # type: ignore + + +class GitCommit(GitObject): + def __init__( + self, + rev, + title, + body: str = "", + author: str = "", + author_email: str = "", + parents: list[str] | None = None, + ): + self.rev = rev.strip() + self.title = title.strip() + self.body = body.strip() + self.author = author.strip() + self.author_email = author_email.strip() + self.parents = parents or [] + + @property + def message(self): + return f"{self.title}\n\n{self.body}".strip() + + def __repr__(self): + return f"{self.title} ({self.rev})" + + +class GitTag(GitObject): + def __init__(self, name, rev, date): + self.rev = rev.strip() + self.name = name.strip() + self._date = date.strip() + + def __repr__(self): + return f"GitTag('{self.name}', '{self.rev}', '{self.date}')" + + @property + def date(self): + return self._date + + @classmethod + def from_line(cls, line: str, inner_delimiter: str) -> GitTag: + name, objectname, date, obj = line.split(inner_delimiter) + if not obj: + obj = objectname + + return cls(name=name, rev=obj, date=date) + + +def tag( + tag: str, annotated: bool = False, signed: bool = False, msg: str | None = None +) -> cmd.Command: + _opt = "" + if annotated: + _opt = f"-a {tag} -m" + if signed: + _opt = f"-s {tag} -m" + + # according to https://git-scm.com/book/en/v2/Git-Basics-Tagging, + # we're not able to create lightweight tag with message. + # by adding message, we make it a annotated tags + c = cmd.run(f'git tag {_opt} "{tag if _opt == "" or msg is None else msg}"') + return c + + +def add(*args: str) -> cmd.Command: + c = cmd.run(f"git add {' '.join(args)}") + return c + + +def commit( + message: str, + args: str = "", + committer_date: str | None = None, +) -> cmd.Command: + f = NamedTemporaryFile("wb", delete=False) + f.write(message.encode("utf-8")) + f.close() + + command = f'git commit {args} -F "{f.name}"' + + if committer_date and os.name == "nt": # pragma: no cover + # Using `cmd /v /c "{command}"` sets environment variables only for that command + command = f'cmd /v /c "set GIT_COMMITTER_DATE={committer_date}&& {command}"' + elif committer_date: + command = f"GIT_COMMITTER_DATE={committer_date} {command}" + + c = cmd.run(command) + os.unlink(f.name) + return c + + +def get_commits( + start: str | None = None, + end: str = "HEAD", + *, + args: str = "", +) -> list[GitCommit]: + """Get the commits between start and end.""" + git_log_entries = _get_log_as_str_list(start, end, args) + git_commits = [] + for rev_and_commit in git_log_entries: + if not rev_and_commit: + continue + rev, parents, title, author, author_email, *body_list = rev_and_commit.split( + "\n" + ) + if rev_and_commit: + git_commit = GitCommit( + rev=rev.strip(), + title=title.strip(), + body="\n".join(body_list).strip(), + author=author, + author_email=author_email, + parents=[p for p in parents.strip().split(" ") if p], + ) + git_commits.append(git_commit) + return git_commits + + +def get_filenames_in_commit(git_reference: str = ""): + """Get the list of files that were committed in the requested git reference. + + :param git_reference: a git reference as accepted by `git show`, default: the last commit + + :returns: file names committed in the last commit by default or inside the passed git reference + """ + c = cmd.run(f"git show --name-only --pretty=format: {git_reference}") + if c.return_code == 0: + return c.out.strip().split("\n") + else: + raise GitCommandError(c.err) + + +def get_tags( + dateformat: str = "%Y-%m-%d", reachable_only: bool = False +) -> list[GitTag]: + inner_delimiter = "---inner_delimiter---" + formatter = ( + f'"%(refname:lstrip=2){inner_delimiter}' + f"%(objectname){inner_delimiter}" + f"%(creatordate:format:{dateformat}){inner_delimiter}" + f'%(object)"' + ) + extra = "--merged" if reachable_only else "" + # Force the default language for parsing + env = {"LC_ALL": "C", "LANG": "C", "LANGUAGE": "C"} + c = cmd.run(f"git tag --format={formatter} --sort=-creatordate {extra}", env=env) + if c.return_code != 0: + if reachable_only and c.err == "fatal: malformed object name HEAD\n": + # this can happen if there are no commits in the repo yet + return [] + raise GitCommandError(c.err) + + if c.err: + out.warn(f"Attempting to proceed after: {c.err}") + + if not c.out: + return [] + + git_tags = [ + GitTag.from_line(line=line, inner_delimiter=inner_delimiter) + for line in c.out.split("\n")[:-1] + ] + + return git_tags + + +def tag_exist(tag: str) -> bool: + c = cmd.run(f"git tag --list {tag}") + return tag in c.out + + +def is_signed_tag(tag: str) -> bool: + return cmd.run(f"git tag -v {tag}").return_code == 0 + + +def get_latest_tag_name() -> str | None: + c = cmd.run("git describe --abbrev=0 --tags") + if c.err: + return None + return c.out.strip() + + +def get_tag_message(tag: str) -> str | None: + c = cmd.run(f"git tag -l --format='%(contents:subject)' {tag}") + if c.err: + return None + return c.out.strip() + + +def get_tag_names() -> list[str | None]: + c = cmd.run("git tag --list") + if c.err: + return [] + return [tag.strip() for tag in c.out.split("\n") if tag.strip()] + + +def find_git_project_root() -> Path | None: + c = cmd.run("git rev-parse --show-toplevel") + if not c.err: + return Path(c.out.strip()) + return None + + +def is_staging_clean() -> bool: + """Check if staging is clean.""" + c = cmd.run("git diff --no-ext-diff --cached --name-only") + return not bool(c.out) + + +def is_git_project() -> bool: + c = cmd.run("git rev-parse --is-inside-work-tree") + if c.out.strip() == "true": + return True + return False + + +def get_eol_style() -> EOLTypes: + c = cmd.run("git config core.eol") + eol = c.out.strip().lower() + + # We enumerate the EOL types of the response of + # `git config core.eol`, and map it to our enumration EOLTypes. + # + # It is just like the variant of the "match" syntax. + map = { + "lf": EOLTypes.LF, + "crlf": EOLTypes.CRLF, + "native": EOLTypes.NATIVE, + } + + # If the response of `git config core.eol` is in the map: + if eol in map: + return map[eol] + else: + # The default value is "native". + # https://git-scm.com/docs/git-config#Documentation/git-config.txt-coreeol + return map["native"] + + +def get_core_editor() -> str | None: + c = cmd.run("git var GIT_EDITOR") + if c.out: + return c.out.strip() + return None + + +def smart_open(*args, **kargs): + """Open a file with the EOL style determined from Git.""" + return open(*args, newline=get_eol_style().get_eol_for_open(), **kargs) + + +def _get_log_as_str_list(start: str | None, end: str, args: str) -> list[str]: + """Get string representation of each log entry""" + delimiter = "----------commit-delimiter----------" + log_format: str = "%H%n%P%n%s%n%an%n%ae%n%b" + git_log_cmd = ( + f"git -c log.showSignature=False log --pretty={log_format}{delimiter} {args}" + ) + if start: + command = f"{git_log_cmd} {start}..{end}" + else: + command = f"{git_log_cmd} {end}" + c = cmd.run(command) + if c.return_code != 0: + raise GitCommandError(c.err) + if not c.out: + return [] + return c.out.split(f"{delimiter}\n") diff --git a/commitizen/hooks.py b/commitizen/hooks.py new file mode 100644 index 0000000..f5505d0 --- /dev/null +++ b/commitizen/hooks.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import os + +from commitizen import cmd, out +from commitizen.exceptions import RunHookError + + +def run(hooks, _env_prefix="CZ_", **env): + if isinstance(hooks, str): + hooks = [hooks] + + for hook in hooks: + out.info(f"Running hook '{hook}'") + + c = cmd.run(hook, env=_format_env(_env_prefix, env)) + + if c.out: + out.write(c.out) + if c.err: + out.error(c.err) + + if c.return_code != 0: + raise RunHookError(f"Running hook '{hook}' failed") + + +def _format_env(prefix: str, env: dict[str, str]) -> dict[str, str]: + """_format_env() prefixes all given environment variables with the given + prefix so it can be passed directly to cmd.run().""" + penv = dict(os.environ) + for name, value in env.items(): + name = prefix + name.upper() + value = str(value) if value is not None else "" + penv[name] = value + + return penv diff --git a/commitizen/out.py b/commitizen/out.py new file mode 100644 index 0000000..40342e9 --- /dev/null +++ b/commitizen/out.py @@ -0,0 +1,42 @@ +import io +import sys + +from termcolor import colored + +if sys.platform == "win32": + if isinstance(sys.stdout, io.TextIOWrapper) and sys.version_info >= (3, 7): + sys.stdout.reconfigure(encoding="utf-8") + + +def write(value: str, *args) -> None: + """Intended to be used when value is multiline.""" + print(value, *args) + + +def line(value: str, *args, **kwargs) -> None: + """Wrapper in case I want to do something different later.""" + print(value, *args, **kwargs) + + +def error(value: str) -> None: + message = colored(value, "red") + line(message, file=sys.stderr) + + +def success(value: str) -> None: + message = colored(value, "green") + line(message) + + +def info(value: str) -> None: + message = colored(value, "blue") + line(message) + + +def diagnostic(value: str): + line(value, file=sys.stderr) + + +def warn(value: str) -> None: + message = colored(value, "magenta") + line(message, file=sys.stderr) diff --git a/commitizen/providers/__init__.py b/commitizen/providers/__init__.py new file mode 100644 index 0000000..9cf4ce5 --- /dev/null +++ b/commitizen/providers/__init__.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import sys +from typing import cast + +if sys.version_info >= (3, 10): + from importlib import metadata +else: + import importlib_metadata as metadata + +from commitizen.config.base_config import BaseConfig +from commitizen.exceptions import VersionProviderUnknown +from commitizen.providers.base_provider import VersionProvider +from commitizen.providers.cargo_provider import CargoProvider +from commitizen.providers.commitizen_provider import CommitizenProvider +from commitizen.providers.composer_provider import ComposerProvider +from commitizen.providers.npm_provider import NpmProvider +from commitizen.providers.pep621_provider import Pep621Provider +from commitizen.providers.poetry_provider import PoetryProvider +from commitizen.providers.scm_provider import ScmProvider +from commitizen.providers.uv_provider import UvProvider + +__all__ = [ + "get_provider", + "CargoProvider", + "CommitizenProvider", + "ComposerProvider", + "NpmProvider", + "Pep621Provider", + "PoetryProvider", + "ScmProvider", + "UvProvider", +] + +PROVIDER_ENTRYPOINT = "commitizen.provider" +DEFAULT_PROVIDER = "commitizen" + + +def get_provider(config: BaseConfig) -> VersionProvider: + """ + Get the version provider as defined in the configuration + + :raises VersionProviderUnknown: if the provider named by `version_provider` is not found. + """ + provider_name = config.settings["version_provider"] or DEFAULT_PROVIDER + try: + (ep,) = metadata.entry_points(name=provider_name, group=PROVIDER_ENTRYPOINT) + except ValueError: + raise VersionProviderUnknown(f'Version Provider "{provider_name}" unknown.') + provider_cls = ep.load() + return cast(VersionProvider, provider_cls(config)) diff --git a/commitizen/providers/base_provider.py b/commitizen/providers/base_provider.py new file mode 100644 index 0000000..3404831 --- /dev/null +++ b/commitizen/providers/base_provider.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import json +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, ClassVar + +import tomlkit + +from commitizen.config.base_config import BaseConfig + + +class VersionProvider(ABC): + """ + Abstract base class for version providers. + + Each version provider should inherit and implement this class. + """ + + config: BaseConfig + + def __init__(self, config: BaseConfig): + self.config = config + + @abstractmethod + def get_version(self) -> str: + """ + Get the current version + """ + + @abstractmethod + def set_version(self, version: str): + """ + Set the new current version + """ + + +class FileProvider(VersionProvider): + """ + Base class for file-based version providers + """ + + filename: ClassVar[str] + + @property + def file(self) -> Path: + return Path() / self.filename + + +class JsonProvider(FileProvider): + """ + Base class for JSON-based version providers + """ + + indent: ClassVar[int] = 2 + + def get_version(self) -> str: + document = json.loads(self.file.read_text()) + return self.get(document) + + def set_version(self, version: str): + document = json.loads(self.file.read_text()) + self.set(document, version) + self.file.write_text(json.dumps(document, indent=self.indent) + "\n") + + def get(self, document: dict[str, Any]) -> str: + return document["version"] # type: ignore + + def set(self, document: dict[str, Any], version: str): + document["version"] = version + + +class TomlProvider(FileProvider): + """ + Base class for TOML-based version providers + """ + + def get_version(self) -> str: + document = tomlkit.parse(self.file.read_text()) + return self.get(document) + + def set_version(self, version: str): + document = tomlkit.parse(self.file.read_text()) + self.set(document, version) + self.file.write_text(tomlkit.dumps(document)) + + def get(self, document: tomlkit.TOMLDocument) -> str: + return document["project"]["version"] # type: ignore + + def set(self, document: tomlkit.TOMLDocument, version: str): + document["project"]["version"] = version # type: ignore diff --git a/commitizen/providers/cargo_provider.py b/commitizen/providers/cargo_provider.py new file mode 100644 index 0000000..cee687c --- /dev/null +++ b/commitizen/providers/cargo_provider.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import tomlkit + +from commitizen.providers.base_provider import TomlProvider + + +class CargoProvider(TomlProvider): + """ + Cargo version management + + With support for `workspaces` + """ + + filename = "Cargo.toml" + + def get(self, document: tomlkit.TOMLDocument) -> str: + try: + return document["package"]["version"] # type: ignore + except tomlkit.exceptions.NonExistentKey: + ... + return document["workspace"]["package"]["version"] # type: ignore + + def set(self, document: tomlkit.TOMLDocument, version: str): + try: + document["workspace"]["package"]["version"] = version # type: ignore + return + except tomlkit.exceptions.NonExistentKey: + ... + document["package"]["version"] = version # type: ignore diff --git a/commitizen/providers/commitizen_provider.py b/commitizen/providers/commitizen_provider.py new file mode 100644 index 0000000..a1da25f --- /dev/null +++ b/commitizen/providers/commitizen_provider.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from commitizen.providers.base_provider import VersionProvider + + +class CommitizenProvider(VersionProvider): + """ + Default version provider: Fetch and set version in commitizen config. + """ + + def get_version(self) -> str: + return self.config.settings["version"] # type: ignore + + def set_version(self, version: str): + self.config.set_key("version", version) diff --git a/commitizen/providers/composer_provider.py b/commitizen/providers/composer_provider.py new file mode 100644 index 0000000..495357d --- /dev/null +++ b/commitizen/providers/composer_provider.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from commitizen.providers.base_provider import JsonProvider + + +class ComposerProvider(JsonProvider): + """ + Composer version management + """ + + filename = "composer.json" + indent = 4 diff --git a/commitizen/providers/npm_provider.py b/commitizen/providers/npm_provider.py new file mode 100644 index 0000000..12900ff --- /dev/null +++ b/commitizen/providers/npm_provider.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, ClassVar + +from commitizen.providers.base_provider import VersionProvider + + +class NpmProvider(VersionProvider): + """ + npm package.json and package-lock.json version management + """ + + indent: ClassVar[int] = 2 + package_filename = "package.json" + lock_filename = "package-lock.json" + shrinkwrap_filename = "npm-shrinkwrap.json" + + @property + def package_file(self) -> Path: + return Path() / self.package_filename + + @property + def lock_file(self) -> Path: + return Path() / self.lock_filename + + @property + def shrinkwrap_file(self) -> Path: + return Path() / self.shrinkwrap_filename + + def get_version(self) -> str: + """ + Get the current version from package.json + """ + package_document = json.loads(self.package_file.read_text()) + return self.get_package_version(package_document) + + def set_version(self, version: str) -> None: + package_document = self.set_package_version( + json.loads(self.package_file.read_text()), version + ) + self.package_file.write_text( + json.dumps(package_document, indent=self.indent) + "\n" + ) + if self.lock_file.exists(): + lock_document = self.set_lock_version( + json.loads(self.lock_file.read_text()), version + ) + self.lock_file.write_text( + json.dumps(lock_document, indent=self.indent) + "\n" + ) + if self.shrinkwrap_file.exists(): + shrinkwrap_document = self.set_shrinkwrap_version( + json.loads(self.shrinkwrap_file.read_text()), version + ) + self.shrinkwrap_file.write_text( + json.dumps(shrinkwrap_document, indent=self.indent) + "\n" + ) + + def get_package_version(self, document: dict[str, Any]) -> str: + return document["version"] # type: ignore + + def set_package_version( + self, document: dict[str, Any], version: str + ) -> dict[str, Any]: + document["version"] = version + return document + + def set_lock_version( + self, document: dict[str, Any], version: str + ) -> dict[str, Any]: + document["version"] = version + document["packages"][""]["version"] = version + return document + + def set_shrinkwrap_version( + self, document: dict[str, Any], version: str + ) -> dict[str, Any]: + document["version"] = version + document["packages"][""]["version"] = version + return document diff --git a/commitizen/providers/pep621_provider.py b/commitizen/providers/pep621_provider.py new file mode 100644 index 0000000..0b8b5c4 --- /dev/null +++ b/commitizen/providers/pep621_provider.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from commitizen.providers.base_provider import TomlProvider + + +class Pep621Provider(TomlProvider): + """ + PEP-621 version management + """ + + filename = "pyproject.toml" diff --git a/commitizen/providers/poetry_provider.py b/commitizen/providers/poetry_provider.py new file mode 100644 index 0000000..7aa28f5 --- /dev/null +++ b/commitizen/providers/poetry_provider.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import tomlkit + +from commitizen.providers.base_provider import TomlProvider + + +class PoetryProvider(TomlProvider): + """ + Poetry version management + """ + + filename = "pyproject.toml" + + def get(self, pyproject: tomlkit.TOMLDocument) -> str: + return pyproject["tool"]["poetry"]["version"] # type: ignore + + def set(self, pyproject: tomlkit.TOMLDocument, version: str): + pyproject["tool"]["poetry"]["version"] = version # type: ignore diff --git a/commitizen/providers/scm_provider.py b/commitizen/providers/scm_provider.py new file mode 100644 index 0000000..cb57514 --- /dev/null +++ b/commitizen/providers/scm_provider.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from commitizen.git import get_tags +from commitizen.providers.base_provider import VersionProvider +from commitizen.tags import TagRules + + +class ScmProvider(VersionProvider): + """ + A provider fetching the current/last version from the repository history + + The version is fetched using `git describe` and is never set. + + It is meant for `setuptools-scm` or any package manager `*-scm` provider. + """ + + def get_version(self) -> str: + rules = TagRules.from_settings(self.config.settings) + tags = get_tags(reachable_only=True) + version_tags = rules.get_version_tags(tags) + versions = sorted(rules.extract_version(t) for t in version_tags) + if not versions: + return "0.0.0" + return str(versions[-1]) + + def set_version(self, version: str): + # Not necessary + pass diff --git a/commitizen/providers/uv_provider.py b/commitizen/providers/uv_provider.py new file mode 100644 index 0000000..36c8a49 --- /dev/null +++ b/commitizen/providers/uv_provider.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import tomlkit + +from commitizen.providers.base_provider import TomlProvider + +if TYPE_CHECKING: + import tomlkit.items + + +class UvProvider(TomlProvider): + """ + uv.lock and pyproject.tom version management + """ + + filename = "pyproject.toml" + lock_filename = "uv.lock" + + @property + def lock_file(self) -> Path: + return Path() / self.lock_filename + + def set_version(self, version: str) -> None: + super().set_version(version) + self.set_lock_version(version) + + def set_lock_version(self, version: str) -> None: + pyproject_toml_content = tomlkit.parse(self.file.read_text()) + project_name = pyproject_toml_content["project"]["name"] # type: ignore[index] + + document = tomlkit.parse(self.lock_file.read_text()) + + packages: tomlkit.items.AoT = document["package"] # type: ignore[assignment] + for i, package in enumerate(packages): + if package["name"] == project_name: + document["package"][i]["version"] = version # type: ignore[index] + break + self.lock_file.write_text(tomlkit.dumps(document)) diff --git a/commitizen/py.typed b/commitizen/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/commitizen/tags.py b/commitizen/tags.py new file mode 100644 index 0000000..5724bb2 --- /dev/null +++ b/commitizen/tags.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import re +import warnings +from collections.abc import Sequence +from dataclasses import dataclass, field +from functools import cached_property +from string import Template +from typing import TYPE_CHECKING, NamedTuple + +from commitizen import out +from commitizen.defaults import DEFAULT_SETTINGS, Settings, get_tag_regexes +from commitizen.git import GitTag +from commitizen.version_schemes import ( + DEFAULT_SCHEME, + InvalidVersion, + Version, + VersionScheme, + get_version_scheme, +) + +if TYPE_CHECKING: + import sys + + from commitizen.version_schemes import VersionScheme + + # Self is Python 3.11+ but backported in typing-extensions + if sys.version_info < (3, 11): + from typing_extensions import Self + else: + from typing import Self + + +class VersionTag(NamedTuple): + """Represent a version and its matching tag form.""" + + version: str + tag: str + + +@dataclass +class TagRules: + """ + Encapsulate tag-related rules. + + It allows to filter or match tags according to rules provided in settings: + - `tag_format`: the current format of the tags generated on `bump` + - `legacy_tag_formats`: previous known formats of the tag + - `ignored_tag_formats`: known formats that should be ignored + - `merge_prereleases`: if `True`, prereleases will be merged with their release counterpart + - `version_scheme`: the version scheme to use, which will be used to parse and format versions + + This class is meant to abstract and centralize all the logic related to tags. + To ensure consistency, it is recommended to use this class to handle tags. + + Example: + + ```python + settings = DEFAULT_SETTINGS.clone() + settings.update( + { + "tag_format": "v{version}", + "legacy_tag_formats": ["version{version}", "ver{version}"], + "ignored_tag_formats": ["ignored{version}"], + } + ) + + rules = TagRules.from_settings(settings) + + assert rules.is_version_tag("v1.0.0") + assert rules.is_version_tag("version1.0.0") + assert rules.is_version_tag("ver1.0.0") + assert not rules.is_version_tag("ignored1.0.0", warn=True) # Does not warn + assert not rules.is_version_tag("warn1.0.0", warn=True) # Does warn + + assert rules.search_version("# My v1.0.0 version").version == "1.0.0" + assert rules.extract_version("v1.0.0") == Version("1.0.0") + try: + assert rules.extract_version("not-a-v1.0.0") + except InvalidVersion: + print("Does not match a tag format") + ``` + """ + + scheme: VersionScheme = DEFAULT_SCHEME + tag_format: str = DEFAULT_SETTINGS["tag_format"] + legacy_tag_formats: Sequence[str] = field(default_factory=list) + ignored_tag_formats: Sequence[str] = field(default_factory=list) + merge_prereleases: bool = False + + @cached_property + def version_regexes(self) -> Sequence[re.Pattern]: + """Regexes for all legit tag formats, current and legacy""" + tag_formats = [self.tag_format, *self.legacy_tag_formats] + regexes = (self._format_regex(p) for p in tag_formats) + return [re.compile(r) for r in regexes] + + @cached_property + def ignored_regexes(self) -> Sequence[re.Pattern]: + """Regexes for known but ignored tag formats""" + regexes = (self._format_regex(p, star=True) for p in self.ignored_tag_formats) + return [re.compile(r) for r in regexes] + + def _format_regex(self, tag_pattern: str, star: bool = False) -> str: + """ + Format a tag pattern into a regex pattern. + + If star is `True`, the `*` character will be considered as a wildcard. + """ + tag_regexes = get_tag_regexes(self.scheme.parser.pattern) + format_regex = tag_pattern.replace("*", "(?:.*?)") if star else tag_pattern + for pattern, regex in tag_regexes.items(): + format_regex = format_regex.replace(pattern, regex) + return format_regex + + def is_version_tag(self, tag: str | GitTag, warn: bool = False) -> bool: + """ + True if a given tag is a legit version tag. + + if `warn` is `True`, it will print a warning message if the tag is not a version tag. + """ + tag = tag.name if isinstance(tag, GitTag) else tag + is_legit = any(regex.match(tag) for regex in self.version_regexes) + if warn and not is_legit and not self.is_ignored_tag(tag): + out.warn(f"InvalidVersion {tag} doesn't match any configured tag format") + return is_legit + + def is_ignored_tag(self, tag: str | GitTag) -> bool: + """True if a given tag can be ignored""" + tag = tag.name if isinstance(tag, GitTag) else tag + return any(regex.match(tag) for regex in self.ignored_regexes) + + def get_version_tags( + self, tags: Sequence[GitTag], warn: bool = False + ) -> Sequence[GitTag]: + """Filter in version tags and warn on unexpected tags""" + return [tag for tag in tags if self.is_version_tag(tag, warn)] + + def extract_version(self, tag: GitTag) -> Version: + """ + Extract a version from the tag as defined in tag formats. + + Raises `InvalidVersion` if the tag does not match any format. + """ + candidates = ( + m for regex in self.version_regexes if (m := regex.fullmatch(tag.name)) + ) + if not (m := next(candidates, None)): + raise InvalidVersion( + f"Invalid version tag: '{tag.name}' does not match any configured tag format" + ) + if "version" in m.groupdict(): + return self.scheme(m.group("version")) + + parts = m.groupdict() + version = parts["major"] + + if minor := parts.get("minor"): + version = f"{version}.{minor}" + if patch := parts.get("patch"): + version = f"{version}.{patch}" + + if parts.get("prerelease"): + version = f"{version}-{parts['prerelease']}" + if parts.get("devrelease"): + version = f"{version}{parts['devrelease']}" + return self.scheme(version) + + def include_in_changelog(self, tag: GitTag) -> bool: + """Check if a tag should be included in the changelog""" + try: + version = self.extract_version(tag) + except InvalidVersion: + return False + + if self.merge_prereleases and version.is_prerelease: + return False + + return True + + def search_version(self, text: str, last: bool = False) -> VersionTag | None: + """ + Search the first or last version tag occurrence in text. + + It searches for complete versions only (aka `major`, `minor` and `patch`) + """ + candidates = ( + m for regex in self.version_regexes if len(m := list(regex.finditer(text))) + ) + if not (matches := next(candidates, [])): + return None + + match = matches[-1 if last else 0] + + if "version" in match.groupdict(): + return VersionTag(match.group("version"), match.group(0)) + + parts = match.groupdict() + try: + version = f"{parts['major']}.{parts['minor']}.{parts['patch']}" + except KeyError: + return None + + if parts.get("prerelease"): + version = f"{version}-{parts['prerelease']}" + if parts.get("devrelease"): + version = f"{version}{parts['devrelease']}" + return VersionTag(version, match.group(0)) + + def normalize_tag( + self, version: Version | str, tag_format: str | None = None + ) -> str: + """ + The tag and the software version might be different. + + That's why this function exists. + + Example: + | tag | version (PEP 0440) | + | --- | ------- | + | v0.9.0 | 0.9.0 | + | ver1.0.0 | 1.0.0 | + | ver1.0.0.a0 | 1.0.0a0 | + """ + version = self.scheme(version) if isinstance(version, str) else version + tag_format = tag_format or self.tag_format + + major, minor, patch = version.release + prerelease = version.prerelease or "" + + t = Template(tag_format) + return t.safe_substitute( + version=version, + major=major, + minor=minor, + patch=patch, + prerelease=prerelease, + ) + + def find_tag_for( + self, tags: Sequence[GitTag], version: Version | str + ) -> GitTag | None: + """Find the first matching tag for a given version.""" + version = self.scheme(version) if isinstance(version, str) else version + possible_tags = [ + self.normalize_tag(version, f) + for f in (self.tag_format, *self.legacy_tag_formats) + ] + candidates = [t for t in tags if any(t.name == p for p in possible_tags)] + if len(candidates) > 1: + warnings.warn( + UserWarning( + f"Multiple tags found for version {version}: {', '.join(t.name for t in candidates)}" + ) + ) + return next(iter(candidates), None) + + @classmethod + def from_settings(cls, settings: Settings) -> Self: + """Extract tag rules from settings""" + return cls( + scheme=get_version_scheme(settings), + tag_format=settings["tag_format"], + legacy_tag_formats=settings["legacy_tag_formats"], + ignored_tag_formats=settings["ignored_tag_formats"], + merge_prereleases=settings["changelog_merge_prerelease"], + ) diff --git a/commitizen/templates/CHANGELOG.adoc.j2 b/commitizen/templates/CHANGELOG.adoc.j2 new file mode 100644 index 0000000..fe16c5d --- /dev/null +++ b/commitizen/templates/CHANGELOG.adoc.j2 @@ -0,0 +1,19 @@ +{% for entry in tree %} + +== {{ entry.version }}{% if entry.date %} ({{ entry.date }}){% endif %} + +{% for change_key, changes in entry.changes.items() %} + +{% if change_key %} +=== {{ change_key }} +{% endif %} + +{% for change in changes %} +{% if change.scope %} +* *{{ change.scope }}*: {{ change.message }} +{% elif change.message %} +* {{ change.message }} +{% endif %} +{% endfor %} +{% endfor %} +{% endfor %} diff --git a/commitizen/templates/CHANGELOG.md.j2 b/commitizen/templates/CHANGELOG.md.j2 new file mode 100644 index 0000000..de63880 --- /dev/null +++ b/commitizen/templates/CHANGELOG.md.j2 @@ -0,0 +1,19 @@ +{% for entry in tree %} + +## {{ entry.version }}{% if entry.date %} ({{ entry.date }}){% endif %} + +{% for change_key, changes in entry.changes.items() %} + +{% if change_key %} +### {{ change_key }} +{% endif %} + +{% for change in changes %} +{% if change.scope %} +- **{{ change.scope }}**: {{ change.message }} +{% elif change.message %} +- {{ change.message }} +{% endif %} +{% endfor %} +{% endfor %} +{% endfor %} diff --git a/commitizen/templates/CHANGELOG.rst.j2 b/commitizen/templates/CHANGELOG.rst.j2 new file mode 100644 index 0000000..4287108 --- /dev/null +++ b/commitizen/templates/CHANGELOG.rst.j2 @@ -0,0 +1,23 @@ +{% for entry in tree %} + +{% set entry_title -%} +{{ entry.version }}{% if entry.date %} ({{ entry.date }}){% endif -%} +{%- endset %} +{{ entry_title }} +{{ "=" * entry_title|length }} +{% for change_key, changes in entry.changes.items() %} + +{% if change_key -%} +{{ change_key }} +{{ "-" * change_key|length }} +{% endif %} + +{% for change in changes %} +{% if change.scope %} +- **{{ change.scope }}**: {{ change.message }} +{% elif change.message %} +- {{ change.message }} +{% endif %} +{% endfor %} +{% endfor %} +{% endfor %} diff --git a/commitizen/templates/CHANGELOG.textile.j2 b/commitizen/templates/CHANGELOG.textile.j2 new file mode 100644 index 0000000..db55f4c --- /dev/null +++ b/commitizen/templates/CHANGELOG.textile.j2 @@ -0,0 +1,19 @@ +{% for entry in tree %} + +h2. {{ entry.version }}{% if entry.date %} ({{ entry.date }}){% endif %} + +{% for change_key, changes in entry.changes.items() %} + +{% if change_key %} +h3. {{ change_key }} +{% endif %} + +{% for change in changes %} +{% if change.scope %} +- *{{ change.scope }}*: {{ change.message }} +{% elif change.message %} +- {{ change.message }} +{% endif %} +{% endfor %} +{% endfor %} +{% endfor %} diff --git a/commitizen/version_schemes.py b/commitizen/version_schemes.py new file mode 100644 index 0000000..2486be5 --- /dev/null +++ b/commitizen/version_schemes.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +import re +import sys +import warnings +from itertools import zip_longest +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Literal, + Protocol, + cast, + runtime_checkable, +) + +if sys.version_info >= (3, 10): + from importlib import metadata +else: + import importlib_metadata as metadata + +from packaging.version import InvalidVersion # noqa: F401: Rexpose the common exception +from packaging.version import Version as _BaseVersion + +from commitizen.defaults import MAJOR, MINOR, PATCH, Settings +from commitizen.exceptions import VersionSchemeUnknown + +if TYPE_CHECKING: + # TypeAlias is Python 3.10+ but backported in typing-extensions + if sys.version_info >= (3, 10): + from typing import TypeAlias + else: + from typing_extensions import TypeAlias + + # Self is Python 3.11+ but backported in typing-extensions + if sys.version_info < (3, 11): + from typing_extensions import Self + else: + from typing import Self + + +Increment: TypeAlias = Literal["MAJOR", "MINOR", "PATCH"] +Prerelease: TypeAlias = Literal["alpha", "beta", "rc"] +DEFAULT_VERSION_PARSER = r"v?(?P<version>([0-9]+)\.([0-9]+)(?:\.([0-9]+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.]+)?(\w+)?)" + + +@runtime_checkable +class VersionProtocol(Protocol): + parser: ClassVar[re.Pattern] + """Regex capturing this version scheme into a `version` group""" + + def __init__(self, version: str): + """ + Initialize a version object from its string representation. + + :raises InvalidVersion: If the ``version`` does not conform to the scheme in any way. + """ + raise NotImplementedError("must be implemented") + + def __str__(self) -> str: + """A string representation of the version that can be rounded-tripped.""" + raise NotImplementedError("must be implemented") + + @property + def scheme(self) -> VersionScheme: + """The version scheme this version follows.""" + raise NotImplementedError("must be implemented") + + @property + def release(self) -> tuple[int, ...]: + """The components of the "release" segment of the version.""" + raise NotImplementedError("must be implemented") + + @property + def is_prerelease(self) -> bool: + """Whether this version is a pre-release.""" + raise NotImplementedError("must be implemented") + + @property + def prerelease(self) -> str | None: + """The prelease potion of the version is this is a prerelease.""" + raise NotImplementedError("must be implemented") + + @property + def public(self) -> str: + """The public portion of the version.""" + raise NotImplementedError("must be implemented") + + @property + def local(self) -> str | None: + """The local version segment of the version.""" + raise NotImplementedError("must be implemented") + + @property + def major(self) -> int: + """The first item of :attr:`release` or ``0`` if unavailable.""" + raise NotImplementedError("must be implemented") + + @property + def minor(self) -> int: + """The second item of :attr:`release` or ``0`` if unavailable.""" + raise NotImplementedError("must be implemented") + + @property + def micro(self) -> int: + """The third item of :attr:`release` or ``0`` if unavailable.""" + raise NotImplementedError("must be implemented") + + def __lt__(self, other: Any) -> bool: + raise NotImplementedError("must be implemented") + + def __le__(self, other: Any) -> bool: + raise NotImplementedError("must be implemented") + + def __eq__(self, other: object) -> bool: + raise NotImplementedError("must be implemented") + + def __ge__(self, other: Any) -> bool: + raise NotImplementedError("must be implemented") + + def __gt__(self, other: Any) -> bool: + raise NotImplementedError("must be implemented") + + def __ne__(self, other: object) -> bool: + raise NotImplementedError("must be implemented") + + def bump( + self, + increment: Increment | None, + prerelease: Prerelease | None = None, + prerelease_offset: int = 0, + devrelease: int | None = None, + is_local_version: bool = False, + build_metadata: str | None = None, + exact_increment: bool = False, + ) -> Self: + """ + Based on the given increment, generate the next bumped version according to the version scheme + + Args: + increment: The component to increase + prerelease: The type of prerelease, if Any + is_local_version: Whether to increment the local version instead + exact_increment: Treat the increment and prerelease arguments explicitly. Disables logic + that attempts to deduce the correct increment when a prelease suffix is present. + """ + + +# With PEP 440 and SemVer semantic, Scheme is the type, Version is an instance +Version: TypeAlias = VersionProtocol +VersionScheme: TypeAlias = type[VersionProtocol] + + +class BaseVersion(_BaseVersion): + """ + A base class implementing the `VersionProtocol` for PEP440-like versions. + """ + + parser: ClassVar[re.Pattern] = re.compile(DEFAULT_VERSION_PARSER) + """Regex capturing this version scheme into a `version` group""" + + @property + def scheme(self) -> VersionScheme: + return self.__class__ + + @property + def prerelease(self) -> str | None: + # version.pre is needed for mypy check + if self.is_prerelease and self.pre: + return f"{self.pre[0]}{self.pre[1]}" + return None + + def generate_prerelease( + self, prerelease: str | None = None, offset: int = 0 + ) -> str: + """Generate prerelease + + X.YaN # Alpha release + X.YbN # Beta release + X.YrcN # Release Candidate + X.Y # Final + + This function might return something like 'alpha1' + but it will be handled by Version. + """ + if not prerelease: + return "" + + # prevent down-bumping the pre-release phase, e.g. from 'b1' to 'a2' + # https://packaging.python.org/en/latest/specifications/version-specifiers/#pre-releases + # https://semver.org/#spec-item-11 + if self.is_prerelease and self.pre: + prerelease = max(prerelease, self.pre[0]) + + # version.pre is needed for mypy check + if self.is_prerelease and self.pre and prerelease.startswith(self.pre[0]): + prev_prerelease: int = self.pre[1] + new_prerelease_number = prev_prerelease + 1 + else: + new_prerelease_number = offset + pre_version = f"{prerelease}{new_prerelease_number}" + return pre_version + + def generate_devrelease(self, devrelease: int | None) -> str: + """Generate devrelease + + The devrelease version should be passed directly and is not + inferred based on the previous version. + """ + if devrelease is None: + return "" + + return f"dev{devrelease}" + + def generate_build_metadata(self, build_metadata: str | None) -> str: + """Generate build-metadata + + Build-metadata (local version) is not used in version calculations + but added after + statically. + """ + if build_metadata is None: + return "" + + return f"+{build_metadata}" + + def increment_base(self, increment: Increment | None = None) -> str: + prev_release = list(self.release) + increments = [MAJOR, MINOR, PATCH] + base = dict(zip_longest(increments, prev_release, fillvalue=0)) + + if increment == MAJOR: + base[MAJOR] += 1 + base[MINOR] = 0 + base[PATCH] = 0 + elif increment == MINOR: + base[MINOR] += 1 + base[PATCH] = 0 + elif increment == PATCH: + base[PATCH] += 1 + + return f"{base[MAJOR]}.{base[MINOR]}.{base[PATCH]}" + + def bump( + self, + increment: Increment | None, + prerelease: Prerelease | None = None, + prerelease_offset: int = 0, + devrelease: int | None = None, + is_local_version: bool = False, + build_metadata: str | None = None, + exact_increment: bool = False, + ) -> Self: + """Based on the given increment a proper semver will be generated. + + For now the rules and versioning scheme is based on + python's PEP 0440. + More info: https://www.python.org/dev/peps/pep-0440/ + + Example: + PATCH 1.0.0 -> 1.0.1 + MINOR 1.0.0 -> 1.1.0 + MAJOR 1.0.0 -> 2.0.0 + """ + + if self.local and is_local_version: + local_version = self.scheme(self.local).bump(increment) + return self.scheme(f"{self.public}+{local_version}") # type: ignore + else: + if not self.is_prerelease: + base = self.increment_base(increment) + elif exact_increment: + base = self.increment_base(increment) + else: + base = f"{self.major}.{self.minor}.{self.micro}" + if increment == PATCH: + pass + elif increment == MINOR: + if self.micro != 0: + base = self.increment_base(increment) + elif increment == MAJOR: + if self.minor != 0 or self.micro != 0: + base = self.increment_base(increment) + dev_version = self.generate_devrelease(devrelease) + + release = list(self.release) + if len(release) < 3: + release += [0] * (3 - len(release)) + current_base = ".".join(str(part) for part in release) + if base == current_base: + pre_version = self.generate_prerelease( + prerelease, offset=prerelease_offset + ) + else: + base_version = cast(BaseVersion, self.scheme(base)) + pre_version = base_version.generate_prerelease( + prerelease, offset=prerelease_offset + ) + build_metadata = self.generate_build_metadata(build_metadata) + # TODO: post version + return self.scheme(f"{base}{pre_version}{dev_version}{build_metadata}") # type: ignore + + +class Pep440(BaseVersion): + """ + PEP 440 Version Scheme + + See: https://peps.python.org/pep-0440/ + """ + + +class SemVer(BaseVersion): + """ + Semantic Versioning (SemVer) scheme + + See: https://semver.org/spec/v1.0.0.html + """ + + def __str__(self) -> str: + parts = [] + + # Epoch + if self.epoch != 0: + parts.append(f"{self.epoch}!") + + # Release segment + parts.append(".".join(str(x) for x in self.release)) + + # Pre-release + if self.prerelease: + parts.append(f"-{self.prerelease}") + + # Post-release + if self.post is not None: + parts.append(f"-post{self.post}") + + # Development release + if self.dev is not None: + parts.append(f"-dev{self.dev}") + + # Local version segment + if self.local: + parts.append(f"+{self.local}") + + return "".join(parts) + + +class SemVer2(SemVer): + """ + Semantic Versioning 2.0 (SemVer2) schema + + See: https://semver.org/spec/v2.0.0.html + """ + + _STD_PRELEASES = { + "a": "alpha", + "b": "beta", + } + + @property + def prerelease(self) -> str | None: + if self.is_prerelease and self.pre: + prerelease_type = self._STD_PRELEASES.get(self.pre[0], self.pre[0]) + return f"{prerelease_type}.{self.pre[1]}" + return None + + def __str__(self) -> str: + parts = [] + + # Epoch + if self.epoch != 0: + parts.append(f"{self.epoch}!") + + # Release segment + parts.append(".".join(str(x) for x in self.release)) + + # Pre-release identifiers + # See: https://semver.org/spec/v2.0.0.html#spec-item-9 + prerelease_parts = [] + if self.prerelease: + prerelease_parts.append(f"{self.prerelease}") + + # Post-release + if self.post is not None: + prerelease_parts.append(f"post.{self.post}") + + # Development release + if self.dev is not None: + prerelease_parts.append(f"dev.{self.dev}") + + if prerelease_parts: + parts.append("-") + parts.append(".".join(prerelease_parts)) + + # Local version segment + if self.local: + parts.append(f"+{self.local}") + + return "".join(parts) + + +DEFAULT_SCHEME: VersionScheme = Pep440 + +SCHEMES_ENTRYPOINT = "commitizen.scheme" +"""Schemes entrypoints group""" + +KNOWN_SCHEMES = [ep.name for ep in metadata.entry_points(group=SCHEMES_ENTRYPOINT)] +"""All known registered version schemes""" + + +def get_version_scheme(settings: Settings, name: str | None = None) -> VersionScheme: + """ + Get the version scheme as defined in the configuration + or from an overridden `name` + + :raises VersionSchemeUnknown: if the version scheme is not found. + """ + # TODO: Remove the deprecated `version_type` handling + deprecated_setting: str | None = settings.get("version_type") + if deprecated_setting: + warnings.warn( + DeprecationWarning( + "`version_type` setting is deprecated and will be removed in commitizen 4. " + "Please use `version_scheme` instead" + ) + ) + name = name or settings.get("version_scheme") or deprecated_setting + if not name: + return DEFAULT_SCHEME + + try: + (ep,) = metadata.entry_points(name=name, group=SCHEMES_ENTRYPOINT) + except ValueError: + raise VersionSchemeUnknown(f'Version scheme "{name}" unknown.') + scheme = cast(VersionScheme, ep.load()) + + if not isinstance(scheme, VersionProtocol): + warnings.warn(f"Version scheme {name} does not implement the VersionProtocol") + + return scheme diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..a5fd325 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +commitizen (4.6.0+dfsg-1) unstable; urgency=low + + * Initial upload to sid (Closes: #886697). + + -- Daniel Baumann <daniel@debian.org> Mon, 21 Apr 2025 10:42:43 +0200 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..1b43fdf --- /dev/null +++ b/debian/control @@ -0,0 +1,47 @@ +Source: commitizen +Section: utils +Priority: optional +Maintainer: Daniel Baumann <daniel@debian.org> +Build-Depends: + debhelper-compat (= 13), + dh-sequence-python3, + pybuild-plugin-pyproject, + python3-all, + python3-argcomplete <!nocheck>, + python3-charset-normalizer <!nocheck>, + python3-colorama <!nocheck>, + python3-dateutil <!nocheck>, + python3-decli <!nocheck>, + python3-deprecated <!nocheck>, + python3-jinja2 <!nocheck>, + python3-poetry-core, + python3-prompt-toolkit <!nocheck>, + python3-pytest <!nocheck>, + python3-pytest-freezegun <!nocheck>, + python3-pytest-mock <!nocheck>, + python3-pytest-regressions <!nocheck>, + python3-questionary <!nocheck>, + python3-termcolor <!nocheck>, + python3-tomlkit <!nocheck>, + python3-yaml <!nocheck>, +Rules-Requires-Root: no +Standards-Version: 4.7.2 +Homepage: https://github.com/commitizen-tools/commitizen +Vcs-Browser: https://forgejo.debian.net/git/commitizen +Vcs-Git: https://forgejo.debian.net/git/commitizen + +Package: commitizen +Section: utils +Architecture: all +Depends: + ${misc:Depends}, + ${python3:Depends}, +Description: Git release management tool designed for teams + Commitizen assumes your team uses a standard way of committing rules and from + that foundation, it can bump your project's version, create the changelog, and + update files. + . + By default, commitizen uses conventional commits, but you can build your own + set of rules, and publish them. Using a standardized set of rules to write + commits, makes commits easier to read, and enforces writing descriptive + commits. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..408ff69 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,32 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: commitizen +Upstream-Contact: https://github.com/commitizen-tools/commitizen/issues +Source: https://github.com/commitizen-tools/commitizen/tags +Files-Excluded: docs + +Files: * +Copyright: 2017-2025 Santiago Fraire Willemoes <santiwilly@gmail.com> +License: MIT + +Files: debian/* +Copyright: 2025 Daniel Baumann <daniel@debian.org> +License: MIT + +License: MIT + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..732cc55 --- /dev/null +++ b/debian/rules @@ -0,0 +1,12 @@ +#!/usr/bin/make -f + +%: + dh ${@} --buildsystem=pybuild + +override_dh_auto_test: + # currently 4 tests fail, 1031 pass - needs fixing upstream + dh_auto_test || true + +execute_after_dh_auto_clean: + # help pybuild + rm -rf *.egg-info diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/debian/watch b/debian/watch new file mode 100644 index 0000000..fc2cd76 --- /dev/null +++ b/debian/watch @@ -0,0 +1,3 @@ +version=4 +opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/commitizen-$1\.tar\.gz/ \ +https://github.com/commitizen-tools/commitizen/tags .*/v?(\d\S+)\.tar\.gz diff --git a/hooks/post-commit.py b/hooks/post-commit.py new file mode 100755 index 0000000..c7dea82 --- /dev/null +++ b/hooks/post-commit.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +from pathlib import Path + +try: + from commitizen.cz.utils import get_backup_file_path +except ImportError as error: + print(f"could not import commitizen:\n{error}") + exit(1) + + +def post_commit() -> None: + backup_file = Path(get_backup_file_path()) + + # remove backup file if it exists + if backup_file.is_file(): + backup_file.unlink() + + +if __name__ == "__main__": + post_commit() + exit(0) diff --git a/hooks/prepare-commit-msg.py b/hooks/prepare-commit-msg.py new file mode 100755 index 0000000..e666fa6 --- /dev/null +++ b/hooks/prepare-commit-msg.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +import shutil +import subprocess +import sys +from pathlib import Path +from subprocess import CalledProcessError + +try: + from commitizen.cz.utils import get_backup_file_path +except ImportError as error: + print("could not import commitizen:") + print(error) + exit(1) + + +def prepare_commit_msg(commit_msg_file: str) -> int: + # check if the commit message needs to be generated using commitizen + exit_code = subprocess.run( + [ + "cz", + "check", + "--commit-msg-file", + commit_msg_file, + ], + capture_output=True, + ).returncode + if exit_code != 0: + backup_file = Path(get_backup_file_path()) + if backup_file.is_file(): + # confirm if commit message from backup file should be reused + answer = input("retry with previous message? [y/N]: ") + if answer.lower() == "y": + shutil.copyfile(backup_file, commit_msg_file) + return 0 + + # use commitizen to generate the commit message + try: + subprocess.run( + [ + "cz", + "commit", + "--dry-run", + "--write-message-to-file", + commit_msg_file, + ], + stdin=sys.stdin, + stdout=sys.stdout, + ).check_returncode() + except CalledProcessError as error: + return error.returncode + + # write message to backup file + shutil.copyfile(commit_msg_file, backup_file) + return 0 + + +if __name__ == "__main__": + # make hook interactive by attaching /dev/tty to stdin + with open("/dev/tty") as tty: + sys.stdin = tty + exit(prepare_commit_msg(sys.argv[1])) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..6a64216 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,78 @@ +site_name: Commitizen +site_description: commit rules, semantic version, conventional commits + +theme: + name: "material" + palette: + - primary: 'deep purple' + # Palette toggle for automatic mode + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + + # Palette toggle for light mode + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + + # Palette toggle for dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to system preference + +repo_name: commitizen-tools/commitizen +repo_url: https://github.com/commitizen-tools/commitizen +edit_uri: "" + +nav: + - Introduction: "README.md" + - Getting Started: "getting_started.md" + - Commands: + - init: "commands/init.md" + - commit: "commands/commit.md" + - bump: "commands/bump.md" + - check: "commands/check.md" + - changelog: "commands/changelog.md" + - example: "commands/example.md" + - info: "commands/info.md" + - ls: "commands/ls.md" + - schema: "commands/schema.md" + - version: "commands/version.md" + - Configuration: "config.md" + - Customization: "customization.md" + - Tutorials: + - Writing commits: "tutorials/writing_commits.md" + - Managing tags formats: "tutorials/tag_format.md" + - Auto check commits: "tutorials/auto_check.md" + - Auto prepare commit message: "tutorials/auto_prepare_commit_message.md" + - GitLab CI: "tutorials/gitlab_ci.md" + - Github Actions: "tutorials/github_actions.md" + - Jenkins pipeline: "tutorials/jenkins_pipeline.md" + - Developmental releases: "tutorials/dev_releases.md" + - Monorepo support: "tutorials/monorepo_guidance.md" + - FAQ: "faq.md" + - Exit Codes: "exit_codes.md" + - Third-Party Commitizen Templates: "third-party-commitizen.md" + - Contributing: "contributing.md" + - Resources: "external_links.md" + +markdown_extensions: + - markdown.extensions.codehilite: + guess_lang: false + - admonition + - codehilite + - extra + - pymdownx.highlight + - pymdownx.superfences + - toc: + permalink: true + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..073e1bc --- /dev/null +++ b/poetry.lock @@ -0,0 +1,1968 @@ +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. + +[[package]] +name = "argcomplete" +version = "3.5.3" +description = "Bash tab completion for argparse" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "argcomplete-3.5.3-py3-none-any.whl", hash = "sha256:2ab2c4a215c59fd6caaff41a869480a23e8f6a5f910b266c1808037f4e375b61"}, + {file = "argcomplete-3.5.3.tar.gz", hash = "sha256:c12bf50eded8aebb298c7b7da7a5ff3ee24dffd9f5281867dfe1424b58c55392"}, +] + +[package.extras] +test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] + +[[package]] +name = "asttokens" +version = "2.4.1" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24"}, + {file = "asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0"}, +] + +[package.dependencies] +six = ">=1.12.0" + +[package.extras] +astroid = ["astroid (>=1,<2)", "astroid (>=2,<4)"] +test = ["astroid (>=1,<2)", "astroid (>=2,<4)", "pytest"] + +[[package]] +name = "babel" +version = "2.16.0" +description = "Internationalization utilities" +optional = false +python-versions = ">=3.8" +groups = ["documentation"] +files = [ + {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, + {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, +] + +[package.extras] +dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] + +[[package]] +name = "cachetools" +version = "5.5.1" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "cachetools-5.5.1-py3-none-any.whl", hash = "sha256:b76651fdc3b24ead3c648bbdeeb940c1b04d365b38b4af66788f9ec4a81d42bb"}, + {file = "cachetools-5.5.1.tar.gz", hash = "sha256:70f238fbba50383ef62e55c6aff6d9673175fe59f7c6782c7a0b9e38f4a9df95"}, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["documentation"] +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +groups = ["linters"] +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "chardet" +version = "5.2.0" +description = "Universal encoding detector for Python 3" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, + {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main", "documentation"] +files = [ + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +groups = ["documentation"] +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev", "documentation", "test"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.6.8" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "coverage-7.6.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b39e6011cd06822eb964d038d5dff5da5d98652b81f5ecd439277b32361a3a50"}, + {file = "coverage-7.6.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:63c19702db10ad79151a059d2d6336fe0c470f2e18d0d4d1a57f7f9713875dcf"}, + {file = "coverage-7.6.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3985b9be361d8fb6b2d1adc9924d01dec575a1d7453a14cccd73225cb79243ee"}, + {file = "coverage-7.6.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:644ec81edec0f4ad17d51c838a7d01e42811054543b76d4ba2c5d6af741ce2a6"}, + {file = "coverage-7.6.8-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f188a2402f8359cf0c4b1fe89eea40dc13b52e7b4fd4812450da9fcd210181d"}, + {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e19122296822deafce89a0c5e8685704c067ae65d45e79718c92df7b3ec3d331"}, + {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:13618bed0c38acc418896005732e565b317aa9e98d855a0e9f211a7ffc2d6638"}, + {file = "coverage-7.6.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:193e3bffca48ad74b8c764fb4492dd875038a2f9925530cb094db92bb5e47bed"}, + {file = "coverage-7.6.8-cp310-cp310-win32.whl", hash = "sha256:3988665ee376abce49613701336544041f2117de7b7fbfe91b93d8ff8b151c8e"}, + {file = "coverage-7.6.8-cp310-cp310-win_amd64.whl", hash = "sha256:f56f49b2553d7dd85fd86e029515a221e5c1f8cb3d9c38b470bc38bde7b8445a"}, + {file = "coverage-7.6.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:86cffe9c6dfcfe22e28027069725c7f57f4b868a3f86e81d1c62462764dc46d4"}, + {file = "coverage-7.6.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d82ab6816c3277dc962cfcdc85b1efa0e5f50fb2c449432deaf2398a2928ab94"}, + {file = "coverage-7.6.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13690e923a3932e4fad4c0ebfb9cb5988e03d9dcb4c5150b5fcbf58fd8bddfc4"}, + {file = "coverage-7.6.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be32da0c3827ac9132bb488d331cb32e8d9638dd41a0557c5569d57cf22c9c1"}, + {file = "coverage-7.6.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44e6c85bbdc809383b509d732b06419fb4544dca29ebe18480379633623baafb"}, + {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:768939f7c4353c0fac2f7c37897e10b1414b571fd85dd9fc49e6a87e37a2e0d8"}, + {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e44961e36cb13c495806d4cac67640ac2866cb99044e210895b506c26ee63d3a"}, + {file = "coverage-7.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ea8bb1ab9558374c0ab591783808511d135a833c3ca64a18ec927f20c4030f0"}, + {file = "coverage-7.6.8-cp311-cp311-win32.whl", hash = "sha256:629a1ba2115dce8bf75a5cce9f2486ae483cb89c0145795603d6554bdc83e801"}, + {file = "coverage-7.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:fb9fc32399dca861584d96eccd6c980b69bbcd7c228d06fb74fe53e007aa8ef9"}, + {file = "coverage-7.6.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee"}, + {file = "coverage-7.6.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a"}, + {file = "coverage-7.6.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d"}, + {file = "coverage-7.6.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb"}, + {file = "coverage-7.6.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649"}, + {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787"}, + {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c"}, + {file = "coverage-7.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443"}, + {file = "coverage-7.6.8-cp312-cp312-win32.whl", hash = "sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad"}, + {file = "coverage-7.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4"}, + {file = "coverage-7.6.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb"}, + {file = "coverage-7.6.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63"}, + {file = "coverage-7.6.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365"}, + {file = "coverage-7.6.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002"}, + {file = "coverage-7.6.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3"}, + {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022"}, + {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e"}, + {file = "coverage-7.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b"}, + {file = "coverage-7.6.8-cp313-cp313-win32.whl", hash = "sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146"}, + {file = "coverage-7.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28"}, + {file = "coverage-7.6.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d"}, + {file = "coverage-7.6.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451"}, + {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764"}, + {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf"}, + {file = "coverage-7.6.8-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5"}, + {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4"}, + {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83"}, + {file = "coverage-7.6.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b"}, + {file = "coverage-7.6.8-cp313-cp313t-win32.whl", hash = "sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71"}, + {file = "coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc"}, + {file = "coverage-7.6.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ac47fa29d8d41059ea3df65bd3ade92f97ee4910ed638e87075b8e8ce69599e"}, + {file = "coverage-7.6.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:24eda3a24a38157eee639ca9afe45eefa8d2420d49468819ac5f88b10de84f4c"}, + {file = "coverage-7.6.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4c81ed2820b9023a9a90717020315e63b17b18c274a332e3b6437d7ff70abe0"}, + {file = "coverage-7.6.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd55f8fc8fa494958772a2a7302b0354ab16e0b9272b3c3d83cdb5bec5bd1779"}, + {file = "coverage-7.6.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f39e2f3530ed1626c66e7493be7a8423b023ca852aacdc91fb30162c350d2a92"}, + {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:716a78a342679cd1177bc8c2fe957e0ab91405bd43a17094324845200b2fddf4"}, + {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:177f01eeaa3aee4a5ffb0d1439c5952b53d5010f86e9d2667963e632e30082cc"}, + {file = "coverage-7.6.8-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:912e95017ff51dc3d7b6e2be158dedc889d9a5cc3382445589ce554f1a34c0ea"}, + {file = "coverage-7.6.8-cp39-cp39-win32.whl", hash = "sha256:4db3ed6a907b555e57cc2e6f14dc3a4c2458cdad8919e40b5357ab9b6db6c43e"}, + {file = "coverage-7.6.8-cp39-cp39-win_amd64.whl", hash = "sha256:428ac484592f780e8cd7b6b14eb568f7c85460c92e2a37cb0c0e5186e1a0d076"}, + {file = "coverage-7.6.8-pp39.pp310-none-any.whl", hash = "sha256:5c52a036535d12590c32c49209e79cabaad9f9ad8aa4cbd875b68c4d67a9cbce"}, + {file = "coverage-7.6.8.tar.gz", hash = "sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "decli" +version = "0.6.2" +description = "Minimal, easy-to-use, declarative cli tool" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "decli-0.6.2-py3-none-any.whl", hash = "sha256:2fc84106ce9a8f523ed501ca543bdb7e416c064917c12a59ebdc7f311a97b7ed"}, + {file = "decli-0.6.2.tar.gz", hash = "sha256:36f71eb55fd0093895efb4f416ec32b7f6e00147dda448e3365cf73ceab42d6f"}, +] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +groups = ["dev"] +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + +[[package]] +name = "deprecated" +version = "1.2.18" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +groups = ["test"] +files = [ + {file = "Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec"}, + {file = "deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d"}, +] + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools", "tox"] + +[[package]] +name = "distlib" +version = "0.3.9" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["dev", "linters"] +files = [ + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["dev", "test"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "execnet" +version = "2.1.1" +description = "execnet: rapid multi-Python deployment" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"}, + {file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"}, +] + +[package.extras] +testing = ["hatch", "pre-commit", "pytest", "tox"] + +[[package]] +name = "executing" +version = "2.1.0" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "executing-2.1.0-py2.py3-none-any.whl", hash = "sha256:8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf"}, + {file = "executing-2.1.0.tar.gz", hash = "sha256:8ea27ddd260da8150fa5a708269c4a10e76161e2496ec3e587da9e3c0fe4b9ab"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] + +[[package]] +name = "filelock" +version = "3.16.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +groups = ["dev", "linters"] +files = [ + {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, + {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] +typing = ["typing-extensions (>=4.12.2)"] + +[[package]] +name = "freezegun" +version = "1.5.1" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.7" +groups = ["test"] +files = [ + {file = "freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1"}, + {file = "freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + +[[package]] +name = "ghp-import" +version = "2.1.0" +description = "Copy your docs directly to the gh-pages branch." +optional = false +python-versions = "*" +groups = ["documentation"] +files = [ + {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, + {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, +] + +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["flake8", "markdown", "twine", "wheel"] + +[[package]] +name = "identify" +version = "2.6.3" +description = "File identification library for Python" +optional = false +python-versions = ">=3.9" +groups = ["linters"] +files = [ + {file = "identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd"}, + {file = "identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["documentation"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "importlib-metadata" +version = "8.6.1" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.9" +groups = ["main", "documentation"] +markers = "python_version < \"3.10\"" +files = [ + {file = "importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e"}, + {file = "importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580"}, +] + +[package.dependencies] +zipp = ">=3.20" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib_resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +groups = ["test"] +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "ipython" +version = "8.18.1" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "ipython-8.18.1-py3-none-any.whl", hash = "sha256:e8267419d72d81955ec1177f8a29aaa90ac80ad647499201119e2f05e99aa397"}, + {file = "ipython-8.18.1.tar.gz", hash = "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} +prompt-toolkit = ">=3.0.41,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5" +typing-extensions = {version = "*", markers = "python_version < \"3.10\""} + +[package.extras] +all = ["black", "curio", "docrepr", "exceptiongroup", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio (<0.22)", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] +black = ["black"] +doc = ["docrepr", "exceptiongroup", "ipykernel", "matplotlib", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio (<0.22)", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath"] +test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath", "trio"] + +[[package]] +name = "jedi" +version = "0.19.2" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"}, + {file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"}, +] + +[package.dependencies] +parso = ">=0.8.4,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<9.0.0)"] + +[[package]] +name = "jinja2" +version = "3.1.5" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +groups = ["main", "documentation"] +files = [ + {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, + {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markdown" +version = "3.7" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.8" +groups = ["documentation"] +files = [ + {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, + {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} + +[package.extras] +docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +groups = ["script"] +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main", "documentation"] +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, + {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, +] + +[package.dependencies] +traitlets = "*" + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["script"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for ๐Ÿ." +optional = false +python-versions = ">=3.6" +groups = ["documentation"] +files = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +description = "Project documentation with Markdown." +optional = false +python-versions = ">=3.8" +groups = ["documentation"] +files = [ + {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, + {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} +ghp-import = ">=1.0" +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} +jinja2 = ">=2.11.1" +markdown = ">=3.3.6" +markupsafe = ">=2.0.1" +mergedeep = ">=1.3.4" +mkdocs-get-deps = ">=0.2.0" +packaging = ">=20.5" +pathspec = ">=0.11.1" +pyyaml = ">=5.1" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" + +[package.extras] +i18n = ["babel (>=2.9.0)"] +min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.4)", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" +optional = false +python-versions = ">=3.8" +groups = ["documentation"] +files = [ + {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, + {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, +] + +[package.dependencies] +importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} +mergedeep = ">=1.3.4" +platformdirs = ">=2.2.0" +pyyaml = ">=5.1" + +[[package]] +name = "mkdocs-material" +version = "9.5.50" +description = "Documentation that simply works" +optional = false +python-versions = ">=3.8" +groups = ["documentation"] +files = [ + {file = "mkdocs_material-9.5.50-py3-none-any.whl", hash = "sha256:f24100f234741f4d423a9d672a909d859668a4f404796be3cf035f10d6050385"}, + {file = "mkdocs_material-9.5.50.tar.gz", hash = "sha256:ae5fe16f3d7c9ccd05bb6916a7da7420cf99a9ce5e33debd9d40403a090d5825"}, +] + +[package.dependencies] +babel = ">=2.10,<3.0" +colorama = ">=0.4,<1.0" +jinja2 = ">=3.0,<4.0" +markdown = ">=3.2,<4.0" +mkdocs = ">=1.6,<2.0" +mkdocs-material-extensions = ">=1.3,<2.0" +paginate = ">=0.5,<1.0" +pygments = ">=2.16,<3.0" +pymdown-extensions = ">=10.2,<11.0" +regex = ">=2022.4" +requests = ">=2.26,<3.0" + +[package.extras] +git = ["mkdocs-git-committers-plugin-2 (>=1.1,<3)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"] +imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"] +recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +description = "Extension pack for Python Markdown and MkDocs Material." +optional = false +python-versions = ">=3.8" +groups = ["documentation"] +files = [ + {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, + {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, +] + +[[package]] +name = "mypy" +version = "1.14.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +groups = ["linters"] +files = [ + {file = "mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb"}, + {file = "mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0"}, + {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d"}, + {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b"}, + {file = "mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427"}, + {file = "mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f"}, + {file = "mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c"}, + {file = "mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1"}, + {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8"}, + {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f"}, + {file = "mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1"}, + {file = "mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae"}, + {file = "mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14"}, + {file = "mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9"}, + {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11"}, + {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e"}, + {file = "mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89"}, + {file = "mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b"}, + {file = "mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255"}, + {file = "mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34"}, + {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a"}, + {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9"}, + {file = "mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd"}, + {file = "mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107"}, + {file = "mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31"}, + {file = "mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6"}, + {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319"}, + {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac"}, + {file = "mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b"}, + {file = "mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837"}, + {file = "mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35"}, + {file = "mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc"}, + {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9"}, + {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb"}, + {file = "mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60"}, + {file = "mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c"}, + {file = "mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1"}, + {file = "mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6"}, +] + +[package.dependencies] +mypy_extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing_extensions = ">=4.6.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +groups = ["linters"] +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["linters"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev", "documentation", "test"] +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "paginate" +version = "0.5.7" +description = "Divides large result sets into pages for easier browsing" +optional = false +python-versions = "*" +groups = ["documentation"] +files = [ + {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"}, + {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"}, +] + +[package.extras] +dev = ["pytest", "tox"] +lint = ["black"] + +[[package]] +name = "parso" +version = "0.8.4" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, + {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, +] + +[package.extras] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["docopt", "pytest"] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["documentation"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +groups = ["dev"] +markers = "sys_platform != \"win32\"" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "platformdirs" +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +groups = ["dev", "documentation", "linters"] +files = [ + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +groups = ["dev", "test"] +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "4.1.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +groups = ["linters"] +files = [ + {file = "pre_commit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b"}, + {file = "pre_commit-4.1.0.tar.gz", hash = "sha256:ae3f018575a588e30dfddfab9a05448bfbd6b73d78709617b5a2b853549716d4"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "prompt-toolkit" +version = "3.0.48" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +groups = ["main", "dev"] +files = [ + {file = "prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"}, + {file = "prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +groups = ["dev"] +markers = "sys_platform != \"win32\"" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, + {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, +] + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev", "documentation", "script"] +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pymdown-extensions" +version = "10.12" +description = "Extension pack for Python Markdown." +optional = false +python-versions = ">=3.8" +groups = ["documentation"] +files = [ + {file = "pymdown_extensions-10.12-py3-none-any.whl", hash = "sha256:49f81412242d3527b8b4967b990df395c89563043bc51a3d2d7d500e52123b77"}, + {file = "pymdown_extensions-10.12.tar.gz", hash = "sha256:b0ee1e0b2bef1071a47891ab17003bfe5bf824a398e13f49f8ed653b699369a7"}, +] + +[package.dependencies] +markdown = ">=3.6" +pyyaml = "*" + +[package.extras] +extra = ["pygments (>=2.12)"] + +[[package]] +name = "pyproject-api" +version = "1.9.0" +description = "API to interact with the python pyproject.toml based projects" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pyproject_api-1.9.0-py3-none-any.whl", hash = "sha256:326df9d68dea22d9d98b5243c46e3ca3161b07a1b9b18e213d1e24fd0e605766"}, + {file = "pyproject_api-1.9.0.tar.gz", hash = "sha256:7e8a9854b2dfb49454fae421cb86af43efbb2b2454e5646ffb7623540321ae6e"}, +] + +[package.dependencies] +packaging = ">=24.2" +tomli = {version = ">=2.2.1", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx-autodoc-typehints (>=3)"] +testing = ["covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "setuptools (>=75.8)"] + +[[package]] +name = "pytest" +version = "8.3.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, + {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "6.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, + {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, +] + +[package.dependencies] +coverage = {version = ">=7.5", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pytest-datadir" +version = "1.5.0" +description = "pytest plugin for test data directories and files" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "pytest-datadir-1.5.0.tar.gz", hash = "sha256:1617ed92f9afda0c877e4eac91904b5f779d24ba8f5e438752e3ae39d8d2ee3f"}, + {file = "pytest_datadir-1.5.0-py3-none-any.whl", hash = "sha256:34adf361bcc7b37961bbc1dfa8d25a4829e778bab461703c38a5c50ca9c36dc8"}, +] + +[package.dependencies] +pytest = ">=5.0" + +[[package]] +name = "pytest-freezer" +version = "0.4.9" +description = "Pytest plugin providing a fixture interface for spulec/freezegun" +optional = false +python-versions = ">=3.6" +groups = ["test"] +files = [ + {file = "pytest_freezer-0.4.9-py3-none-any.whl", hash = "sha256:8b6c50523b7d4aec4590b52bfa5ff766d772ce506e2bf4846c88041ea9ccae59"}, + {file = "pytest_freezer-0.4.9.tar.gz", hash = "sha256:21bf16bc9cc46bf98f94382c4b5c3c389be7056ff0be33029111ae11b3f1c82a"}, +] + +[package.dependencies] +freezegun = ">=1.1" +pytest = ">=3.6" + +[[package]] +name = "pytest-mock" +version = "3.14.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "pytest-regressions" +version = "2.7.0" +description = "Easy to use fixtures to write regression tests." +optional = false +python-versions = ">=3.9" +groups = ["test"] +files = [ + {file = "pytest_regressions-2.7.0-py3-none-any.whl", hash = "sha256:69f5e3f03493cf0ef84d96d23e50a546617c198b1d7746f2e2b9e441cbab4847"}, + {file = "pytest_regressions-2.7.0.tar.gz", hash = "sha256:4c30064e0923929012c94f5d6f35205be06fd8709c7f0dba0228e05c460af05e"}, +] + +[package.dependencies] +pytest = ">=6.2.0" +pytest-datadir = ">=1.2.0" +pyyaml = "*" + +[package.extras] +dataframe = ["numpy", "pandas"] +dev = ["matplotlib", "mypy", "numpy", "pandas", "pillow", "pre-commit", "pyarrow", "restructuredtext-lint", "tox"] +image = ["numpy", "pillow"] +num = ["numpy", "pandas"] + +[[package]] +name = "pytest-xdist" +version = "3.6.1" +description = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs" +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"}, + {file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"}, +] + +[package.dependencies] +execnet = ">=2.1" +pytest = ">=7.0.0" + +[package.extras] +psutil = ["psutil (>=3.0)"] +setproctitle = ["setproctitle"] +testing = ["filelock"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["documentation", "test"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main", "documentation", "linters", "test"] +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "pyyaml-env-tag" +version = "0.1" +description = "A custom YAML tag for referencing environment variables in YAML files. " +optional = false +python-versions = ">=3.6" +groups = ["documentation"] +files = [ + {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, + {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, +] + +[package.dependencies] +pyyaml = "*" + +[[package]] +name = "questionary" +version = "2.1.0" +description = "Python library to build pretty command line user prompts โญ๏ธ" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "questionary-2.1.0-py3-none-any.whl", hash = "sha256:44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec"}, + {file = "questionary-2.1.0.tar.gz", hash = "sha256:6302cdd645b19667d8f6e6634774e9538bfcd1aad9be287e743d96cacaf95587"}, +] + +[package.dependencies] +prompt_toolkit = ">=2.0,<4.0" + +[[package]] +name = "regex" +version = "2024.11.6" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.8" +groups = ["documentation"] +files = [ + {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, + {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, + {file = "regex-2024.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62"}, + {file = "regex-2024.11.6-cp310-cp310-win32.whl", hash = "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e"}, + {file = "regex-2024.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45"}, + {file = "regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9"}, + {file = "regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad"}, + {file = "regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54"}, + {file = "regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d"}, + {file = "regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff"}, + {file = "regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3a51ccc315653ba012774efca4f23d1d2a8a8f278a6072e29c7147eee7da446b"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ad182d02e40de7459b73155deb8996bbd8e96852267879396fb274e8700190e3"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba9b72e5643641b7d41fa1f6d5abda2c9a263ae835b917348fc3c928182ad467"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40291b1b89ca6ad8d3f2b82782cc33807f1406cf68c8d440861da6304d8ffbbd"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdf58d0e516ee426a48f7b2c03a332a4114420716d55769ff7108c37a09951bf"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a36fdf2af13c2b14738f6e973aba563623cb77d753bbbd8d414d18bfaa3105dd"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1cee317bfc014c2419a76bcc87f071405e3966da434e03e13beb45f8aced1a6"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50153825ee016b91549962f970d6a4442fa106832e14c918acd1c8e479916c4f"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ea1bfda2f7162605f6e8178223576856b3d791109f15ea99a9f95c16a7636fb5"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:df951c5f4a1b1910f1a99ff42c473ff60f8225baa1cdd3539fe2819d9543e9df"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:072623554418a9911446278f16ecb398fb3b540147a7828c06e2011fa531e773"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f654882311409afb1d780b940234208a252322c24a93b442ca714d119e68086c"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:89d75e7293d2b3e674db7d4d9b1bee7f8f3d1609428e293771d1a962617150cc"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f65557897fc977a44ab205ea871b690adaef6b9da6afda4790a2484b04293a5f"}, + {file = "regex-2024.11.6-cp38-cp38-win32.whl", hash = "sha256:6f44ec28b1f858c98d3036ad5d7d0bfc568bdd7a74f9c24e25f41ef1ebfd81a4"}, + {file = "regex-2024.11.6-cp38-cp38-win_amd64.whl", hash = "sha256:bb8f74f2f10dbf13a0be8de623ba4f9491faf58c24064f32b65679b021ed0001"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b"}, + {file = "regex-2024.11.6-cp39-cp39-win32.whl", hash = "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57"}, + {file = "regex-2024.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983"}, + {file = "regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519"}, +] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["documentation"] +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "rich" +version = "13.9.4" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["script"] +files = [ + {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, + {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "ruff" +version = "0.9.4" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["linters"] +files = [ + {file = "ruff-0.9.4-py3-none-linux_armv6l.whl", hash = "sha256:64e73d25b954f71ff100bb70f39f1ee09e880728efb4250c632ceed4e4cdf706"}, + {file = "ruff-0.9.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6ce6743ed64d9afab4fafeaea70d3631b4d4b28b592db21a5c2d1f0ef52934bf"}, + {file = "ruff-0.9.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:54499fb08408e32b57360f6f9de7157a5fec24ad79cb3f42ef2c3f3f728dfe2b"}, + {file = "ruff-0.9.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37c892540108314a6f01f105040b5106aeb829fa5fb0561d2dcaf71485021137"}, + {file = "ruff-0.9.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de9edf2ce4b9ddf43fd93e20ef635a900e25f622f87ed6e3047a664d0e8f810e"}, + {file = "ruff-0.9.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87c90c32357c74f11deb7fbb065126d91771b207bf9bfaaee01277ca59b574ec"}, + {file = "ruff-0.9.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:56acd6c694da3695a7461cc55775f3a409c3815ac467279dfa126061d84b314b"}, + {file = "ruff-0.9.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0c93e7d47ed951b9394cf352d6695b31498e68fd5782d6cbc282425655f687a"}, + {file = "ruff-0.9.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d4c8772670aecf037d1bf7a07c39106574d143b26cfe5ed1787d2f31e800214"}, + {file = "ruff-0.9.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfc5f1d7afeda8d5d37660eeca6d389b142d7f2b5a1ab659d9214ebd0e025231"}, + {file = "ruff-0.9.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:faa935fc00ae854d8b638c16a5f1ce881bc3f67446957dd6f2af440a5fc8526b"}, + {file = "ruff-0.9.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6c634fc6f5a0ceae1ab3e13c58183978185d131a29c425e4eaa9f40afe1e6d6"}, + {file = "ruff-0.9.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:433dedf6ddfdec7f1ac7575ec1eb9844fa60c4c8c2f8887a070672b8d353d34c"}, + {file = "ruff-0.9.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d612dbd0f3a919a8cc1d12037168bfa536862066808960e0cc901404b77968f0"}, + {file = "ruff-0.9.4-py3-none-win32.whl", hash = "sha256:db1192ddda2200671f9ef61d9597fcef89d934f5d1705e571a93a67fb13a4402"}, + {file = "ruff-0.9.4-py3-none-win_amd64.whl", hash = "sha256:05bebf4cdbe3ef75430d26c375773978950bbf4ee3c95ccb5448940dc092408e"}, + {file = "ruff-0.9.4-py3-none-win_arm64.whl", hash = "sha256:585792f1e81509e38ac5123492f8875fbc36f3ede8185af0a26df348e5154f41"}, + {file = "ruff-0.9.4.tar.gz", hash = "sha256:6907ee3529244bb0ed066683e075f09285b38dd5b4039370df6ff06041ca19e7"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["dev", "documentation", "test"] +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + +[[package]] +name = "termcolor" +version = "2.5.0" +description = "ANSI color formatting for output in terminal" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "termcolor-2.5.0-py3-none-any.whl", hash = "sha256:37b17b5fc1e604945c2642c872a3764b5d547a48009871aea3edd3afa180afb8"}, + {file = "termcolor-2.5.0.tar.gz", hash = "sha256:998d8d27da6d48442e8e1f016119076b690d962507531df4890fcd2db2ef8a6f"}, +] + +[package.extras] +tests = ["pytest", "pytest-cov"] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev", "linters", "test"] +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] +markers = {dev = "python_version < \"3.11\"", linters = "python_version < \"3.11\"", test = "python_full_version <= \"3.11.0a6\""} + +[[package]] +name = "tomlkit" +version = "0.13.2" +description = "Style preserving TOML library" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, + {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, +] + +[[package]] +name = "tox" +version = "4.24.1" +description = "tox is a generic virtualenv management and test command line tool" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "tox-4.24.1-py3-none-any.whl", hash = "sha256:57ba7df7d199002c6df8c2db9e6484f3de6ca8f42013c083ea2d4d1e5c6bdc75"}, + {file = "tox-4.24.1.tar.gz", hash = "sha256:083a720adbc6166fff0b7d1df9d154f9d00bfccb9403b8abf6bc0ee435d6a62e"}, +] + +[package.dependencies] +cachetools = ">=5.5" +chardet = ">=5.2" +colorama = ">=0.4.6" +filelock = ">=3.16.1" +packaging = ">=24.2" +platformdirs = ">=4.3.6" +pluggy = ">=1.5" +pyproject-api = ">=1.8" +tomli = {version = ">=2.1", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.12.2", markers = "python_version < \"3.11\""} +virtualenv = ">=20.27.1" + +[package.extras] +test = ["devpi-process (>=1.0.2)", "pytest (>=8.3.3)", "pytest-mock (>=3.14)"] + +[[package]] +name = "traitlets" +version = "5.14.3" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, + {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] + +[[package]] +name = "types-deprecated" +version = "1.2.15.20241117" +description = "Typing stubs for Deprecated" +optional = false +python-versions = ">=3.8" +groups = ["linters"] +files = [ + {file = "types-Deprecated-1.2.15.20241117.tar.gz", hash = "sha256:924002c8b7fddec51ba4949788a702411a2e3636cd9b2a33abd8ee119701d77e"}, + {file = "types_Deprecated-1.2.15.20241117-py3-none-any.whl", hash = "sha256:a0cc5e39f769fc54089fd8e005416b55d74aa03f6964d2ed1a0b0b2e28751884"}, +] + +[[package]] +name = "types-python-dateutil" +version = "2.9.0.20241206" +description = "Typing stubs for python-dateutil" +optional = false +python-versions = ">=3.8" +groups = ["linters"] +files = [ + {file = "types_python_dateutil-2.9.0.20241206-py3-none-any.whl", hash = "sha256:e248a4bc70a486d3e3ec84d0dc30eec3a5f979d6e7ee4123ae043eedbb987f53"}, + {file = "types_python_dateutil-2.9.0.20241206.tar.gz", hash = "sha256:18f493414c26ffba692a72369fea7a154c502646301ebfe3d56a04b3767284cb"}, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20241230" +description = "Typing stubs for PyYAML" +optional = false +python-versions = ">=3.8" +groups = ["linters"] +files = [ + {file = "types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6"}, + {file = "types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c"}, +] + +[[package]] +name = "types-termcolor" +version = "0.1.1" +description = "Typing stubs for termcolor" +optional = false +python-versions = "*" +groups = ["linters"] +files = [ + {file = "types-termcolor-0.1.1.tar.gz", hash = "sha256:4d9e09ce7f3267985f5280b22e25790c98cb64628b6466e1fb915dbb52ad7136"}, + {file = "types_termcolor-0.1.1-py2.py3-none-any.whl", hash = "sha256:3694c312e32f71fdc0f469c334ea21645f3130d90c93cd53bcb06b1233e174d5"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev", "linters", "script"] +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] +markers = {main = "python_version < \"3.11\"", dev = "python_version < \"3.11\"", script = "python_version < \"3.11\""} + +[[package]] +name = "urllib3" +version = "2.2.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +groups = ["documentation"] +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "virtualenv" +version = "20.27.1" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["dev", "linters"] +files = [ + {file = "virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4"}, + {file = "virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[[package]] +name = "watchdog" +version = "6.0.0" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.9" +groups = ["documentation"] +files = [ + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, + {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, + {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, + {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, + {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, + {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, + {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, + {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, + {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, + {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, + {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, + {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, + {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)"] + +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +groups = ["main", "dev"] +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + +[[package]] +name = "wrapt" +version = "1.17.0" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "wrapt-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a0c23b8319848426f305f9cb0c98a6e32ee68a36264f45948ccf8e7d2b941f8"}, + {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1ca5f060e205f72bec57faae5bd817a1560fcfc4af03f414b08fa29106b7e2d"}, + {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e185ec6060e301a7e5f8461c86fb3640a7beb1a0f0208ffde7a65ec4074931df"}, + {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb90765dd91aed05b53cd7a87bd7f5c188fcd95960914bae0d32c5e7f899719d"}, + {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:879591c2b5ab0a7184258274c42a126b74a2c3d5a329df16d69f9cee07bba6ea"}, + {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fce6fee67c318fdfb7f285c29a82d84782ae2579c0e1b385b7f36c6e8074fffb"}, + {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0698d3a86f68abc894d537887b9bbf84d29bcfbc759e23f4644be27acf6da301"}, + {file = "wrapt-1.17.0-cp310-cp310-win32.whl", hash = "sha256:69d093792dc34a9c4c8a70e4973a3361c7a7578e9cd86961b2bbf38ca71e4e22"}, + {file = "wrapt-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:f28b29dc158ca5d6ac396c8e0a2ef45c4e97bb7e65522bfc04c989e6fe814575"}, + {file = "wrapt-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74bf625b1b4caaa7bad51d9003f8b07a468a704e0644a700e936c357c17dd45a"}, + {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f2a28eb35cf99d5f5bd12f5dd44a0f41d206db226535b37b0c60e9da162c3ed"}, + {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81b1289e99cf4bad07c23393ab447e5e96db0ab50974a280f7954b071d41b489"}, + {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f2939cd4a2a52ca32bc0b359015718472d7f6de870760342e7ba295be9ebaf9"}, + {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a9653131bda68a1f029c52157fd81e11f07d485df55410401f745007bd6d339"}, + {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4e4b4385363de9052dac1a67bfb535c376f3d19c238b5f36bddc95efae15e12d"}, + {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bdf62d25234290db1837875d4dceb2151e4ea7f9fff2ed41c0fde23ed542eb5b"}, + {file = "wrapt-1.17.0-cp311-cp311-win32.whl", hash = "sha256:5d8fd17635b262448ab8f99230fe4dac991af1dabdbb92f7a70a6afac8a7e346"}, + {file = "wrapt-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:92a3d214d5e53cb1db8b015f30d544bc9d3f7179a05feb8f16df713cecc2620a"}, + {file = "wrapt-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:89fc28495896097622c3fc238915c79365dd0ede02f9a82ce436b13bd0ab7569"}, + {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875d240fdbdbe9e11f9831901fb8719da0bd4e6131f83aa9f69b96d18fae7504"}, + {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ed16d95fd142e9c72b6c10b06514ad30e846a0d0917ab406186541fe68b451"}, + {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18b956061b8db634120b58f668592a772e87e2e78bc1f6a906cfcaa0cc7991c1"}, + {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:daba396199399ccabafbfc509037ac635a6bc18510ad1add8fd16d4739cdd106"}, + {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4d63f4d446e10ad19ed01188d6c1e1bb134cde8c18b0aa2acfd973d41fcc5ada"}, + {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a5e7cc39a45fc430af1aefc4d77ee6bad72c5bcdb1322cfde852c15192b8bd4"}, + {file = "wrapt-1.17.0-cp312-cp312-win32.whl", hash = "sha256:0a0a1a1ec28b641f2a3a2c35cbe86c00051c04fffcfcc577ffcdd707df3f8635"}, + {file = "wrapt-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:3c34f6896a01b84bab196f7119770fd8466c8ae3dfa73c59c0bb281e7b588ce7"}, + {file = "wrapt-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:714c12485aa52efbc0fc0ade1e9ab3a70343db82627f90f2ecbc898fdf0bb181"}, + {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da427d311782324a376cacb47c1a4adc43f99fd9d996ffc1b3e8529c4074d393"}, + {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba1739fb38441a27a676f4de4123d3e858e494fac05868b7a281c0a383c098f4"}, + {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e711fc1acc7468463bc084d1b68561e40d1eaa135d8c509a65dd534403d83d7b"}, + {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:140ea00c87fafc42739bd74a94a5a9003f8e72c27c47cd4f61d8e05e6dec8721"}, + {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73a96fd11d2b2e77d623a7f26e004cc31f131a365add1ce1ce9a19e55a1eef90"}, + {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0b48554952f0f387984da81ccfa73b62e52817a4386d070c75e4db7d43a28c4a"}, + {file = "wrapt-1.17.0-cp313-cp313-win32.whl", hash = "sha256:498fec8da10e3e62edd1e7368f4b24aa362ac0ad931e678332d1b209aec93045"}, + {file = "wrapt-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd136bb85f4568fffca995bd3c8d52080b1e5b225dbf1c2b17b66b4c5fa02838"}, + {file = "wrapt-1.17.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17fcf043d0b4724858f25b8826c36e08f9fb2e475410bece0ec44a22d533da9b"}, + {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4a557d97f12813dc5e18dad9fa765ae44ddd56a672bb5de4825527c847d6379"}, + {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0229b247b0fc7dee0d36176cbb79dbaf2a9eb7ecc50ec3121f40ef443155fb1d"}, + {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8425cfce27b8b20c9b89d77fb50e368d8306a90bf2b6eef2cdf5cd5083adf83f"}, + {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c900108df470060174108012de06d45f514aa4ec21a191e7ab42988ff42a86c"}, + {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e547b447073fc0dbfcbff15154c1be8823d10dab4ad401bdb1575e3fdedff1b"}, + {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:914f66f3b6fc7b915d46c1cc424bc2441841083de01b90f9e81109c9759e43ab"}, + {file = "wrapt-1.17.0-cp313-cp313t-win32.whl", hash = "sha256:a4192b45dff127c7d69b3bdfb4d3e47b64179a0b9900b6351859f3001397dabf"}, + {file = "wrapt-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:4f643df3d4419ea3f856c5c3f40fec1d65ea2e89ec812c83f7767c8730f9827a"}, + {file = "wrapt-1.17.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:69c40d4655e078ede067a7095544bcec5a963566e17503e75a3a3e0fe2803b13"}, + {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f495b6754358979379f84534f8dd7a43ff8cff2558dcdea4a148a6e713a758f"}, + {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baa7ef4e0886a6f482e00d1d5bcd37c201b383f1d314643dfb0367169f94f04c"}, + {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fc931382e56627ec4acb01e09ce66e5c03c384ca52606111cee50d931a342d"}, + {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8f8909cdb9f1b237786c09a810e24ee5e15ef17019f7cecb207ce205b9b5fcce"}, + {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ad47b095f0bdc5585bced35bd088cbfe4177236c7df9984b3cc46b391cc60627"}, + {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:948a9bd0fb2c5120457b07e59c8d7210cbc8703243225dbd78f4dfc13c8d2d1f"}, + {file = "wrapt-1.17.0-cp38-cp38-win32.whl", hash = "sha256:5ae271862b2142f4bc687bdbfcc942e2473a89999a54231aa1c2c676e28f29ea"}, + {file = "wrapt-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:f335579a1b485c834849e9075191c9898e0731af45705c2ebf70e0cd5d58beed"}, + {file = "wrapt-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d751300b94e35b6016d4b1e7d0e7bbc3b5e1751e2405ef908316c2a9024008a1"}, + {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7264cbb4a18dc4acfd73b63e4bcfec9c9802614572025bdd44d0721983fc1d9c"}, + {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33539c6f5b96cf0b1105a0ff4cf5db9332e773bb521cc804a90e58dc49b10578"}, + {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c30970bdee1cad6a8da2044febd824ef6dc4cc0b19e39af3085c763fdec7de33"}, + {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bc7f729a72b16ee21795a943f85c6244971724819819a41ddbaeb691b2dd85ad"}, + {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:6ff02a91c4fc9b6a94e1c9c20f62ea06a7e375f42fe57587f004d1078ac86ca9"}, + {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2dfb7cff84e72e7bf975b06b4989477873dcf160b2fd89959c629535df53d4e0"}, + {file = "wrapt-1.17.0-cp39-cp39-win32.whl", hash = "sha256:2399408ac33ffd5b200480ee858baa58d77dd30e0dd0cab6a8a9547135f30a88"}, + {file = "wrapt-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:4f763a29ee6a20c529496a20a7bcb16a73de27f5da6a843249c7047daf135977"}, + {file = "wrapt-1.17.0-py3-none-any.whl", hash = "sha256:d2c63b93548eda58abf5188e505ffed0229bf675f7c3090f8e36ad55b8cbc371"}, + {file = "wrapt-1.17.0.tar.gz", hash = "sha256:16187aa2317c731170a88ef35e8937ae0f533c402872c1ee5e6d079fcf320801"}, +] + +[[package]] +name = "zipp" +version = "3.21.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +groups = ["main", "documentation"] +markers = "python_version < \"3.10\"" +files = [ + {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, + {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.9,<4.0" +content-hash = "b0f8544806163bc0dddc039eb313f9d82119b845b3a19dedc381e9c88e8f4466" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6475ec0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,277 @@ +[project] +name = "commitizen" +version = "4.6.0" +description = "Python commitizen client tool" +authors = [{ name = "Santiago Fraire", email = "santiwilly@gmail.com" }] +maintainers = [ + { name = "Wei Lee", email = "weilee.rx@gmail.com" }, + { name = "Axel H.", email = "noirbizarre@gmail.com" }, +] +license = { file = "LICENSE" } +readme = "docs/README.md" +requires-python = ">=3.9,<4.0" +dependencies = [ + "questionary (>=2.0,<3.0)", + "decli (>=0.6.0,<1.0)", + "colorama (>=0.4.1,<1.0)", + "termcolor (>=1.1,<3)", + "packaging>=19", + "tomlkit (>=0.5.3,<1.0.0)", + "jinja2>=2.10.3", + "pyyaml>=3.08", + "argcomplete >=1.12.1,<3.6", + "typing-extensions (>=4.0.1,<5.0.0) ; python_version < '3.11'", + "charset-normalizer (>=2.1.0,<4)", + # Use the Python 3.11 and 3.12 compatible API: https://github.com/python/importlib_metadata#compatibility + "importlib_metadata (>=8.0.0,<9) ; python_version < '3.10'", + +] +keywords = ["commitizen", "conventional", "commits", "git"] +# See also: https://pypi.org/classifiers/ +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "License :: OSI Approved :: MIT License", +] + +[project.urls] +Homepage = "https://github.com/commitizen-tools/commitizen" +Documentation = "https://commitizen-tools.github.io/commitizen/" +Repository = "https://github.com/commitizen-tools/commitizen" +Issues = "https://github.com/commitizen-tools/commitizen/issues" +Changelog = "https://github.com/commitizen-tools/commitizen/blob/master/CHANGELOG.md" + +[project.scripts] +cz = "commitizen.cli:main" +git-cz = "commitizen.cli:main" + +[project.entry-points."commitizen.plugin"] +cz_conventional_commits = "commitizen.cz.conventional_commits:ConventionalCommitsCz" +cz_jira = "commitizen.cz.jira:JiraSmartCz" +cz_customize = "commitizen.cz.customize:CustomizeCommitsCz" + +[project.entry-points."commitizen.changelog_format"] +markdown = "commitizen.changelog_formats.markdown:Markdown" +asciidoc = "commitizen.changelog_formats.asciidoc:AsciiDoc" +textile = "commitizen.changelog_formats.textile:Textile" +restructuredtext = "commitizen.changelog_formats.restructuredtext:RestructuredText" + +[project.entry-points."commitizen.provider"] +cargo = "commitizen.providers:CargoProvider" +commitizen = "commitizen.providers:CommitizenProvider" +composer = "commitizen.providers:ComposerProvider" +npm = "commitizen.providers:NpmProvider" +pep621 = "commitizen.providers:Pep621Provider" +poetry = "commitizen.providers:PoetryProvider" +scm = "commitizen.providers:ScmProvider" +uv = "commitizen.providers:UvProvider" + +[project.entry-points."commitizen.scheme"] +pep440 = "commitizen.version_schemes:Pep440" +semver = "commitizen.version_schemes:SemVer" +semver2 = "commitizen.version_schemes:SemVer2" + +[build-system] +requires = ["poetry-core>=2.0"] +build-backend = "poetry.core.masonry.api" + + +[tool.commitizen] +version = "4.6.0" +tag_format = "v$version" +version_files = [ + "pyproject.toml:version", + "commitizen/__version__.py", + ".pre-commit-config.yaml:rev:.+Commitizen", +] +version_scheme = "pep440" + + +[tool.poetry] +packages = [{ include = "commitizen" }, { include = "commitizen/py.typed" }] + +[tool.poetry.requires-plugins] +"poethepoet" = ">=0.32.2" + +[tool.poetry.group.dev.dependencies] +ipython = "^8.0" +tox = ">4" + +[tool.poetry.group.test.dependencies] +pytest = ">=7.2,<9.0" +pytest-cov = ">=4,<7" +pytest-mock = "^3.10" +pytest-regressions = "^2.4.0" +pytest-freezer = "^0.4.6" +pytest-xdist = "^3.1.0" +deprecated = "^1.2.13" + +[tool.poetry.group.linters.dependencies] +ruff = ">=0.5.0,<0.10.0" +pre-commit = ">=2.18,<5.0" +mypy = "^1.4" +types-deprecated = "^1.2.9.2" +types-python-dateutil = "^2.8.19.13" +types-PyYAML = ">=5.4.3,<7.0.0" +types-termcolor = "^0.1.1" + +[tool.poetry.group.documentation.dependencies] +mkdocs = "^1.4.2" +mkdocs-material = "^9.1.6" + +[tool.poetry.group.script.dependencies] +# for scripts/gen_cli_help_screenshots.py +rich = "^13.7.1" + + +[tool.coverage] +[tool.coverage.report] +show_missing = true +exclude_lines = [ + # Have to re-enable the standard pragma + 'pragma: no cover', + + # Don't complain about missing debug-only code: + 'def __repr__', + 'if self\.debug', + + # Don't complain if tests don't hit defensive assertion code: + 'raise AssertionError', + 'raise NotImplementedError', + + # Don't complain if non-runnable code isn't run: + 'if 0:', + 'if __name__ == .__main__.:', + 'if TYPE_CHECKING:', +] +omit = [ + 'env/*', + 'venv/*', + '.venv/*', + '*/virtualenv/*', + '*/virtualenvs/*', + '*/tests/*', +] + + +[tool.pytest.ini_options] +addopts = "--strict-markers" +testpaths = ["tests/"] + +[tool.tox] +requires = ["tox>=4.22"] +env_list = ["3.9", "3.10", "3.11", "3.12", "3.13"] + +[tool.tox.env_run_base] +description = "Run tests suite against Python {base_python}" +skip_install = true +deps = ["poetry>=2.0"] +commands_pre = [["poetry", "install", "--only", "main,test"]] +commands = [["pytest", { replace = "posargs", extend = true }]] + +[tool.ruff] +line-length = 88 + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # isort + "I", +] +ignore = ["E501", "D1", "D415"] + +[tool.ruff.lint.isort] +known-first-party = ["commitizen", "tests"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.mypy] +files = "commitizen" +disallow_untyped_decorators = true +disallow_subclassing_any = true +warn_return_any = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_unused_configs = true + +[[tool.mypy.overrides]] +module = "py.*" # Legacy pytest dependencies +ignore_missing_imports = true + +[tool.codespell] +# Ref: https://github.com/codespell-project/codespell#using-a-config-file +skip = '.git*,*.svg,*.lock' +check-hidden = true +ignore-words-list = 'asend' + +[tool.poe] +poetry_command = "" + +[tool.poe.tasks] +format.help = "Format the code" +format.sequence = [ + { cmd = "ruff check --fix commitizen tests" }, + { cmd = "ruff format commitizen tests" }, +] + +lint.help = "Lint the code" +lint.sequence = [ + { cmd = "ruff check commitizen/ tests/ --fix" }, + { cmd = "mypy commitizen/ tests/" }, +] + +check-commit.help = "Check the commit message" +check-commit.cmd = "cz -nr 3 check --rev-range origin/master.." + +test.help = "Run the test suite" +test.cmd = "pytest -n 3 --dist=loadfile" + +"test:all".help = "Run the test suite on all supported Python versions" +"test:all".cmd = "tox --parallel" + +cover.help = "Run the test suite with coverage" +cover.ref = "test --cov-report term-missing --cov-report=xml:coverage.xml --cov=commitizen" + +all.help = "Run all tasks" +all.sequence = [ + "format", + "lint", + "cover", + "check-commit", +] + +"doc:screenshots".help = "Render documentation screeenshots" +"doc:screenshots".script = "scripts.gen_cli_help_screenshots:gen_cli_help_screenshots" + +"doc:build".help = "Build the documentation" +"doc:build".cmd = "mkdocs build" + +doc.help = "Live documentation server" +doc.cmd = "mkdocs serve" + +ci.help = "Run all tasks in CI" +ci.sequence = [ + { cmd = "pre-commit run --all-files" }, + "cover", +] +ci.env = { SKIP = "no-commit-to-branch" } + +setup-pre-commit.help = "Install pre-commit hooks" +setup-pre-commit.cmd = "pre-commit install" diff --git a/scripts/gen_cli_help_screenshots.py b/scripts/gen_cli_help_screenshots.py new file mode 100644 index 0000000..0706612 --- /dev/null +++ b/scripts/gen_cli_help_screenshots.py @@ -0,0 +1,42 @@ +import os +import subprocess +from pathlib import Path + +from rich.console import Console + +from commitizen.cli import data + +project_root = Path(__file__).parent.parent.absolute() +images_root = project_root / Path("docs") / Path("images") / Path("cli_help") + + +def gen_cli_help_screenshots() -> None: + """Generate the screenshot for help message on each cli command and save them as svg files.""" + if not os.path.exists(images_root): + os.makedirs(images_root) + print(f"Created {images_root}") + + help_cmds = _list_help_cmds() + for cmd in help_cmds: + file_name = f"{cmd.replace(' ', '_').replace('-', '_')}.svg" + _export_cmd_as_svg(cmd, f"{images_root}/{file_name}") + + +def _list_help_cmds() -> list[str]: + cmds = [f"{data['prog']} --help"] + [ + f"{data['prog']} {sub_c['name'] if isinstance(sub_c['name'], str) else sub_c['name'][0]} --help" + for sub_c in data["subcommands"]["commands"] + ] + + return cmds + + +def _export_cmd_as_svg(cmd: str, file_name: str) -> None: + stdout = subprocess.run(cmd, shell=True, capture_output=True).stdout.decode("utf-8") + console = Console(record=True, width=80) + console.print(f"$ {cmd}\n{stdout}") + console.save_svg(file_name, title="") + + +if __name__ == "__main__": + gen_cli_help_screenshots() diff --git a/tests/CHANGELOG_FOR_TEST.md b/tests/CHANGELOG_FOR_TEST.md new file mode 100644 index 0000000..e92ca1c --- /dev/null +++ b/tests/CHANGELOG_FOR_TEST.md @@ -0,0 +1,129 @@ + +## v1.2.0 (2019-04-19) + +### feat + +- custom cz plugins now support bumping version + +## v1.1.1 (2019-04-18) + +### refactor + +- changed stdout statements +- **schema**: command logic removed from commitizen base +- **info**: command logic removed from commitizen base +- **example**: command logic removed from commitizen base +- **commit**: moved most of the commit logic to the commit command + +### fix + +- **bump**: commit message now fits better with semver +- conventional commit 'breaking change' in body instead of title + +## v1.1.0 (2019-04-14) + +### feat + +- new working bump command +- create version tag +- update given files with new version +- **config**: new set key, used to set version to cfg +- support for pyproject.toml +- first semantic version bump implementation + +### fix + +- removed all from commit +- fix config file not working + +### refactor + +- added commands folder, better integration with decli + +## v1.0.0 (2019-03-01) + +### refactor + +- removed delegator, added decli and many tests + +### BREAKING CHANGE + +- API is stable + +## 1.0.0b2 (2019-01-18) + +## v1.0.0b1 (2019-01-17) + +### feat + +- py3 only, tests and conventional commits 1.0 + +## v0.9.11 (2018-12-17) + +### fix + +- **config**: load config reads in order without failing if there is no commitizen section + +## v0.9.10 (2018-09-22) + +### fix + +- parse scope (this is my punishment for not having tests) + +## v0.9.9 (2018-09-22) + +### fix + +- parse scope empty + +## v0.9.8 (2018-09-22) + +### fix + +- **scope**: parse correctly again + +## v0.9.7 (2018-09-22) + +### fix + +- **scope**: parse correctly + +## v0.9.6 (2018-09-19) + +### refactor + +- **conventionalCommit**: moved filters to questions instead of message + +### fix + +- **manifest**: included missing files + +## v0.9.5 (2018-08-24) + +### fix + +- **config**: home path for python versions between 3.0 and 3.5 + +## v0.9.4 (2018-08-02) + +### feat + +- **cli**: added version + +## v0.9.3 (2018-07-28) + +### feat + +- **committer**: conventional commit is a bit more intelligent now + +## v0.9.2 (2017-11-11) + +### refactor + +- renamed conventional_changelog to conventional_commits, not backward compatible + +## v0.9.1 (2017-11-11) + +### fix + +- **setup.py**: future is now required for every python version diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/commands/conftest.py b/tests/commands/conftest.py new file mode 100644 index 0000000..9193184 --- /dev/null +++ b/tests/commands/conftest.py @@ -0,0 +1,53 @@ +import os + +import pytest + +from commitizen import defaults +from commitizen.config import BaseConfig, JsonConfig + + +@pytest.fixture() +def config(): + _config = BaseConfig() + _config.settings.update({"name": defaults.DEFAULT_SETTINGS["name"]}) + return _config + + +@pytest.fixture() +def config_customize(): + json_string = r"""{ + "commitizen": { + "name": "cz_customize", + "version": "3.0.0", + "changelog_incremental": "true", + "customize": { + "message_template": "{{prefix}}({{scope}}): {{subject}}\n\n{{body}}{% if is_breaking_change %}\nBREAKING CHANGE: {{footer}}{% endif %}", + "schema": "<type>(<scope>): <subject>\n<BLANK LINE>\n<body>\n<BLANK LINE>\n(BREAKING CHANGE: <footer>)", + "schema_pattern": "(build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert|bump)(\\(\\S+\\))?!?:(\\s.*)", + "change_type_map": { + "feat": "Feat", + "fix": "Fix", + "refactor": "Refactor", + "perf": "Perf" + }, + "change_type_order": ["Refactor", "Feat"], + "commit_parser": "^(?P<change_type>feat|fix|refactor|perf|BREAKING CHANGE)(?:\\((?P<scope>[^()\\r\\n]*)\\)|\\()?(?P<breaking>!)?:\\s(?P<message>.*)?", + "changelog_pattern": "^(BREAKING[\\-\\ ]CHANGE|feat|fix|refactor|perf)(\\(.+\\))?(!)?", + "questions": [ + + ] + } + } + }""" + _config = JsonConfig(data=json_string, path="not_exist.json") + return _config + + +@pytest.fixture() +def changelog_path() -> str: + return os.path.join(os.getcwd(), "CHANGELOG.md") + + +@pytest.fixture() +def config_path() -> str: + return os.path.join(os.getcwd(), "pyproject.toml") diff --git a/tests/commands/test_bump_command.py b/tests/commands/test_bump_command.py new file mode 100644 index 0000000..b5ff7e6 --- /dev/null +++ b/tests/commands/test_bump_command.py @@ -0,0 +1,1658 @@ +from __future__ import annotations + +import inspect +import re +import sys +from pathlib import Path +from textwrap import dedent +from unittest.mock import MagicMock, call + +import py +import pytest +from pytest_mock import MockFixture + +import commitizen.commands.bump as bump +from commitizen import cli, cmd, git, hooks +from commitizen.changelog_formats import ChangelogFormat +from commitizen.cz.base import BaseCommitizen +from commitizen.exceptions import ( + BumpTagFailedError, + CommitizenException, + CurrentVersionNotFoundError, + DryRunExit, + ExitCode, + ExpectedExit, + GetNextExit, + InvalidManualVersion, + NoCommitsFoundError, + NoneIncrementExit, + NoPatternMapError, + NotAGitProjectError, + NotAllowed, + NoVersionSpecifiedError, +) +from tests.utils import create_file_and_commit, create_tag, skip_below_py_3_13 + + +@pytest.mark.parametrize( + "commit_msg", + ( + "fix: username exception", + "fix(user): username exception", + "refactor: remove ini configuration support", + "refactor(config): remove ini configuration support", + "perf: update to use multiproess", + "perf(worker): update to use multiproess", + ), +) +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_patch_increment(commit_msg, mocker: MockFixture): + create_file_and_commit(commit_msg) + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.1.1") + assert tag_exists is True + + +@pytest.mark.parametrize("commit_msg", ("feat: new file", "feat(user): new file")) +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_minor_increment(commit_msg, mocker: MockFixture): + create_file_and_commit(commit_msg) + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.2.0") + cmd_res = cmd.run('git for-each-ref refs/tags --format "%(objecttype):%(refname)"') + assert tag_exists is True and "commit:refs/tags/0.2.0\n" in cmd_res.out + + +@pytest.mark.parametrize("commit_msg", ("feat: new file", "feat(user): new file")) +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_minor_increment_annotated(commit_msg, mocker: MockFixture): + create_file_and_commit(commit_msg) + testargs = ["cz", "bump", "--yes", "--annotated-tag"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.2.0") + cmd_res = cmd.run('git for-each-ref refs/tags --format "%(objecttype):%(refname)"') + assert tag_exists is True and "tag:refs/tags/0.2.0\n" in cmd_res.out + + _is_signed = git.is_signed_tag("0.2.0") + assert _is_signed is False + + +@pytest.mark.parametrize("commit_msg", ("feat: new file", "feat(user): new file")) +@pytest.mark.usefixtures("tmp_commitizen_project_with_gpg") +def test_bump_minor_increment_signed(commit_msg, mocker: MockFixture): + create_file_and_commit(commit_msg) + testargs = ["cz", "bump", "--yes", "--gpg-sign"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.2.0") + cmd_res = cmd.run('git for-each-ref refs/tags --format "%(objecttype):%(refname)"') + assert tag_exists is True and "tag:refs/tags/0.2.0\n" in cmd_res.out + + _is_signed = git.is_signed_tag("0.2.0") + assert _is_signed is True + + +@pytest.mark.parametrize("commit_msg", ("feat: new file", "feat(user): new file")) +def test_bump_minor_increment_annotated_config_file( + commit_msg, mocker: MockFixture, tmp_commitizen_project +): + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + tmp_commitizen_cfg_file.write( + f"{tmp_commitizen_cfg_file.read()}\nannotated_tag = 1" + ) + create_file_and_commit(commit_msg) + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.2.0") + cmd_res = cmd.run('git for-each-ref refs/tags --format "%(objecttype):%(refname)"') + assert tag_exists is True and "tag:refs/tags/0.2.0\n" in cmd_res.out + + _is_signed = git.is_signed_tag("0.2.0") + assert _is_signed is False + + +@pytest.mark.parametrize("commit_msg", ("feat: new file", "feat(user): new file")) +def test_bump_minor_increment_signed_config_file( + commit_msg, mocker: MockFixture, tmp_commitizen_project_with_gpg +): + tmp_commitizen_cfg_file = tmp_commitizen_project_with_gpg.join("pyproject.toml") + tmp_commitizen_cfg_file.write(f"{tmp_commitizen_cfg_file.read()}\ngpg_sign = 1") + create_file_and_commit(commit_msg) + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.2.0") + cmd_res = cmd.run('git for-each-ref refs/tags --format "%(objecttype):%(refname)"') + assert tag_exists is True and "tag:refs/tags/0.2.0\n" in cmd_res.out + + _is_signed = git.is_signed_tag("0.2.0") + assert _is_signed is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.parametrize( + "commit_msg", + ( + "feat: new user interface\n\nBREAKING CHANGE: age is no longer supported", + "feat!: new user interface\n\nBREAKING CHANGE: age is no longer supported", + "feat!: new user interface", + "feat(user): new user interface\n\nBREAKING CHANGE: age is no longer supported", + "feat(user)!: new user interface\n\nBREAKING CHANGE: age is no longer supported", + "feat(user)!: new user interface", + "BREAKING CHANGE: age is no longer supported", + "BREAKING-CHANGE: age is no longer supported", + ), +) +def test_bump_major_increment(commit_msg, mocker: MockFixture): + create_file_and_commit(commit_msg) + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("1.0.0") + assert tag_exists is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.parametrize( + "commit_msg", + ( + "feat: new user interface\n\nBREAKING CHANGE: age is no longer supported", + "feat!: new user interface\n\nBREAKING CHANGE: age is no longer supported", + "feat!: new user interface", + "feat(user): new user interface\n\nBREAKING CHANGE: age is no longer supported", + "feat(user)!: new user interface\n\nBREAKING CHANGE: age is no longer supported", + "feat(user)!: new user interface", + "BREAKING CHANGE: age is no longer supported", + "BREAKING-CHANGE: age is no longer supported", + ), +) +def test_bump_major_increment_major_version_zero(commit_msg, mocker): + create_file_and_commit(commit_msg) + + testargs = ["cz", "bump", "--yes", "--major-version-zero"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.parametrize( + "commit_msg,increment,expected_tag", + [ + ("feat: new file", "PATCH", "0.1.1"), + ("fix: username exception", "major", "1.0.0"), + ("refactor: remove ini configuration support", "patch", "0.1.1"), + ("BREAKING CHANGE: age is no longer supported", "minor", "0.2.0"), + ], +) +def test_bump_command_increment_option( + commit_msg, increment, expected_tag, mocker: MockFixture +): + create_file_and_commit(commit_msg) + + testargs = ["cz", "bump", "--increment", increment, "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist(expected_tag) + assert tag_exists is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_command_prelease(mocker: MockFixture): + create_file_and_commit("feat: location") + + # Create an alpha pre-release. + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0a0") + assert tag_exists is True + + # Create a beta pre-release. + testargs = ["cz", "bump", "--prerelease", "beta", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0b0") + assert tag_exists is True + + # With a current beta pre-release, bumping alpha must bump beta + # because we can't bump "backwards". + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0a1") + assert tag_exists is False + tag_exists = git.tag_exist("0.2.0b1") + assert tag_exists is True + + # Create a rc pre-release. + testargs = ["cz", "bump", "--prerelease", "rc", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0rc0") + assert tag_exists is True + + # With a current rc pre-release, bumping alpha must bump rc. + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0a1") + assert tag_exists is False + tag_exists = git.tag_exist("0.2.0b2") + assert tag_exists is False + tag_exists = git.tag_exist("0.2.0rc1") + assert tag_exists is True + + # With a current rc pre-release, bumping beta must bump rc. + testargs = ["cz", "bump", "--prerelease", "beta", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0a2") + assert tag_exists is False + tag_exists = git.tag_exist("0.2.0b2") + assert tag_exists is False + tag_exists = git.tag_exist("0.2.0rc2") + assert tag_exists is True + + # Create a final release from the current pre-release. + testargs = ["cz", "bump"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_command_prelease_increment(mocker: MockFixture): + # FINAL RELEASE + create_file_and_commit("fix: location") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + assert git.tag_exist("0.1.1") + + # PRERELEASE + create_file_and_commit("fix: location") + + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + assert git.tag_exist("0.1.2a0") + + create_file_and_commit("feat: location") + + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + assert git.tag_exist("0.2.0a0") + + create_file_and_commit("feat!: breaking") + + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + assert git.tag_exist("1.0.0a0") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_command_prelease_exact_mode(mocker: MockFixture): + # PRERELEASE + create_file_and_commit("feat: location") + + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0a0") + assert tag_exists is True + + # PRERELEASE + PATCH BUMP + testargs = [ + "cz", + "bump", + "--prerelease", + "alpha", + "--yes", + "--increment-mode=exact", + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0a1") + assert tag_exists is True + + # PRERELEASE + MINOR BUMP + # --increment-mode allows the minor version to bump, and restart the prerelease + create_file_and_commit("feat: location") + + testargs = [ + "cz", + "bump", + "--prerelease", + "alpha", + "--yes", + "--increment-mode=exact", + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.3.0a0") + assert tag_exists is True + + # PRERELEASE + MAJOR BUMP + # --increment-mode=exact allows the major version to bump, and restart the prerelease + testargs = [ + "cz", + "bump", + "--prerelease", + "alpha", + "--yes", + "--increment=MAJOR", + "--increment-mode=exact", + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("1.0.0a0") + assert tag_exists is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_on_git_with_hooks_no_verify_disabled(mocker: MockFixture): + """Bump commit without --no-verify""" + cmd.run("mkdir .git/hooks") + with open(".git/hooks/pre-commit", "w", encoding="utf-8") as f: + f.write('#!/usr/bin/env bash\necho "0.1.0"') + cmd.run("chmod +x .git/hooks/pre-commit") + + # MINOR + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + + cli.main() + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_tag_exists_raises_exception(mocker: MockFixture): + cmd.run("mkdir .git/hooks") + with open(".git/hooks/post-commit", "w", encoding="utf-8") as f: + f.write("#!/usr/bin/env bash\nexit 9") + cmd.run("chmod +x .git/hooks/post-commit") + + # MINOR + create_file_and_commit("feat: new file") + git.tag("0.2.0") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(BumpTagFailedError) as excinfo: + cli.main() + assert "0.2.0" in str(excinfo.value) # This should be a fatal error + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_on_git_with_hooks_no_verify_enabled(mocker: MockFixture): + cmd.run("mkdir .git/hooks") + with open(".git/hooks/pre-commit", "w", encoding="utf-8") as f: + f.write('#!/usr/bin/env bash\necho "0.1.0"') + cmd.run("chmod +x .git/hooks/pre-commit") + + # MINOR + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes", "--no-verify"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_when_bumpping_is_not_support(mocker: MockFixture): + create_file_and_commit( + "feat: new user interface\n\nBREAKING CHANGE: age is no longer supported" + ) + + testargs = ["cz", "-n", "cz_jira", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NoPatternMapError) as excinfo: + cli.main() + + assert "'cz_jira' rule does not support bump" in str(excinfo.value) + + +@pytest.mark.usefixtures("tmp_git_project") +def test_bump_when_version_is_not_specify(mocker: MockFixture): + mocker.patch.object(sys, "argv", ["cz", "bump"]) + + with pytest.raises(NoVersionSpecifiedError) as excinfo: + cli.main() + + assert NoVersionSpecifiedError.message in str(excinfo.value) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_when_no_new_commit(mocker: MockFixture): + """bump without any commits since the last bump.""" + # We need this first commit otherwise the revision is invalid. + create_file_and_commit("feat: initial") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + + # First bump. + # The next bump should fail since + # there is not a commit between the two bumps. + cli.main() + + # bump without a new commit. + with pytest.raises(NoCommitsFoundError) as excinfo: + cli.main() + + expected_error_message = "[NO_COMMITS_FOUND]\nNo new commits found." + assert expected_error_message in str(excinfo.value) + + +def test_bump_when_version_inconsistent_in_version_files( + tmp_commitizen_project, mocker: MockFixture +): + tmp_version_file = tmp_commitizen_project.join("__version__.py") + tmp_version_file.write("100.999.10000") + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + tmp_version_file_string = str(tmp_version_file).replace("\\", "/") + tmp_commitizen_cfg_file.write( + f"{tmp_commitizen_cfg_file.read()}\n" + f'version_files = ["{tmp_version_file_string}"]' + ) + + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes", "--check-consistency"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(CurrentVersionNotFoundError) as excinfo: + cli.main() + + partial_expected_error_message = "Current version 0.1.0 is not found in" + assert partial_expected_error_message in str(excinfo.value) + + +def test_bump_major_version_zero_when_major_is_not_zero(mocker, tmp_commitizen_project): + tmp_version_file = tmp_commitizen_project.join("__version__.py") + tmp_version_file.write("1.0.0") + tmp_version_file_string = str(tmp_version_file).replace("\\", "/") + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + tmp_commitizen_cfg_file.write( + f"[tool.commitizen]\n" + 'version="1.0.0"\n' + f'version_files = ["{str(tmp_version_file_string)}"]' + ) + tmp_changelog_file = tmp_commitizen_project.join("CHANGELOG.md") + tmp_changelog_file.write("## v1.0.0") + + create_file_and_commit("feat(user): new file") + create_tag("v1.0.0") + create_file_and_commit("feat(user)!: new file") + + testargs = ["cz", "bump", "--yes", "--major-version-zero"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NotAllowed) as excinfo: + cli.main() + + expected_error_message = ( + "--major-version-zero is meaningless for current version 1.0.0" + ) + assert expected_error_message in str(excinfo.value) + + +def test_bump_files_only(mocker: MockFixture, tmp_commitizen_project): + tmp_version_file = tmp_commitizen_project.join("__version__.py") + tmp_version_file.write("0.1.0") + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + tmp_version_file_string = str(tmp_version_file).replace("\\", "/") + tmp_commitizen_cfg_file.write( + f"{tmp_commitizen_cfg_file.read()}\n" + f'version_files = ["{tmp_version_file_string}"]' + ) + + create_file_and_commit("feat: new user interface") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + create_file_and_commit("feat: another new feature") + testargs = ["cz", "bump", "--yes", "--files-only"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(ExpectedExit): + cli.main() + + tag_exists = git.tag_exist("0.3.0") + assert tag_exists is False + + with open(tmp_version_file, encoding="utf-8") as f: + assert "0.3.0" in f.read() + + with open(tmp_commitizen_cfg_file, encoding="utf-8") as f: + assert "0.3.0" in f.read() + + +def test_bump_local_version(mocker: MockFixture, tmp_commitizen_project): + tmp_version_file = tmp_commitizen_project.join("__version__.py") + tmp_version_file.write("4.5.1+0.1.0") + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + tmp_version_file_string = str(tmp_version_file).replace("\\", "/") + tmp_commitizen_cfg_file.write( + f"[tool.commitizen]\n" + 'version="4.5.1+0.1.0"\n' + f'version_files = ["{tmp_version_file_string}"]' + ) + + create_file_and_commit("feat: new user interface") + testargs = ["cz", "bump", "--yes", "--local-version"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("4.5.1+0.2.0") + assert tag_exists is True + + with open(tmp_version_file, encoding="utf-8") as f: + assert "4.5.1+0.2.0" in f.read() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_dry_run(mocker: MockFixture, capsys): + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + + out, _ = capsys.readouterr() + assert "0.2.0" in out + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is False + + +def test_bump_in_non_git_project(tmpdir, config, mocker: MockFixture): + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + + with tmpdir.as_cwd(): + with pytest.raises(NotAGitProjectError): + with pytest.raises(ExpectedExit): + cli.main() + + +def test_none_increment_exit_should_be_a_class(): + assert inspect.isclass(NoneIncrementExit) + + +def test_none_increment_exit_should_be_expected_exit_subclass(): + assert issubclass(NoneIncrementExit, CommitizenException) + + +def test_none_increment_exit_should_exist_in_bump(): + assert hasattr(bump, "NoneIncrementExit") + + +def test_none_increment_exit_is_exception(): + assert bump.NoneIncrementExit == NoneIncrementExit + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_none_increment_should_not_call_git_tag_and_error_code_is_not_zero( + mocker: MockFixture, +): + create_file_and_commit("test(test_get_all_droplets): fix bad comparison test") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + + # stash git.tag for later restore + stashed_git_tag = git.tag + dummy_value = git.tag("0.0.2") + git.tag = MagicMock(return_value=dummy_value) + + with pytest.raises(NoneIncrementExit): + try: + cli.main() + except NoneIncrementExit as e: + git.tag.assert_not_called() + assert e.exit_code == ExitCode.NO_INCREMENT + raise e + + # restore pop stashed + git.tag = stashed_git_tag + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_with_changelog_arg(mocker: MockFixture, changelog_path): + create_file_and_commit("feat(user): new file") + testargs = ["cz", "bump", "--yes", "--changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + with open(changelog_path, encoding="utf-8") as f: + out = f.read() + assert out.startswith("#") + assert "0.2.0" in out + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_with_changelog_config(mocker: MockFixture, changelog_path, config_path): + create_file_and_commit("feat(user): new file") + with open(config_path, "a", encoding="utf-8") as fp: + fp.write("update_changelog_on_bump = true\n") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + with open(changelog_path, encoding="utf-8") as f: + out = f.read() + assert out.startswith("#") + assert "0.2.0" in out + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_prevent_prerelease_when_no_increment_detected(mocker: MockFixture, capsys): + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + + cli.main() + out, _ = capsys.readouterr() + + assert "0.2.0" in out + + create_file_and_commit("test: new file") + testargs = ["cz", "bump", "-pr", "beta"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NoCommitsFoundError) as excinfo: + cli.main() + + expected_error_message = ( + "[NO_COMMITS_FOUND]\nNo commits found to generate a pre-release." + ) + assert expected_error_message in str(excinfo.value) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_with_changelog_to_stdout_arg(mocker: MockFixture, capsys, changelog_path): + create_file_and_commit("feat(user): this should appear in stdout") + testargs = ["cz", "bump", "--yes", "--changelog-to-stdout"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + out, _ = capsys.readouterr() + + assert "this should appear in stdout" in out + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + with open(changelog_path, encoding="utf-8") as f: + out = f.read() + assert out.startswith("#") + assert "0.2.0" in out + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_with_changelog_to_stdout_dry_run_arg( + mocker: MockFixture, capsys, changelog_path +): + create_file_and_commit( + "feat(user): this should appear in stdout with dry-run enabled" + ) + testargs = ["cz", "bump", "--yes", "--changelog-to-stdout", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + out, _ = capsys.readouterr() + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is False + assert out.startswith("#") + assert "this should appear in stdout with dry-run enabled" in out + assert "0.2.0" in out + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_without_git_to_stdout_arg(mocker: MockFixture, capsys, changelog_path): + create_file_and_commit("feat(user): this should appear in stdout") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + out, _ = capsys.readouterr() + + assert ( + re.search(r"^\[master \w+] bump: version 0.1.0 โ†’ 0.2.0", out, re.MULTILINE) + is not None + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_with_git_to_stdout_arg(mocker: MockFixture, capsys, changelog_path): + create_file_and_commit("feat(user): this should appear in stdout") + testargs = ["cz", "bump", "--yes", "--git-output-to-stderr"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + out, _ = capsys.readouterr() + + assert ( + re.search(r"^\[master \w+] bump: version 0.1.0 โ†’ 0.2.0", out, re.MULTILINE) + is None + ) + + +@pytest.mark.parametrize( + "version_filepath, version_regex, version_file_content", + [ + pytest.param( + "pyproject.toml", + "pyproject.toml:^version", + """ +[tool.poetry] +name = "my_package" +version = "0.1.0" +""", + id="version in pyproject.toml with regex", + ), + pytest.param( + "pyproject.toml", + "pyproject.toml", + """ +[tool.poetry] +name = "my_package" +version = "0.1.0" +""", + id="version in pyproject.toml without regex", + ), + pytest.param( + "__init__.py", + "__init__.py:^__version__", + """ +'''This is a test file.''' +__version__ = "0.1.0" +""", + id="version in __init__.py with regex", + ), + pytest.param( + "pyproject.toml", + "*.toml:^version", + """ +[tool.poetry] +name = "my_package" +version = "0.1.0" +""", + id="version in pyproject.toml with glob and regex", + ), + ], +) +@pytest.mark.parametrize( + "cli_bump_changelog_args", + [ + ("cz", "bump", "--changelog", "--yes"), + ( + "cz", + "bump", + "--changelog", + "--changelog-to-stdout", + "--annotated-tag", + "--check-consistency", + "--yes", + ), + ], + ids=lambda cmd_tuple: " ".join(cmd_tuple), +) +def test_bump_changelog_command_commits_untracked_changelog_and_version_files( + tmp_commitizen_project, + mocker, + cli_bump_changelog_args: tuple[str, ...], + version_filepath: str, + version_regex: str, + version_file_content: str, +): + """Ensure that changelog always gets committed, no matter what version file or cli options get passed. + + Steps: + - Append the version file's name and regex commitizen configuration lines to `pyproject.toml`. + - Append to or create the version file. + - Add a commit of type fix to be eligible for a version bump. + - Call commitizen main cli and assert that the `CHANGELOG.md` and the version file were committed. + """ + + with tmp_commitizen_project.join("pyproject.toml").open( + mode="a", + encoding="utf-8", + ) as commitizen_config: + commitizen_config.write(f"version_files = [\n'{version_regex}'\n]") + + with tmp_commitizen_project.join(version_filepath).open( + mode="a+", encoding="utf-8" + ) as version_file: + version_file.write(version_file_content) + + create_file_and_commit("fix: some test commit") + + mocker.patch.object(sys, "argv", cli_bump_changelog_args) + cli.main() + + commit_file_names = git.get_filenames_in_commit() + assert "CHANGELOG.md" in commit_file_names + assert version_filepath in commit_file_names + + +@pytest.mark.parametrize( + "testargs", + [ + ["cz", "bump", "--local-version", "1.2.3"], + ["cz", "bump", "--prerelease", "rc", "1.2.3"], + ["cz", "bump", "--devrelease", "0", "1.2.3"], + ["cz", "bump", "--devrelease", "1", "1.2.3"], + ["cz", "bump", "--increment", "PATCH", "1.2.3"], + ["cz", "bump", "--build-metadata=a.b.c", "1.2.3"], + ["cz", "bump", "--local-version", "--build-metadata=a.b.c"], + ], +) +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_invalid_manual_args_raises_exception(mocker, testargs): + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NotAllowed): + cli.main() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.parametrize( + "manual_version", + [ + "noversion", + "1.2..3", + ], +) +def test_bump_invalid_manual_version_raises_exception(mocker, manual_version): + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes", manual_version] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(InvalidManualVersion) as excinfo: + cli.main() + + expected_error_message = ( + f"[INVALID_MANUAL_VERSION]\nInvalid manual version: '{manual_version}'" + ) + assert expected_error_message in str(excinfo.value) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.parametrize( + "manual_version", + [ + "0.0.1", + "0.1.0rc2", + "0.1.0.dev2", + "0.1.0+1.0.0", + "0.1.0rc2.dev2+1.0.0", + "0.1.1", + "0.2.0", + "1.0.0", + ], +) +def test_bump_manual_version(mocker, manual_version): + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes", manual_version] + mocker.patch.object(sys, "argv", testargs) + cli.main() + tag_exists = git.tag_exist(manual_version) + assert tag_exists is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_manual_version_disallows_major_version_zero(mocker): + create_file_and_commit("feat: new file") + + manual_version = "0.2.0" + testargs = ["cz", "bump", "--yes", "--major-version-zero", manual_version] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NotAllowed) as excinfo: + cli.main() + + expected_error_message = ( + "--major-version-zero cannot be combined with MANUAL_VERSION" + ) + assert expected_error_message in str(excinfo.value) + + +@pytest.mark.parametrize("commit_msg", ("feat: new file", "feat(user): new file")) +def test_bump_with_pre_bump_hooks( + commit_msg, mocker: MockFixture, tmp_commitizen_project +): + pre_bump_hook = "scripts/pre_bump_hook.sh" + post_bump_hook = "scripts/post_bump_hook.sh" + + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + tmp_commitizen_cfg_file.write( + f"{tmp_commitizen_cfg_file.read()}\n" + f'pre_bump_hooks = ["{pre_bump_hook}"]\n' + f'post_bump_hooks = ["{post_bump_hook}"]\n' + ) + + run_mock = mocker.Mock() + mocker.patch.object(hooks, "run", run_mock) + + create_file_and_commit(commit_msg) + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + run_mock.assert_has_calls( + [ + call( + [pre_bump_hook], + _env_prefix="CZ_PRE_", + is_initial=True, + current_version="0.1.0", + current_tag_version="0.1.0", + new_version="0.2.0", + new_tag_version="0.2.0", + message="bump: version 0.1.0 โ†’ 0.2.0", + increment="MINOR", + changelog_file_name=None, + ), + call( + [post_bump_hook], + _env_prefix="CZ_POST_", + was_initial=True, + previous_version="0.1.0", + previous_tag_version="0.1.0", + current_version="0.2.0", + current_tag_version="0.2.0", + message="bump: version 0.1.0 โ†’ 0.2.0", + increment="MINOR", + changelog_file_name=None, + ), + ] + ) + + +def test_bump_with_hooks_and_increment(mocker: MockFixture, tmp_commitizen_project): + pre_bump_hook = "scripts/pre_bump_hook.sh" + post_bump_hook = "scripts/post_bump_hook.sh" + + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + tmp_commitizen_cfg_file.write( + f"{tmp_commitizen_cfg_file.read()}\n" + f'pre_bump_hooks = ["{pre_bump_hook}"]\n' + f'post_bump_hooks = ["{post_bump_hook}"]\n' + ) + + run_mock = mocker.Mock() + mocker.patch.object(hooks, "run", run_mock) + + create_file_and_commit("test: some test") + testargs = ["cz", "bump", "--yes", "--increment", "MINOR"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + +@pytest.mark.usefixtures("tmp_git_project") +def test_bump_use_version_provider(mocker: MockFixture): + mock = mocker.MagicMock(name="provider") + mock.get_version.return_value = "0.0.0" + get_provider = mocker.patch( + "commitizen.commands.bump.get_provider", return_value=mock + ) + + create_file_and_commit("fix: fake commit") + testargs = ["cz", "bump", "--yes", "--changelog"] + mocker.patch.object(sys, "argv", testargs) + + cli.main() + + assert git.tag_exist("0.0.1") + get_provider.assert_called_once() + mock.get_version.assert_called_once() + mock.set_version.assert_called_once_with("0.0.1") + + +def test_bump_command_prelease_scheme_via_cli( + tmp_commitizen_project_initial, mocker: MockFixture +): + tmp_commitizen_project = tmp_commitizen_project_initial() + tmp_version_file = tmp_commitizen_project.join("__version__.py") + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + + testargs = [ + "cz", + "bump", + "--prerelease", + "alpha", + "--yes", + "--version-scheme", + "semver", + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0-a0") + assert tag_exists is True + + for version_file in [tmp_version_file, tmp_commitizen_cfg_file]: + with open(version_file) as f: + assert "0.2.0-a0" in f.read() + + # PRERELEASE BUMP CREATES VERSION WITHOUT PRERELEASE + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + for version_file in [tmp_version_file, tmp_commitizen_cfg_file]: + with open(version_file) as f: + assert "0.2.0" in f.read() + + +def test_bump_command_prelease_scheme_via_config( + tmp_commitizen_project_initial, mocker: MockFixture +): + tmp_commitizen_project = tmp_commitizen_project_initial( + config_extra='version_scheme = "semver"\n', + ) + tmp_version_file = tmp_commitizen_project.join("__version__.py") + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0-a0") + assert tag_exists is True + + for version_file in [tmp_version_file, tmp_commitizen_cfg_file]: + with open(version_file) as f: + assert "0.2.0-a0" in f.read() + + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0-a1") + assert tag_exists is True + + for version_file in [tmp_version_file, tmp_commitizen_cfg_file]: + with open(version_file) as f: + assert "0.2.0-a1" in f.read() + + # PRERELEASE BUMP CREATES VERSION WITHOUT PRERELEASE + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is True + + for version_file in [tmp_version_file, tmp_commitizen_cfg_file]: + with open(version_file) as f: + assert "0.2.0" in f.read() + + +def test_bump_command_prelease_scheme_check_old_tags( + tmp_commitizen_project_initial, mocker: MockFixture +): + tmp_commitizen_project = tmp_commitizen_project_initial( + config_extra=('tag_format = "v$version"\nversion_scheme = "semver"\n'), + ) + tmp_version_file = tmp_commitizen_project.join("__version__.py") + tmp_commitizen_cfg_file = tmp_commitizen_project.join("pyproject.toml") + + testargs = ["cz", "bump", "--prerelease", "alpha", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("v0.2.0-a0") + assert tag_exists is True + + for version_file in [tmp_version_file, tmp_commitizen_cfg_file]: + with open(version_file) as f: + assert "0.2.0-a0" in f.read() + + testargs = ["cz", "bump", "--prerelease", "alpha"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("v0.2.0-a1") + assert tag_exists is True + + for version_file in [tmp_version_file, tmp_commitizen_cfg_file]: + with open(version_file) as f: + assert "0.2.0-a1" in f.read() + + # PRERELEASE BUMP CREATES VERSION WITHOUT PRERELEASE + testargs = ["cz", "bump"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist("v0.2.0") + assert tag_exists is True + + for version_file in [tmp_version_file, tmp_commitizen_cfg_file]: + with open(version_file) as f: + assert "0.2.0" in f.read() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.usefixtures("use_cz_semver") +@pytest.mark.parametrize( + "message, expected_tag", + [ + ("minor: add users", "0.2.0"), + ("patch: bug affecting users", "0.1.1"), + ("major: bug affecting users", "1.0.0"), + ], +) +def test_bump_with_plugin(mocker: MockFixture, message: str, expected_tag: str): + create_file_and_commit(message) + + testargs = ["cz", "--name", "cz_semver", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist(expected_tag) + assert tag_exists is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.usefixtures("use_cz_semver") +@pytest.mark.parametrize( + "message, expected_tag", + [ + ("minor: add users", "0.2.0"), + ("patch: bug affecting users", "0.1.1"), + ("major: bug affecting users", "0.2.0"), + ], +) +def test_bump_with_major_version_zero_with_plugin( + mocker: MockFixture, message: str, expected_tag: str +): + create_file_and_commit(message) + + testargs = ["cz", "--name", "cz_semver", "bump", "--yes", "--major-version-zero"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + tag_exists = git.tag_exist(expected_tag) + assert tag_exists is True + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_command_version_type_deprecation(mocker: MockFixture): + create_file_and_commit("feat: check deprecation on --version-type") + + testargs = [ + "cz", + "bump", + "--prerelease", + "alpha", + "--yes", + "--version-type", + "semver", + ] + mocker.patch.object(sys, "argv", testargs) + with pytest.warns(DeprecationWarning): + cli.main() + + assert git.tag_exist("0.2.0-a0") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_command_version_scheme_priority_over_version_type(mocker: MockFixture): + create_file_and_commit("feat: check deprecation on --version-type") + + testargs = [ + "cz", + "bump", + "--prerelease", + "alpha", + "--yes", + "--version-type", + "semver", + "--version-scheme", + "pep440", + ] + mocker.patch.object(sys, "argv", testargs) + with pytest.warns(DeprecationWarning): + cli.main() + + assert git.tag_exist("0.2.0a0") + + +@pytest.mark.parametrize( + "arg, cfg, expected", + ( + pytest.param("", "", "default", id="default"), + pytest.param("", "changelog.cfg", "from config", id="from-config"), + pytest.param( + "--template=changelog.cmd", "changelog.cfg", "from cmd", id="from-command" + ), + ), +) +def test_bump_template_option_precedance( + mocker: MockFixture, + tmp_commitizen_project: Path, + any_changelog_format: ChangelogFormat, + arg: str, + cfg: str, + expected: str, +): + project_root = Path(tmp_commitizen_project) + cfg_template = project_root / "changelog.cfg" + cmd_template = project_root / "changelog.cmd" + default_template = project_root / any_changelog_format.template + changelog = project_root / any_changelog_format.default_changelog_file + + cfg_template.write_text("from config") + cmd_template.write_text("from cmd") + default_template.write_text("default") + + create_file_and_commit("feat: new file") + + if cfg: + pyproject = project_root / "pyproject.toml" + pyproject.write_text( + dedent( + f"""\ + [tool.commitizen] + version = "0.1.0" + template = "{cfg}" + """ + ) + ) + + testargs = ["cz", "bump", "--yes", "--changelog"] + if arg: + testargs.append(arg) + mocker.patch.object(sys, "argv", testargs + ["0.1.1"]) + cli.main() + + out = changelog.read_text() + assert out == expected + + +def test_bump_template_extras_precedance( + mocker: MockFixture, + tmp_commitizen_project: Path, + any_changelog_format: ChangelogFormat, + mock_plugin: BaseCommitizen, +): + project_root = Path(tmp_commitizen_project) + changelog_tpl = project_root / any_changelog_format.template + changelog_tpl.write_text("{{first}} - {{second}} - {{third}}") + + mock_plugin.template_extras = dict( + first="from-plugin", second="from-plugin", third="from-plugin" + ) + + pyproject = project_root / "pyproject.toml" + pyproject.write_text( + dedent( + """\ + [tool.commitizen] + version = "0.1.0" + [tool.commitizen.extras] + first = "from-config" + second = "from-config" + """ + ) + ) + + create_file_and_commit("feat: new file") + + testargs = [ + "cz", + "bump", + "--yes", + "--changelog", + "--extra", + "first=from-command", + "0.1.1", + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + changelog = project_root / any_changelog_format.default_changelog_file + assert changelog.read_text() == "from-command - from-config - from-plugin" + + +def test_bump_template_extra_quotes( + mocker: MockFixture, + tmp_commitizen_project: Path, + any_changelog_format: ChangelogFormat, +): + project_root = Path(tmp_commitizen_project) + changelog_tpl = project_root / any_changelog_format.template + changelog_tpl.write_text("{{first}} - {{second}} - {{third}}") + + create_file_and_commit("feat: new file") + + testargs = [ + "cz", + "bump", + "--changelog", + "--yes", + "-e", + "first=no-quote", + "-e", + "second='single quotes'", + "-e", + 'third="double quotes"', + "0.1.1", + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + changelog = project_root / any_changelog_format.default_changelog_file + assert changelog.read_text() == "no-quote - single quotes - double quotes" + + +def test_bump_changelog_contains_increment_only(mocker, tmp_commitizen_project, capsys): + """Issue 1024""" + # Initialize commitizen up to v1.0.0 + project_root = Path(tmp_commitizen_project) + tmp_commitizen_cfg_file = project_root / "pyproject.toml" + tmp_commitizen_cfg_file.write_text( + '[tool.commitizen]\nversion="1.0.0"\nupdate_changelog_on_bump = true\n' + ) + tmp_changelog_file = project_root / "CHANGELOG.md" + tmp_changelog_file.write_text("## v1.0.0") + create_file_and_commit("feat(user): new file") + create_tag("v1.0.0") + + # Add a commit and bump to v2.0.0 + create_file_and_commit("feat(user)!: new file") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + _ = capsys.readouterr() + + # Add a commit and create the incremental changelog to v3.0.0 + # it should only include v3 changes + create_file_and_commit("feat(next)!: next version") + testargs = ["cz", "bump", "--yes", "--files-only", "--changelog-to-stdout"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(ExpectedExit): + cli.main() + out, _ = capsys.readouterr() + + assert "3.0.0" in out + assert "2.0.0" not in out + + +@skip_below_py_3_13 +def test_bump_command_shows_description_when_use_help_option( + mocker: MockFixture, capsys, file_regression +): + testargs = ["cz", "bump", "--help"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(SystemExit): + cli.main() + + out, _ = capsys.readouterr() + file_regression.check(out, extension=".txt") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_get_next(mocker: MockFixture, capsys): + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes", "--get-next"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(GetNextExit): + cli.main() + + out, _ = capsys.readouterr() + assert "0.2.0" in out + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is False + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_get_next_update_changelog_on_bump( + mocker: MockFixture, capsys, config_path +): + create_file_and_commit("feat: new file") + with open(config_path, "a", encoding="utf-8") as fp: + fp.write("update_changelog_on_bump = true\n") + + testargs = ["cz", "bump", "--yes", "--get-next"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(GetNextExit): + cli.main() + + out, _ = capsys.readouterr() + assert "0.2.0" in out + + tag_exists = git.tag_exist("0.2.0") + assert tag_exists is False + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_get_next__changelog_is_not_allowed(mocker: MockFixture): + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes", "--get-next", "--changelog"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NotAllowed): + cli.main() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_get_next__changelog_to_stdout_is_not_allowed(mocker: MockFixture): + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes", "--get-next", "--changelog-to-stdout"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NotAllowed): + cli.main() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_get_next__manual_version_is_not_allowed(mocker: MockFixture): + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes", "--get-next", "0.2.1"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NotAllowed): + cli.main() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_get_next__no_eligible_commits_raises(mocker: MockFixture): + create_file_and_commit("chore: new commit") + + testargs = ["cz", "bump", "--yes", "--get-next"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NoneIncrementExit): + cli.main() + + +def test_bump_allow_no_commit_with_no_commit(mocker, tmp_commitizen_project, capsys): + with tmp_commitizen_project.as_cwd(): + # Create the first commit and bump to 1.0.0 + create_file_and_commit("feat(user)!: new file") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + # Verify NoCommitsFoundError should be raised + # when there's no new commit and "--allow-no-commit" is not set + with pytest.raises(NoCommitsFoundError): + testargs = ["cz", "bump"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + # bump to 1.0.1 with new commit when "--allow-no-commit" is set + testargs = ["cz", "bump", "--allow-no-commit"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + out, _ = capsys.readouterr() + assert "bump: version 1.0.0 โ†’ 1.0.1" in out + + +def test_bump_allow_no_commit_with_no_eligible_commit( + mocker, tmp_commitizen_project, capsys +): + with tmp_commitizen_project.as_cwd(): + # Create the first commit and bump to 1.0.0 + create_file_and_commit("feat(user)!: new file") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + # Create a commit that is ineligible to bump + create_file_and_commit("docs(bump): add description for allow no commit") + + # Verify NoneIncrementExit should be raised + # when there's no eligible bumping commit and "--allow-no-commit" is not set + with pytest.raises(NoneIncrementExit): + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + # bump to 1.0.1 with ineligible commit when "--allow-no-commit" is set + testargs = ["cz", "bump", "--allow-no-commit"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + out, _ = capsys.readouterr() + assert "bump: version 1.0.0 โ†’ 1.0.1" in out + + +def test_bump_allow_no_commit_with_increment(mocker, tmp_commitizen_project, capsys): + with tmp_commitizen_project.as_cwd(): + # # Create the first commit and bump to 1.0.0 + create_file_and_commit("feat(user)!: new file") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + # Verify NoCommitsFoundError should be raised + # when there's no new commit and "--allow-no-commit" is not set + with pytest.raises(NoCommitsFoundError): + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + # bump to 1.1.0 with no new commit when "--allow-no-commit" is set + # and increment is specified + testargs = ["cz", "bump", "--yes", "--allow-no-commit", "--increment", "MINOR"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + out, _ = capsys.readouterr() + assert "bump: version 1.0.0 โ†’ 1.1.0" in out + + +def test_bump_allow_no_commit_with_manual_version( + mocker, tmp_commitizen_project, capsys +): + with tmp_commitizen_project.as_cwd(): + # # Create the first commit and bump to 1.0.0 + create_file_and_commit("feat(user)!: new file") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + # Verify NoCommitsFoundError should be raised + # when there's no new commit and "--allow-no-commit" is not set + with pytest.raises(NoCommitsFoundError): + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + # bump to 1.1.0 with no new commit when "--allow-no-commit" is set + # and increment is specified + testargs = ["cz", "bump", "--yes", "--allow-no-commit", "2.0.0"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + out, _ = capsys.readouterr() + assert "bump: version 1.0.0 โ†’ 2.0.0" in out + + +def test_bump_detect_legacy_tags_from_scm( + tmp_commitizen_project: py.path.local, mocker: MockFixture +): + project_root = Path(tmp_commitizen_project) + tmp_commitizen_cfg_file = project_root / "pyproject.toml" + tmp_commitizen_cfg_file.write_text( + "\n".join( + [ + "[tool.commitizen]", + 'version_provider = "scm"', + 'tag_format = "v$version"', + "legacy_tag_formats = [", + ' "legacy-${version}"', + "]", + ] + ), + ) + create_file_and_commit("feat: new file") + create_tag("legacy-0.4.2") + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--increment", "patch", "--changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + assert git.tag_exist("v0.4.3") diff --git a/tests/commands/test_bump_command/test_bump_command_shows_description_when_use_help_option.txt b/tests/commands/test_bump_command/test_bump_command_shows_description_when_use_help_option.txt new file mode 100644 index 0000000..5d44388 --- /dev/null +++ b/tests/commands/test_bump_command/test_bump_command_shows_description_when_use_help_option.txt @@ -0,0 +1,81 @@ +usage: cz bump [-h] [--dry-run] [--files-only] [--local-version] [--changelog] + [--no-verify] [--yes] [--tag-format TAG_FORMAT] + [--bump-message BUMP_MESSAGE] [--prerelease {alpha,beta,rc}] + [--devrelease DEVRELEASE] [--increment {MAJOR,MINOR,PATCH}] + [--increment-mode {linear,exact}] [--check-consistency] + [--annotated-tag] + [--annotated-tag-message ANNOTATED_TAG_MESSAGE] [--gpg-sign] + [--changelog-to-stdout] [--git-output-to-stderr] [--retry] + [--major-version-zero] [--template TEMPLATE] [--extra EXTRA] + [--file-name FILE_NAME] [--prerelease-offset PRERELEASE_OFFSET] + [--version-scheme {pep440,semver,semver2}] + [--version-type {pep440,semver,semver2}] + [--build-metadata BUILD_METADATA] [--get-next] + [--allow-no-commit] + [MANUAL_VERSION] + +bump semantic version based on the git log + +positional arguments: + MANUAL_VERSION bump to the given version (e.g: 1.5.3) + +options: + -h, --help show this help message and exit + --dry-run show output to stdout, no commit, no modified files + --files-only bump version in the files from the config + --local-version bump only the local version portion + --changelog, -ch generate the changelog for the newest version + --no-verify this option bypasses the pre-commit and commit-msg + hooks + --yes accept automatically questions done + --tag-format TAG_FORMAT + the format used to tag the commit and read it, use it + in existing projects, wrap around simple quotes + --bump-message BUMP_MESSAGE + template used to create the release commit, useful + when working with CI + --prerelease, -pr {alpha,beta,rc} + choose type of prerelease + --devrelease, -d DEVRELEASE + specify non-negative integer for dev. release + --increment {MAJOR,MINOR,PATCH} + manually specify the desired increment + --increment-mode {linear,exact} + set the method by which the new version is chosen. + 'linear' (default) guesses the next version based on + typical linear version progression, such that bumping + of a pre-release with lower precedence than the + current pre-release phase maintains the current phase + of higher precedence. 'exact' applies the changes that + have been specified (or determined from the commit + log) without interpretation, such that the increment + and pre-release are always honored + --check-consistency, -cc + check consistency among versions defined in commitizen + configuration and version_files + --annotated-tag, -at create annotated tag instead of lightweight one + --annotated-tag-message, -atm ANNOTATED_TAG_MESSAGE + create annotated tag message + --gpg-sign, -s sign tag instead of lightweight one + --changelog-to-stdout + Output changelog to the stdout + --git-output-to-stderr + Redirect git output to stderr + --retry retry commit if it fails the 1st time + --major-version-zero keep major version at zero, even for breaking changes + --template, -t TEMPLATE + changelog template file name (relative to the current + working directory) + --extra, -e EXTRA a changelog extra variable (in the form 'key=value') + --file-name FILE_NAME + file name of changelog (default: 'CHANGELOG.md') + --prerelease-offset PRERELEASE_OFFSET + start pre-releases with this offset + --version-scheme {pep440,semver,semver2} + choose version scheme + --version-type {pep440,semver,semver2} + Deprecated, use --version-scheme + --build-metadata BUILD_METADATA + Add additional build-metadata to the version-number + --get-next Determine the next version and write to stdout + --allow-no-commit bump version without eligible commits diff --git a/tests/commands/test_changelog_command.py b/tests/commands/test_changelog_command.py new file mode 100644 index 0000000..f794b8d --- /dev/null +++ b/tests/commands/test_changelog_command.py @@ -0,0 +1,1927 @@ +import itertools +import sys +from datetime import datetime +from pathlib import Path +from textwrap import dedent + +import pytest +from dateutil import relativedelta +from jinja2 import FileSystemLoader +from pytest_mock import MockFixture +from pytest_regressions.file_regression import FileRegressionFixture + +from commitizen import __file__ as commitizen_init +from commitizen import cli, git +from commitizen.changelog_formats import ChangelogFormat +from commitizen.commands.changelog import Changelog +from commitizen.config.base_config import BaseConfig +from commitizen.cz.base import BaseCommitizen +from commitizen.exceptions import ( + DryRunExit, + InvalidCommandArgumentError, + NoCommitsFoundError, + NoRevisionError, + NotAGitProjectError, + NotAllowed, +) +from tests.utils import ( + create_branch, + create_file_and_commit, + create_tag, + get_current_branch, + merge_branch, + skip_below_py_3_13, + switch_branch, + wait_for_tag, +) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_from_version_zero_point_two( + mocker: MockFixture, capsys, file_regression +): + create_file_and_commit("feat: new file") + create_file_and_commit("refactor: not in changelog") + + # create tag + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + capsys.readouterr() + + create_file_and_commit("feat: after 0.2.0") + create_file_and_commit("feat: after 0.2") + + testargs = ["cz", "changelog", "--start-rev", "0.2.0", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + + out, _ = capsys.readouterr() + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_with_different_cz(mocker: MockFixture, capsys, file_regression): + create_file_and_commit("JRA-34 #comment corrected indent issue") + create_file_and_commit("JRA-35 #time 1w 2d 4h 30m Total work logged") + + testargs = ["cz", "-n", "cz_jira", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(DryRunExit): + cli.main() + out, _ = capsys.readouterr() + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_from_start( + mocker: MockFixture, capsys, changelog_format: ChangelogFormat, file_regression +): + create_file_and_commit("feat: new file") + create_file_and_commit("refactor: is in changelog") + create_file_and_commit("Merge into master") + changelog_file = f"CHANGELOG.{changelog_format.extension}" + template = f"CHANGELOG.{changelog_format.extension}.j2" + + testargs = [ + "cz", + "changelog", + "--file-name", + changelog_file, + "--template", + template, + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_file, encoding="utf-8") as f: + out = f.read() + file_regression.check(out, extension=changelog_format.ext) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_replacing_unreleased_using_incremental( + mocker: MockFixture, capsys, changelog_format: ChangelogFormat, file_regression +): + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + create_file_and_commit("Merge into master") + changelog_file = f"CHANGELOG.{changelog_format.extension}" + template = f"CHANGELOG.{changelog_format.extension}.j2" + + testargs = [ + "cz", + "changelog", + "--file-name", + changelog_file, + "--template", + template, + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + testargs = [ + "cz", + "bump", + "--yes", + "--file-name", + changelog_file, + "--template", + template, + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = [ + "cz", + "changelog", + "--incremental", + "--file-name", + changelog_file, + "--template", + template, + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_file, encoding="utf-8") as f: + out = f.read().replace( + datetime.strftime(datetime.now(), "%Y-%m-%d"), "2022-08-14" + ) + + file_regression.check(out, extension=changelog_format.ext) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_is_persisted_using_incremental( + mocker: MockFixture, capsys, changelog_path, file_regression +): + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + create_file_and_commit("Merge into master") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + testargs = ["cz", "changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, "a", encoding="utf-8") as f: + f.write("\nnote: this should be persisted using increment\n") + + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, encoding="utf-8") as f: + out = f.read().replace( + datetime.strftime(datetime.now(), "%Y-%m-%d"), "2022-08-14" + ) + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_incremental_angular_sample( + mocker: MockFixture, capsys, changelog_path, file_regression +): + with open(changelog_path, "w", encoding="utf-8") as f: + f.write( + "# [10.0.0-rc.3](https://github.com/angular/angular/compare/10.0.0-rc.2...10.0.0-rc.3) (2020-04-22)\n" + "\n" + "### Bug Fixes" + "\n" + "* **common:** format day-periods that cross midnight ([#36611](https://github.com/angular/angular/issues/36611)) ([c6e5fc4](https://github.com/angular/angular/commit/c6e5fc4)), closes [#36566](https://github.com/angular/angular/issues/36566)\n" + ) + create_file_and_commit("irrelevant commit") + git.tag("10.0.0-rc.3") + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, encoding="utf-8") as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +KEEP_A_CHANGELOG = """# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). +""" + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_incremental_keep_a_changelog_sample( + mocker: MockFixture, capsys, changelog_path, file_regression +): + with open(changelog_path, "w", encoding="utf-8") as f: + f.write(KEEP_A_CHANGELOG) + create_file_and_commit("irrelevant commit") + git.tag("1.0.0") + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, encoding="utf-8") as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.parametrize("dry_run", [True, False]) +def test_changelog_hook(mocker: MockFixture, config: BaseConfig, dry_run: bool): + changelog_hook_mock = mocker.Mock() + changelog_hook_mock.return_value = "cool changelog hook" + + create_file_and_commit("feat: new file") + create_file_and_commit("refactor: is in changelog") + create_file_and_commit("Merge into master") + + config.settings["change_type_order"] = ["Refactor", "Feat"] # type: ignore[typeddict-unknown-key] + changelog = Changelog( + config, {"unreleased_version": None, "incremental": True, "dry_run": dry_run} + ) + mocker.patch.object(changelog.cz, "changelog_hook", changelog_hook_mock) + try: + changelog() + except DryRunExit: + pass + + full_changelog = ( + "## Unreleased\n\n### Refactor\n\n- is in changelog\n\n### Feat\n\n- new file\n" + ) + partial_changelog = full_changelog + if dry_run: + partial_changelog = "" + + changelog_hook_mock.assert_called_with(full_changelog, partial_changelog) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_hook_customize(mocker: MockFixture, config_customize): + changelog_hook_mock = mocker.Mock() + changelog_hook_mock.return_value = "cool changelog hook" + + create_file_and_commit("feat: new file") + create_file_and_commit("refactor: is in changelog") + create_file_and_commit("Merge into master") + + changelog = Changelog( + config_customize, + {"unreleased_version": None, "incremental": True, "dry_run": False}, + ) + mocker.patch.object(changelog.cz, "changelog_hook", changelog_hook_mock) + changelog() + full_changelog = ( + "## Unreleased\n\n### Refactor\n\n- is in changelog\n\n### Feat\n\n- new file\n" + ) + + changelog_hook_mock.assert_called_with(full_changelog, full_changelog) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_release_hook(mocker: MockFixture, config): + def changelog_release_hook(release: dict, tag: git.GitTag) -> dict: + return release + + for i in range(3): + create_file_and_commit("feat: new file") + create_file_and_commit("refactor: is in changelog") + create_file_and_commit("Merge into master") + git.tag(f"0.{i + 1}.0") + + # changelog = Changelog(config, {}) + changelog = Changelog( + config, {"unreleased_version": None, "incremental": True, "dry_run": False} + ) + mocker.patch.object(changelog.cz, "changelog_release_hook", changelog_release_hook) + spy = mocker.spy(changelog.cz, "changelog_release_hook") + changelog() + + assert spy.call_count == 3 + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_with_non_linear_merges_commit_order( + mocker: MockFixture, config_customize +): + """Test that commits merged non-linearly are correctly ordered in the changelog + + A typical scenario is having two branches from main like so: + * feat: I will be merged first - (2023-03-01 11:35:51 +0100) | (branchB) + | * feat: I will be merged second - (2023-03-01 11:35:22 +0100) | (branchA) + |/ + * feat: initial commit - (2023-03-01 11:34:54 +0100) | (HEAD -> main) + + And merging them, for example in the reverse order they were created on would give the following: + * Merge branch 'branchA' - (2023-03-01 11:42:59 +0100) | (HEAD -> main) + |\ + | * feat: I will be merged second - (2023-03-01 11:35:22 +0100) | (branchA) + * | feat: I will be merged first - (2023-03-01 11:35:51 +0100) | (branchB) + |/ + * feat: initial commit - (2023-03-01 11:34:54 +0100) | + + In this case we want the changelog to reflect the topological order of commits, + i.e. the order in which they were merged into the main branch + + So the above example should result in the following: + ## Unreleased + + ### Feat + - I will be merged second + - I will be merged first + - initial commit + """ + changelog_hook_mock = mocker.Mock() + changelog_hook_mock.return_value = "cool changelog hook" + + create_file_and_commit("feat: initial commit") + + main_branch = get_current_branch() + + create_branch("branchA") + create_branch("branchB") + + switch_branch("branchA") + create_file_and_commit("feat: I will be merged second") + + switch_branch("branchB") + create_file_and_commit("feat: I will be merged first") + + # Note we merge branches opposite order than author_date + switch_branch(main_branch) + merge_branch("branchB") + merge_branch("branchA") + + changelog = Changelog( + config_customize, + {"unreleased_version": None, "incremental": True, "dry_run": False}, + ) + mocker.patch.object(changelog.cz, "changelog_hook", changelog_hook_mock) + changelog() + full_changelog = "\ +## Unreleased\n\n\ +\ +### Feat\n\n\ +- I will be merged second\n\ +- I will be merged first\n\ +- initial commit\n" + + changelog_hook_mock.assert_called_with(full_changelog, full_changelog) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_multiple_incremental_do_not_add_new_lines( + mocker: MockFixture, capsys, changelog_path, file_regression +): + """Test for bug https://github.com/commitizen-tools/commitizen/issues/192""" + create_file_and_commit("feat: add new output") + + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("fix: output glitch") + + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("fix: no more explosions") + + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("feat: add more stuff") + + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, encoding="utf-8") as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_incremental_newline_separates_new_content_from_old( + mocker: MockFixture, changelog_path +): + """Test for https://github.com/commitizen-tools/commitizen/issues/509""" + with open(changelog_path, "w", encoding="utf-8") as f: + f.write("Pre-existing content that should be kept\n") + + create_file_and_commit("feat: add more cat videos") + + testargs = ["cz", "changelog", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, encoding="utf-8") as f: + out = f.read() + + assert ( + out + == "Pre-existing content that should be kept\n\n## Unreleased\n\n### Feat\n\n- add more cat videos\n" + ) + + +def test_changelog_without_revision(mocker: MockFixture, tmp_commitizen_project): + changelog_file = tmp_commitizen_project.join("CHANGELOG.md") + changelog_file.write( + """ + # Unreleased + + ## v1.0.0 + """ + ) + + # create_file_and_commit("feat: new file") + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NoRevisionError): + cli.main() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_incremental_with_revision(mocker): + """combining incremental with a revision doesn't make sense""" + testargs = ["cz", "changelog", "--incremental", "0.2.0"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NotAllowed): + cli.main() + + +def test_changelog_with_different_tag_name_and_changelog_content( + mocker: MockFixture, tmp_commitizen_project +): + changelog_file = tmp_commitizen_project.join("CHANGELOG.md") + changelog_file.write( + """ + # Unreleased + + ## v1.0.0 + """ + ) + create_file_and_commit("feat: new file") + git.tag("2.0.0") + + # create_file_and_commit("feat: new file") + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NoRevisionError): + cli.main() + + +def test_changelog_in_non_git_project(tmpdir, config, mocker: MockFixture): + testargs = ["cz", "changelog", "--incremental"] + mocker.patch.object(sys, "argv", testargs) + + with tmpdir.as_cwd(): + with pytest.raises(NotAGitProjectError): + cli.main() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_breaking_change_content_v1_beta(mocker: MockFixture, capsys, file_regression): + commit_message = ( + "feat(users): email pattern corrected\n\n" + "BREAKING CHANGE: migrate by renaming user to users\n\n" + "footer content" + ) + create_file_and_commit(commit_message) + testargs = ["cz", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + out, _ = capsys.readouterr() + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_breaking_change_content_v1(mocker: MockFixture, capsys, file_regression): + commit_message = ( + "feat(users): email pattern corrected\n\n" + "body content\n\n" + "BREAKING CHANGE: migrate by renaming user to users" + ) + create_file_and_commit(commit_message) + testargs = ["cz", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + out, _ = capsys.readouterr() + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_breaking_change_content_v1_multiline( + mocker: MockFixture, capsys, file_regression +): + commit_message = ( + "feat(users): email pattern corrected\n\n" + "body content\n\n" + "BREAKING CHANGE: migrate by renaming user to users.\n" + "and then connect the thingy with the other thingy\n\n" + "footer content" + ) + create_file_and_commit(commit_message) + testargs = ["cz", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + out, _ = capsys.readouterr() + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_breaking_change_content_v1_with_exclamation_mark( + mocker: MockFixture, capsys, file_regression +): + commit_message = "chore!: drop support for py36" + create_file_and_commit(commit_message) + testargs = ["cz", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + out, _ = capsys.readouterr() + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_breaking_change_content_v1_with_exclamation_mark_feat( + mocker: MockFixture, capsys, file_regression +): + commit_message = "feat(pipeline)!: some text with breaking change" + create_file_and_commit(commit_message) + testargs = ["cz", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + out, _ = capsys.readouterr() + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_config_flag_increment( + mocker: MockFixture, changelog_path, config_path, file_regression +): + with open(config_path, "a", encoding="utf-8") as f: + f.write("changelog_incremental = true\n") + with open(changelog_path, "a", encoding="utf-8") as f: + f.write("\nnote: this should be persisted using increment\n") + + create_file_and_commit("feat: add new output") + + testargs = ["cz", "changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, encoding="utf-8") as f: + out = f.read() + + assert "this should be persisted using increment" in out + file_regression.check(out, extension=".md") + + +@pytest.mark.parametrize("test_input", ["rc", "alpha", "beta"]) +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_config_flag_merge_prerelease( + mocker: MockFixture, changelog_path, config_path, file_regression, test_input +): + with open(config_path, "a") as f: + f.write("changelog_merge_prerelease = true\n") + + create_file_and_commit("irrelevant commit") + mocker.patch("commitizen.git.GitTag.date", "1970-01-01") + git.tag("1.0.0") + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + + testargs = ["cz", "bump", "--prerelease", test_input, "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path) as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_config_start_rev_option( + mocker: MockFixture, capsys, config_path, file_regression +): + # create commit and tag + create_file_and_commit("feat: new file") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + capsys.readouterr() + + create_file_and_commit("feat: after 0.2.0") + create_file_and_commit("feat: after 0.2") + + with open(config_path, "a", encoding="utf-8") as f: + f.write('changelog_start_rev = "0.2.0"\n') + + testargs = ["cz", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + + out, _ = capsys.readouterr() + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_incremental_keep_a_changelog_sample_with_annotated_tag( + mocker: MockFixture, capsys, changelog_path, file_regression +): + """Fix #378""" + with open(changelog_path, "w", encoding="utf-8") as f: + f.write(KEEP_A_CHANGELOG) + create_file_and_commit("irrelevant commit") + git.tag("1.0.0", annotated=True) + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, encoding="utf-8") as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.parametrize("test_input", ["rc", "alpha", "beta"]) +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2021-06-11") +def test_changelog_incremental_with_release_candidate_version( + mocker: MockFixture, changelog_path, file_regression, test_input +): + """Fix #357""" + with open(changelog_path, "w", encoding="utf-8") as f: + f.write(KEEP_A_CHANGELOG) + create_file_and_commit("irrelevant commit") + git.tag("1.0.0", annotated=True) + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + + testargs = ["cz", "bump", "--changelog", "--prerelease", test_input, "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path, encoding="utf-8") as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.parametrize( + "from_pre,to_pre", itertools.product(["alpha", "beta", "rc"], repeat=2) +) +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2021-06-11") +def test_changelog_incremental_with_prerelease_version_to_prerelease_version( + mocker: MockFixture, changelog_path, file_regression, from_pre, to_pre +): + with open(changelog_path, "w") as f: + f.write(KEEP_A_CHANGELOG) + create_file_and_commit("irrelevant commit") + git.tag("1.0.0", annotated=True) + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + + testargs = ["cz", "bump", "--changelog", "--prerelease", from_pre, "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + testargs = ["cz", "bump", "--changelog", "--prerelease", to_pre, "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path) as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.parametrize("test_input", ["rc", "alpha", "beta"]) +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_release_candidate_version_with_merge_prerelease( + mocker: MockFixture, changelog_path, file_regression, test_input +): + """Fix #357""" + with open(changelog_path, "w") as f: + f.write(KEEP_A_CHANGELOG) + create_file_and_commit("irrelevant commit") + mocker.patch("commitizen.git.GitTag.date", "1970-01-01") + git.tag("1.0.0") + + create_file_and_commit("feat: add new output") + create_file_and_commit("fix: output glitch") + + testargs = ["cz", "bump", "--prerelease", test_input, "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--merge-prerelease"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path) as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.parametrize("test_input", ["rc", "alpha", "beta"]) +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2023-04-16") +def test_changelog_incremental_with_merge_prerelease( + mocker: MockFixture, changelog_path, file_regression, test_input +): + """Fix #357""" + with open(changelog_path, "w") as f: + f.write(KEEP_A_CHANGELOG) + create_file_and_commit("irrelevant commit") + mocker.patch("commitizen.git.GitTag.date", "1970-01-01") + git.tag("1.0.0") + + create_file_and_commit("feat: add new output") + + testargs = ["cz", "bump", "--prerelease", test_input, "--yes", "--changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("fix: output glitch") + + testargs = ["cz", "bump", "--prerelease", test_input, "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("fix: mama gotta work") + create_file_and_commit("feat: add more stuff") + create_file_and_commit("Merge into master") + + testargs = ["cz", "changelog", "--merge-prerelease", "--incremental"] + + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path) as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_with_filename_as_empty_string( + mocker: MockFixture, changelog_path, config_path +): + with open(config_path, "a", encoding="utf-8") as f: + f.write("changelog_file = true\n") + + create_file_and_commit("feat: add new output") + + testargs = ["cz", "changelog"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(NotAllowed): + cli.main() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2022-02-13") +def test_changelog_from_rev_first_version_from_arg( + mocker: MockFixture, config_path, changelog_path, file_regression +): + mocker.patch("commitizen.git.GitTag.date", "2022-02-13") + + with open(config_path, "a", encoding="utf-8") as f: + f.write('tag_format = "$version"\n') + + # create commit and tag + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + + create_file_and_commit("feat: after 0.2.0") + create_file_and_commit("feat: another feature") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + testargs = ["cz", "changelog", "0.2.0"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + with open(changelog_path, encoding="utf-8") as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2022-02-13") +def test_changelog_from_rev_latest_version_from_arg( + mocker: MockFixture, config_path, changelog_path, file_regression +): + mocker.patch("commitizen.git.GitTag.date", "2022-02-13") + + with open(config_path, "a", encoding="utf-8") as f: + f.write('tag_format = "$version"\n') + + # create commit and tag + create_file_and_commit("feat: new file") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + + create_file_and_commit("feat: after 0.2.0") + create_file_and_commit("feat: another feature") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + wait_for_tag() + + testargs = ["cz", "changelog", "0.3.0"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path) as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2022-02-13") +@pytest.mark.parametrize( + "rev_range,tag", + ( + pytest.param("0.8.0", "0.2.0", id="single-not-found"), + pytest.param("0.1.0..0.3.0", "0.3.0", id="lower-bound-not-found"), + pytest.param("0.1.0..0.3.0", "0.1.0", id="upper-bound-not-found"), + pytest.param("0.3.0..0.4.0", "0.2.0", id="none-found"), + ), +) +def test_changelog_from_rev_range_not_found( + mocker: MockFixture, config_path, rev_range: str, tag: str +): + """Provides an invalid revision ID to changelog command""" + with open(config_path, "a", encoding="utf-8") as f: + f.write('tag_format = "$version"\n') + + # create commit and tag + create_file_and_commit("feat: new file") + create_tag(tag) + create_file_and_commit("feat: new file") + create_tag("1.0.0") + + testargs = ["cz", "changelog", rev_range] # it shouldn't exist + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(NoCommitsFoundError) as excinfo: + cli.main() + + assert "Could not find a valid revision" in str(excinfo) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2022-02-13") +def test_changelog_multiple_matching_tags( + mocker: MockFixture, config_path, changelog_path +): + with open(config_path, "a", encoding="utf-8") as f: + f.write('tag_format = "new-$version"\nlegacy_tag_formats = ["legacy-$version"]') + + create_file_and_commit("feat: new file") + create_tag("legacy-1.0.0") + create_file_and_commit("feat: new file") + create_tag("legacy-2.0.0") + create_tag("new-2.0.0") + + testargs = ["cz", "changelog", "1.0.0..2.0.0"] # it shouldn't exist + mocker.patch.object(sys, "argv", testargs) + with pytest.warns() as warnings: + cli.main() + + assert len(warnings) == 1 + warning = warnings[0] + assert "Multiple tags found for version 2.0.0" in str(warning.message) + + with open(changelog_path) as f: + out = f.read() + + # Ensure only one tag is rendered + assert out.count("2.0.0") == 1 + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2022-02-13") +def test_changelog_from_rev_range_default_tag_format( + mocker, config_path, changelog_path +): + """Checks that rev_range is calculated with the default (None) tag format""" + # create commit and tag + create_file_and_commit("feat: new file") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + + create_file_and_commit("feat: after 0.2.0") + create_file_and_commit("feat: another feature") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + + testargs = ["cz", "changelog", "0.3.0"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + with open(changelog_path) as f: + out = f.read() + + assert "new file" not in out + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2022-02-13") +def test_changelog_from_rev_version_range_including_first_tag( + mocker: MockFixture, config_path, changelog_path, file_regression +): + mocker.patch("commitizen.git.GitTag.date", "2022-02-13") + + with open(config_path, "a", encoding="utf-8") as f: + f.write('tag_format = "$version"\n') + + # create commit and tag + create_file_and_commit("feat: new file") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + create_file_and_commit("feat: after 0.2.0") + create_file_and_commit("feat: another feature") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + testargs = ["cz", "changelog", "0.2.0..0.3.0"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + with open(changelog_path, encoding="utf-8") as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2022-02-13") +def test_changelog_from_rev_version_range_from_arg( + mocker: MockFixture, config_path, changelog_path, file_regression +): + mocker.patch("commitizen.git.GitTag.date", "2022-02-13") + + with open(config_path, "a", encoding="utf-8") as f: + f.write('tag_format = "$version"\n') + + # create commit and tag + create_file_and_commit("feat: new file") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + create_file_and_commit("feat: after 0.2.0") + create_file_and_commit("feat: another feature") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + + create_file_and_commit("feat: getting ready for this") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + + testargs = ["cz", "changelog", "0.3.0..0.4.0"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + with open(changelog_path) as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2022-02-13") +def test_changelog_from_rev_version_range_with_legacy_tags( + mocker: MockFixture, config_path, changelog_path, file_regression +): + mocker.patch("commitizen.git.GitTag.date", "2022-02-13") + + changelog = Path(changelog_path) + Path(config_path).write_text( + "\n".join( + [ + "[tool.commitizen]", + 'version_provider = "scm"', + 'tag_format = "v$version"', + "legacy_tag_formats = [", + ' "legacy-${version}",', + ' "old-${version}",', + "]", + ] + ), + ) + + create_file_and_commit("feat: new file") + create_tag("old-0.2.0") + create_file_and_commit("feat: new file") + create_tag("legacy-0.3.0") + create_file_and_commit("feat: new file") + create_tag("legacy-0.4.0") + + testargs = ["cz", "changelog", "0.2.0..0.4.0"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + file_regression.check(changelog.read_text(), extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2022-02-13") +def test_changelog_from_rev_version_with_big_range_from_arg( + mocker: MockFixture, config_path, changelog_path, file_regression +): + mocker.patch("commitizen.git.GitTag.date", "2022-02-13") + + with open(config_path, "a", encoding="utf-8") as f: + f.write('tag_format = "$version"\n') + + # create commit and tag + create_file_and_commit("feat: new file") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + + create_file_and_commit("feat: after 0.2.0") + create_file_and_commit("feat: another feature") + + testargs = ["cz", "bump", "--yes"] # 0.3.0 + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + create_file_and_commit("feat: getting ready for this") + + testargs = ["cz", "bump", "--yes"] # 0.4.0 + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + create_file_and_commit("fix: small error") + + testargs = ["cz", "bump", "--yes"] # 0.4.1 + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + create_file_and_commit("feat: new shinny feature") + + testargs = ["cz", "bump", "--yes"] # 0.5.0 + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + create_file_and_commit("feat: amazing different shinny feature") + # dirty hack to avoid same time between tags + + testargs = ["cz", "bump", "--yes"] # 0.6.0 + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + + testargs = ["cz", "changelog", "0.3.0..0.5.0"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + with open(changelog_path) as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2022-02-13") +def test_changelog_from_rev_latest_version_dry_run( + mocker: MockFixture, capsys, config_path, changelog_path, file_regression +): + mocker.patch("commitizen.git.GitTag.date", "2022-02-13") + + with open(config_path, "a") as f: + f.write('tag_format = "$version"\n') + + # create commit and tag + create_file_and_commit("feat: new file") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + + create_file_and_commit("feat: after 0.2.0") + create_file_and_commit("feat: another feature") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + capsys.readouterr() + wait_for_tag() + + testargs = ["cz", "changelog", "0.3.0", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + + out, _ = capsys.readouterr() + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_invalid_subject_is_skipped(mocker: MockFixture, capsys): + """Fix #510""" + non_conformant_commit_title = ( + "Merge pull request #487 from manang/master\n\n" + "feat: skip merge messages that start with Pull request\n" + ) + create_file_and_commit(non_conformant_commit_title) + create_file_and_commit("feat: a new world") + testargs = ["cz", "changelog", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + out, _ = capsys.readouterr() + + assert out == ("## Unreleased\n\n### Feat\n\n- a new world\n\n") + + +@pytest.mark.freeze_time("2022-02-13") +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_with_customized_change_type_order( + mocker, config_path, changelog_path, file_regression +): + mocker.patch("commitizen.git.GitTag.date", "2022-02-13") + + with open(config_path, "a") as f: + f.write('tag_format = "$version"\n') + f.write( + 'change_type_order = ["BREAKING CHANGE", "Perf", "Fix", "Feat", "Refactor"]\n' + ) + + # create commit and tag + create_file_and_commit("feat: new file") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + create_file_and_commit("feat: after 0.2.0") + create_file_and_commit("feat: another feature") + create_file_and_commit("fix: fix bug") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + + create_file_and_commit("feat: getting ready for this") + create_file_and_commit("perf: perf improvement") + + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + + testargs = ["cz", "changelog", "0.3.0..0.4.0"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + with open(changelog_path) as f: + out = f.read() + + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_empty_commit_list(mocker): + create_file_and_commit("feat: a new world") + + # test changelog properly handles when no commits are found for the revision + mocker.patch("commitizen.git.get_commits", return_value=[]) + testargs = ["cz", "changelog"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(NoCommitsFoundError): + cli.main() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2022-02-13") +def test_changelog_prerelease_rev_with_use_scheme_semver( + mocker: MockFixture, capsys, config_path, changelog_path, file_regression +): + mocker.patch("commitizen.git.GitTag.date", "2022-02-13") + + with open(config_path, "a") as f: + f.write('tag_format = "$version"\nversion_scheme = "semver"') + + # create commit and tag + create_file_and_commit("feat: new file") + testargs = ["cz", "bump", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + + create_file_and_commit("feat: after 0.2.0") + create_file_and_commit("feat: another feature") + + testargs = ["cz", "bump", "--yes", "--prerelease", "alpha"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + capsys.readouterr() + wait_for_tag() + + tag_exists = git.tag_exist("0.3.0-a0") + assert tag_exists is True + + testargs = ["cz", "changelog", "0.3.0-a0", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + + out, _ = capsys.readouterr() + + file_regression.check(out, extension=".md") + + testargs = ["cz", "bump", "--yes", "--prerelease", "alpha"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + capsys.readouterr() + wait_for_tag() + + tag_exists = git.tag_exist("0.3.0-a1") + assert tag_exists is True + + testargs = ["cz", "changelog", "0.3.0-a1", "--dry-run"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(DryRunExit): + cli.main() + + out, _ = capsys.readouterr() + + file_regression.check(out, extension=".second-prerelease.md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_uses_version_tags_for_header(mocker: MockFixture, config): + """Tests that changelog headers always use version tags even if there are non-version tags + + This tests a scenario fixed in this commit: + The first header was using a non-version tag and outputting "## 0-not-a-version" instead of "## 1.0.0 + """ + create_file_and_commit("feat: commit in 1.0.0") + create_tag("0-not-a-version") + create_tag("1.0.0") + create_tag("also-not-a-version") + + write_patch = mocker.patch("commitizen.commands.changelog.out.write") + + changelog = Changelog( + config, {"dry_run": True, "incremental": True, "unreleased_version": None} + ) + + with pytest.raises(DryRunExit): + changelog() + + changelog_output = write_patch.call_args[0][0] + + assert changelog_output.startswith("## 1.0.0") + assert "0-no-a-version" not in changelog_output + assert "also-not-a-version" not in changelog_output + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_from_current_version_tag_with_nonversion_tag( + mocker: MockFixture, config +): + """Tests that changelog generation for a single version works even if + there is a non-version tag in the list of tags + + This tests a scenario which is fixed in this commit: + You have a commit in between two versions (1.0.0..2.0.0) which is tagged with a non-version tag (not-a-version). + In this case commitizen should disregard the non-version tag when determining the rev-range & generating the changelog. + """ + create_file_and_commit( + "feat: initial commit", + committer_date=( + datetime.now() - relativedelta.relativedelta(seconds=3) + ).isoformat(), + ) + create_tag("1.0.0") + + create_file_and_commit( + "feat: commit 1", + committer_date=( + datetime.now() - relativedelta.relativedelta(seconds=2) + ).isoformat(), + ) + create_tag("1-not-a-version") + + create_file_and_commit( + "feat: commit 2", + committer_date=( + datetime.now() - relativedelta.relativedelta(seconds=1) + ).isoformat(), + ) + + create_file_and_commit("bump: version 1.0.0 โ†’ 2.0.0") + create_tag("2.0.0") + + mocker.patch("commitizen.git.GitTag.date", "2022-02-13") + write_patch = mocker.patch("commitizen.commands.changelog.out.write") + + changelog = Changelog( + config, + { + "dry_run": True, + "incremental": False, + "unreleased_version": None, + "rev_range": "2.0.0", + }, + ) + + with pytest.raises(DryRunExit): + changelog() + + full_changelog = "\ +## 2.0.0 (2022-02-13)\n\n\ +### Feat\n\n\ +- commit 2\n\ +- commit 1\n" + + write_patch.assert_called_with(full_changelog) + + +@pytest.mark.parametrize( + "arg,cfg,expected", + ( + pytest.param("", "", "default", id="default"), + pytest.param("", "changelog.cfg", "from config", id="from-config"), + pytest.param( + "--template=changelog.cmd", "changelog.cfg", "from cmd", id="from-command" + ), + ), +) +def test_changelog_template_option_precedance( + mocker: MockFixture, + tmp_commitizen_project: Path, + any_changelog_format: ChangelogFormat, + arg: str, + cfg: str, + expected: str, +): + project_root = Path(tmp_commitizen_project) + cfg_template = project_root / "changelog.cfg" + cmd_template = project_root / "changelog.cmd" + default_template = project_root / any_changelog_format.template + changelog = project_root / any_changelog_format.default_changelog_file + + cfg_template.write_text("from config") + cmd_template.write_text("from cmd") + default_template.write_text("default") + + create_file_and_commit("feat: new file") + + if cfg: + pyproject = project_root / "pyproject.toml" + pyproject.write_text( + dedent( + f"""\ + [tool.commitizen] + version = "0.1.0" + template = "{cfg}" + """ + ) + ) + + testargs = ["cz", "changelog"] + if arg: + testargs.append(arg) + mocker.patch.object(sys, "argv", testargs) + cli.main() + + out = changelog.read_text() + assert out == expected + + +def test_changelog_template_extras_precedance( + mocker: MockFixture, + tmp_commitizen_project: Path, + mock_plugin: BaseCommitizen, + any_changelog_format: ChangelogFormat, +): + project_root = Path(tmp_commitizen_project) + changelog_tpl = project_root / any_changelog_format.template + changelog_tpl.write_text("{{first}} - {{second}} - {{third}}") + + mock_plugin.template_extras = dict( + first="from-plugin", second="from-plugin", third="from-plugin" + ) + + pyproject = project_root / "pyproject.toml" + pyproject.write_text( + dedent( + """\ + [tool.commitizen] + version = "0.1.0" + [tool.commitizen.extras] + first = "from-config" + second = "from-config" + """ + ) + ) + + create_file_and_commit("feat: new file") + + testargs = ["cz", "changelog", "--extra", "first=from-command"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + changelog = project_root / any_changelog_format.default_changelog_file + assert changelog.read_text() == "from-command - from-config - from-plugin" + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2021-06-11") +def test_changelog_only_tag_matching_tag_format_included_prefix( + mocker: MockFixture, + changelog_path: Path, + config_path: Path, +): + with open(config_path, "a", encoding="utf-8") as f: + f.write('\ntag_format = "custom${version}"\n') + create_file_and_commit("feat: new file") + git.tag("v0.2.0") + create_file_and_commit("feat: another new file") + git.tag("0.2.0") + git.tag("random0.2.0") + testargs = ["cz", "bump", "--changelog", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + create_file_and_commit("feat: another new file") + cli.main() + with open(changelog_path) as f: + out = f.read() + assert out.startswith("## custom0.3.0 (2021-06-11)") + assert "## v0.2.0 (2021-06-11)" not in out + assert "## 0.2.0 (2021-06-11)" not in out + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_only_tag_matching_tag_format_included_prefix_sep( + mocker: MockFixture, + changelog_path: Path, + config_path: Path, +): + with open(config_path, "a", encoding="utf-8") as f: + f.write('\ntag_format = "custom-${version}"\n') + create_file_and_commit("feat: new file") + git.tag("v0.2.0") + create_file_and_commit("feat: another new file") + git.tag("0.2.0") + git.tag("random0.2.0") + wait_for_tag() + testargs = ["cz", "bump", "--changelog", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + with open(changelog_path) as f: + out = f.read() + create_file_and_commit("feat: new version another new file") + create_file_and_commit("feat: new version some new file") + testargs = ["cz", "bump", "--changelog"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + with open(changelog_path) as f: + out = f.read() + assert out.startswith("## custom-0.3.0") + assert "## v0.2.0" not in out + assert "## 0.2.0" not in out + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2021-06-11") +def test_changelog_only_tag_matching_tag_format_included_suffix( + mocker: MockFixture, + changelog_path: Path, + config_path: Path, +): + with open(config_path, "a", encoding="utf-8") as f: + f.write('\ntag_format = "${version}custom"\n') + create_file_and_commit("feat: new file") + git.tag("v0.2.0") + create_file_and_commit("feat: another new file") + git.tag("0.2.0") + git.tag("random0.2.0") + testargs = ["cz", "bump", "--changelog", "--yes"] + mocker.patch.object(sys, "argv", testargs) + # bump to 0.2.0custom + cli.main() + wait_for_tag() + create_file_and_commit("feat: another new file") + # bump to 0.3.0custom + cli.main() + wait_for_tag() + with open(changelog_path) as f: + out = f.read() + assert out.startswith("## 0.3.0custom (2021-06-11)") + assert "## v0.2.0 (2021-06-11)" not in out + assert "## 0.2.0 (2021-06-11)" not in out + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2021-06-11") +def test_changelog_only_tag_matching_tag_format_included_suffix_sep( + mocker: MockFixture, + changelog_path: Path, + config_path: Path, +): + with open(config_path, "a", encoding="utf-8") as f: + f.write('\ntag_format = "${version}-custom"\n') + create_file_and_commit("feat: new file") + git.tag("v0.2.0") + create_file_and_commit("feat: another new file") + git.tag("0.2.0") + git.tag("random0.2.0") + testargs = ["cz", "bump", "--changelog", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + wait_for_tag() + create_file_and_commit("feat: another new file") + cli.main() + wait_for_tag() + with open(changelog_path) as f: + out = f.read() + assert out.startswith("## 0.3.0-custom (2021-06-11)") + assert "## v0.2.0 (2021-06-11)" not in out + assert "## 0.2.0 (2021-06-11)" not in out + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_legacy_tags( + mocker: MockFixture, + changelog_path: Path, + config_path: Path, +): + with open(config_path, "a", encoding="utf-8") as f: + f.writelines( + [ + 'tag_format = "v${version}"\n', + "legacy_tag_formats = [\n", + ' "older-${version}",\n', + ' "oldest-${version}",\n', + "]\n", + ] + ) + create_file_and_commit("feat: new file") + git.tag("oldest-0.1.0") + create_file_and_commit("feat: new file") + git.tag("older-0.2.0") + create_file_and_commit("feat: another new file") + git.tag("v0.3.0") + create_file_and_commit("feat: another new file") + git.tag("not-0.3.1") + testargs = ["cz", "bump", "--changelog", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + out = open(changelog_path).read() + assert "## v0.3.0" in out + assert "## older-0.2.0" in out + assert "## oldest-0.1.0" in out + assert "## v0.3.0" in out + assert "## not-0.3.1" not in out + + +@pytest.mark.usefixtures("tmp_commitizen_project") +@pytest.mark.freeze_time("2024-11-18") +def test_changelog_incremental_change_tag_format( + mocker: MockFixture, + changelog_path: Path, + config_path: Path, + file_regression: FileRegressionFixture, +): + mocker.patch("commitizen.git.GitTag.date", "2024-11-18") + config = Path(config_path) + base_config = config.read_text() + config.write_text( + "\n".join( + ( + base_config, + 'tag_format = "older-${version}"', + ) + ) + ) + create_file_and_commit("feat: new file") + git.tag("older-0.1.0") + create_file_and_commit("feat: new file") + git.tag("older-0.2.0") + mocker.patch.object(sys, "argv", ["cz", "changelog"]) + cli.main() + + config.write_text( + "\n".join( + ( + base_config, + 'tag_format = "v${version}"', + 'legacy_tag_formats = ["older-${version}"]', + ) + ) + ) + create_file_and_commit("feat: another new file") + git.tag("v0.3.0") + mocker.patch.object(sys, "argv", ["cz", "changelog", "--incremental"]) + cli.main() + out = open(changelog_path).read() + assert "## v0.3.0" in out + assert "## older-0.2.0" in out + assert "## older-0.1.0" in out + file_regression.check(out, extension=".md") + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_changelog_ignored_tags( + mocker: MockFixture, + changelog_path: Path, + config_path: Path, + capsys: pytest.CaptureFixture, +): + with open(config_path, "a", encoding="utf-8") as f: + f.writelines( + [ + 'tag_format = "v${version}"\n', + "ignored_tag_formats = [\n", + ' "ignored",\n', + ' "ignore-${version}",\n', + "]\n", + ] + ) + create_file_and_commit("feat: new file") + git.tag("ignore-0.1.0") + create_file_and_commit("feat: new file") + git.tag("ignored") + create_file_and_commit("feat: another new file") + git.tag("v0.3.0") + create_file_and_commit("feat: another new file") + git.tag("not-ignored") + testargs = ["cz", "bump", "--changelog", "--yes"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + out = open(changelog_path).read() + _, err = capsys.readouterr() + assert "## ignore-0.1.0" not in out + assert "InvalidVersion ignore-0.1.0" not in err + assert "## ignored" not in out + assert "InvalidVersion ignored" not in err + assert "## not-ignored" not in out + assert "InvalidVersion not-ignored" in err + assert "## v0.3.0" in out + assert "InvalidVersion v0.3.0" not in err + + +def test_changelog_template_extra_quotes( + mocker: MockFixture, + tmp_commitizen_project: Path, + any_changelog_format: ChangelogFormat, +): + project_root = Path(tmp_commitizen_project) + changelog_tpl = project_root / any_changelog_format.template + changelog_tpl.write_text("{{first}} - {{second}} - {{third}}") + + create_file_and_commit("feat: new file") + + testargs = [ + "cz", + "changelog", + "-e", + "first=no-quote", + "-e", + "second='single quotes'", + "-e", + 'third="double quotes"', + ] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + changelog = project_root / any_changelog_format.default_changelog_file + assert changelog.read_text() == "no-quote - single quotes - double quotes" + + +@pytest.mark.parametrize( + "extra, expected", + ( + pytest.param("key=value=", "value=", id="2-equals"), + pytest.param("key==value", "=value", id="2-consecutives-equals"), + pytest.param("key==value==", "=value==", id="multiple-equals"), + ), +) +def test_changelog_template_extra_weird_but_valid( + mocker: MockFixture, + tmp_commitizen_project: Path, + any_changelog_format: ChangelogFormat, + extra: str, + expected, +): + project_root = Path(tmp_commitizen_project) + changelog_tpl = project_root / any_changelog_format.template + changelog_tpl.write_text("{{key}}") + + create_file_and_commit("feat: new file") + + testargs = ["cz", "changelog", "-e", extra] + mocker.patch.object(sys, "argv", testargs) + cli.main() + + changelog = project_root / any_changelog_format.default_changelog_file + assert changelog.read_text() == expected + + +@pytest.mark.parametrize("extra", ("no-equal", "", "=no-key")) +def test_changelog_template_extra_bad_format( + mocker: MockFixture, + tmp_commitizen_project: Path, + any_changelog_format: ChangelogFormat, + extra: str, +): + project_root = Path(tmp_commitizen_project) + changelog_tpl = project_root / any_changelog_format.template + changelog_tpl.write_text("") + + create_file_and_commit("feat: new file") + + testargs = ["cz", "changelog", "-e", extra] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(InvalidCommandArgumentError): + cli.main() + + +def test_export_changelog_template_from_default( + mocker: MockFixture, + tmp_commitizen_project: Path, + any_changelog_format: ChangelogFormat, +): + project_root = Path(tmp_commitizen_project) + target = project_root / "changelog.jinja" + src = Path(commitizen_init).parent / "templates" / any_changelog_format.template + + args = ["cz", "changelog", "--export-template", str(target)] + + mocker.patch.object(sys, "argv", args) + cli.main() + + assert target.exists() + assert target.read_text() == src.read_text() + + +def test_export_changelog_template_from_plugin( + mocker: MockFixture, + tmp_commitizen_project: Path, + mock_plugin: BaseCommitizen, + changelog_format: ChangelogFormat, + tmp_path: Path, +): + project_root = Path(tmp_commitizen_project) + target = project_root / "changelog.jinja" + src = tmp_path / changelog_format.template + tpl = "I am a custom template" + src.write_text(tpl) + mock_plugin.template_loader = FileSystemLoader(tmp_path) + + args = ["cz", "changelog", "--export-template", str(target)] + + mocker.patch.object(sys, "argv", args) + cli.main() + + assert target.exists() + assert target.read_text() == tpl + + +@skip_below_py_3_13 +def test_changelog_command_shows_description_when_use_help_option( + mocker: MockFixture, capsys, file_regression +): + testargs = ["cz", "changelog", "--help"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(SystemExit): + cli.main() + + out, _ = capsys.readouterr() + file_regression.check(out, extension=".txt") diff --git a/tests/commands/test_changelog_command/test_breaking_change_content_v1.md b/tests/commands/test_changelog_command/test_breaking_change_content_v1.md new file mode 100644 index 0000000..c480973 --- /dev/null +++ b/tests/commands/test_changelog_command/test_breaking_change_content_v1.md @@ -0,0 +1,10 @@ +## Unreleased + +### BREAKING CHANGE + +- migrate by renaming user to users + +### Feat + +- **users**: email pattern corrected + diff --git a/tests/commands/test_changelog_command/test_breaking_change_content_v1_beta.md b/tests/commands/test_changelog_command/test_breaking_change_content_v1_beta.md new file mode 100644 index 0000000..c480973 --- /dev/null +++ b/tests/commands/test_changelog_command/test_breaking_change_content_v1_beta.md @@ -0,0 +1,10 @@ +## Unreleased + +### BREAKING CHANGE + +- migrate by renaming user to users + +### Feat + +- **users**: email pattern corrected + diff --git a/tests/commands/test_changelog_command/test_breaking_change_content_v1_multiline.md b/tests/commands/test_changelog_command/test_breaking_change_content_v1_multiline.md new file mode 100644 index 0000000..7becef3 --- /dev/null +++ b/tests/commands/test_changelog_command/test_breaking_change_content_v1_multiline.md @@ -0,0 +1,11 @@ +## Unreleased + +### BREAKING CHANGE + +- migrate by renaming user to users. +and then connect the thingy with the other thingy + +### Feat + +- **users**: email pattern corrected + diff --git a/tests/commands/test_changelog_command/test_breaking_change_content_v1_with_exclamation_mark.md b/tests/commands/test_changelog_command/test_breaking_change_content_v1_with_exclamation_mark.md new file mode 100644 index 0000000..d12d780 --- /dev/null +++ b/tests/commands/test_changelog_command/test_breaking_change_content_v1_with_exclamation_mark.md @@ -0,0 +1,5 @@ +## Unreleased + + +- drop support for py36 + diff --git a/tests/commands/test_changelog_command/test_breaking_change_content_v1_with_exclamation_mark_feat.md b/tests/commands/test_changelog_command/test_breaking_change_content_v1_with_exclamation_mark_feat.md new file mode 100644 index 0000000..84c2368 --- /dev/null +++ b/tests/commands/test_changelog_command/test_breaking_change_content_v1_with_exclamation_mark_feat.md @@ -0,0 +1,6 @@ +## Unreleased + +### Feat + +- **pipeline**: some text with breaking change + diff --git a/tests/commands/test_changelog_command/test_changelog_command_shows_description_when_use_help_option.txt b/tests/commands/test_changelog_command/test_changelog_command_shows_description_when_use_help_option.txt new file mode 100644 index 0000000..461eb2e --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_command_shows_description_when_use_help_option.txt @@ -0,0 +1,39 @@ +usage: cz changelog [-h] [--dry-run] [--file-name FILE_NAME] + [--unreleased-version UNRELEASED_VERSION] [--incremental] + [--start-rev START_REV] [--merge-prerelease] + [--version-scheme {pep440,semver,semver2}] + [--export-template EXPORT_TEMPLATE] [--template TEMPLATE] + [--extra EXTRA] + [rev_range] + +generate changelog (note that it will overwrite existing file) + +positional arguments: + rev_range generates changelog for the given version (e.g: 1.5.3) + or version range (e.g: 1.5.3..1.7.9) + +options: + -h, --help show this help message and exit + --dry-run show changelog to stdout + --file-name FILE_NAME + file name of changelog (default: 'CHANGELOG.md') + --unreleased-version UNRELEASED_VERSION + set the value for the new version (use the tag value), + instead of using unreleased + --incremental generates changelog from last created version, useful + if the changelog has been manually modified + --start-rev START_REV + start rev of the changelog. If not set, it will + generate changelog from the start + --merge-prerelease collect all changes from prereleases into next non- + prerelease. If not set, it will include prereleases in + the changelog + --version-scheme {pep440,semver,semver2} + choose version scheme + --export-template EXPORT_TEMPLATE + Export the changelog template into this file instead + of rendering it + --template, -t TEMPLATE + changelog template file name (relative to the current + working directory) + --extra, -e EXTRA a changelog extra variable (in the form 'key=value') diff --git a/tests/commands/test_changelog_command/test_changelog_config_flag_increment.md b/tests/commands/test_changelog_command/test_changelog_config_flag_increment.md new file mode 100644 index 0000000..5bdace3 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_config_flag_increment.md @@ -0,0 +1,8 @@ + +note: this should be persisted using increment + +## Unreleased + +### Feat + +- add new output diff --git a/tests/commands/test_changelog_command/test_changelog_config_flag_merge_prerelease_alpha_.md b/tests/commands/test_changelog_command/test_changelog_config_flag_merge_prerelease_alpha_.md new file mode 100644 index 0000000..dca7824 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_config_flag_merge_prerelease_alpha_.md @@ -0,0 +1,13 @@ +## Unreleased + +### Feat + +- add more stuff +- add new output + +### Fix + +- mama gotta work +- output glitch + +## 1.0.0 (1970-01-01) diff --git a/tests/commands/test_changelog_command/test_changelog_config_flag_merge_prerelease_beta_.md b/tests/commands/test_changelog_command/test_changelog_config_flag_merge_prerelease_beta_.md new file mode 100644 index 0000000..dca7824 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_config_flag_merge_prerelease_beta_.md @@ -0,0 +1,13 @@ +## Unreleased + +### Feat + +- add more stuff +- add new output + +### Fix + +- mama gotta work +- output glitch + +## 1.0.0 (1970-01-01) diff --git a/tests/commands/test_changelog_command/test_changelog_config_flag_merge_prerelease_rc_.md b/tests/commands/test_changelog_command/test_changelog_config_flag_merge_prerelease_rc_.md new file mode 100644 index 0000000..dca7824 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_config_flag_merge_prerelease_rc_.md @@ -0,0 +1,13 @@ +## Unreleased + +### Feat + +- add more stuff +- add new output + +### Fix + +- mama gotta work +- output glitch + +## 1.0.0 (1970-01-01) diff --git a/tests/commands/test_changelog_command/test_changelog_config_start_rev_option.md b/tests/commands/test_changelog_command/test_changelog_config_start_rev_option.md new file mode 100644 index 0000000..d1baef2 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_config_start_rev_option.md @@ -0,0 +1,7 @@ +## Unreleased + +### Feat + +- after 0.2 +- after 0.2.0 + diff --git a/tests/commands/test_changelog_command/test_changelog_from_rev_first_version_from_arg.md b/tests/commands/test_changelog_command/test_changelog_from_rev_first_version_from_arg.md new file mode 100644 index 0000000..3519498 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_from_rev_first_version_from_arg.md @@ -0,0 +1,5 @@ +## 0.2.0 (2022-02-13) + +### Feat + +- new file diff --git a/tests/commands/test_changelog_command/test_changelog_from_rev_latest_version_dry_run.md b/tests/commands/test_changelog_command/test_changelog_from_rev_latest_version_dry_run.md new file mode 100644 index 0000000..e6531e6 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_from_rev_latest_version_dry_run.md @@ -0,0 +1,7 @@ +## 0.3.0 (2022-02-13) + +### Feat + +- another feature +- after 0.2.0 + diff --git a/tests/commands/test_changelog_command/test_changelog_from_rev_latest_version_from_arg.md b/tests/commands/test_changelog_command/test_changelog_from_rev_latest_version_from_arg.md new file mode 100644 index 0000000..9188064 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_from_rev_latest_version_from_arg.md @@ -0,0 +1,6 @@ +## 0.3.0 (2022-02-13) + +### Feat + +- another feature +- after 0.2.0 diff --git a/tests/commands/test_changelog_command/test_changelog_from_rev_version_range_from_arg.md b/tests/commands/test_changelog_command/test_changelog_from_rev_version_range_from_arg.md new file mode 100644 index 0000000..0c483c7 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_from_rev_version_range_from_arg.md @@ -0,0 +1,12 @@ +## 0.4.0 (2022-02-13) + +### Feat + +- getting ready for this + +## 0.3.0 (2022-02-13) + +### Feat + +- another feature +- after 0.2.0 diff --git a/tests/commands/test_changelog_command/test_changelog_from_rev_version_range_including_first_tag.md b/tests/commands/test_changelog_command/test_changelog_from_rev_version_range_including_first_tag.md new file mode 100644 index 0000000..44bffb3 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_from_rev_version_range_including_first_tag.md @@ -0,0 +1,12 @@ +## 0.3.0 (2022-02-13) + +### Feat + +- another feature +- after 0.2.0 + +## 0.2.0 (2022-02-13) + +### Feat + +- new file diff --git a/tests/commands/test_changelog_command/test_changelog_from_rev_version_range_with_legacy_tags.md b/tests/commands/test_changelog_command/test_changelog_from_rev_version_range_with_legacy_tags.md new file mode 100644 index 0000000..5d37333 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_from_rev_version_range_with_legacy_tags.md @@ -0,0 +1,17 @@ +## legacy-0.4.0 (2022-02-13) + +### Feat + +- new file + +## legacy-0.3.0 (2022-02-13) + +### Feat + +- new file + +## old-0.2.0 (2022-02-13) + +### Feat + +- new file diff --git a/tests/commands/test_changelog_command/test_changelog_from_rev_version_with_big_range_from_arg.md b/tests/commands/test_changelog_command/test_changelog_from_rev_version_with_big_range_from_arg.md new file mode 100644 index 0000000..376424d --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_from_rev_version_with_big_range_from_arg.md @@ -0,0 +1,24 @@ +## 0.5.0 (2022-02-13) + +### Feat + +- new shinny feature + +## 0.4.1 (2022-02-13) + +### Fix + +- small error + +## 0.4.0 (2022-02-13) + +### Feat + +- getting ready for this + +## 0.3.0 (2022-02-13) + +### Feat + +- another feature +- after 0.2.0 diff --git a/tests/commands/test_changelog_command/test_changelog_from_start_asciidoc_.adoc b/tests/commands/test_changelog_command/test_changelog_from_start_asciidoc_.adoc new file mode 100644 index 0000000..842e120 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_from_start_asciidoc_.adoc @@ -0,0 +1,9 @@ +== Unreleased + +=== Feat + +* new file + +=== Refactor + +* is in changelog diff --git a/tests/commands/test_changelog_command/test_changelog_from_start_markdown_.md b/tests/commands/test_changelog_command/test_changelog_from_start_markdown_.md new file mode 100644 index 0000000..bdc7866 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_from_start_markdown_.md @@ -0,0 +1,9 @@ +## Unreleased + +### Feat + +- new file + +### Refactor + +- is in changelog diff --git a/tests/commands/test_changelog_command/test_changelog_from_start_restructuredtext_.rst b/tests/commands/test_changelog_command/test_changelog_from_start_restructuredtext_.rst new file mode 100644 index 0000000..555f5bc --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_from_start_restructuredtext_.rst @@ -0,0 +1,12 @@ +Unreleased +========== + +Feat +---- + +- new file + +Refactor +-------- + +- is in changelog diff --git a/tests/commands/test_changelog_command/test_changelog_from_start_textile_.textile b/tests/commands/test_changelog_command/test_changelog_from_start_textile_.textile new file mode 100644 index 0000000..e71fb99 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_from_start_textile_.textile @@ -0,0 +1,9 @@ +h2. Unreleased + +h3. Feat + +- new file + +h3. Refactor + +- is in changelog diff --git a/tests/commands/test_changelog_command/test_changelog_from_version_zero_point_two.md b/tests/commands/test_changelog_command/test_changelog_from_version_zero_point_two.md new file mode 100644 index 0000000..d1baef2 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_from_version_zero_point_two.md @@ -0,0 +1,7 @@ +## Unreleased + +### Feat + +- after 0.2 +- after 0.2.0 + diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_angular_sample.md b/tests/commands/test_changelog_command/test_changelog_incremental_angular_sample.md new file mode 100644 index 0000000..83bfdfe --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_angular_sample.md @@ -0,0 +1,16 @@ +## Unreleased + +### Feat + +- add more stuff +- add new output + +### Fix + +- mama gotta work +- output glitch + +# [10.0.0-rc.3](https://github.com/angular/angular/compare/10.0.0-rc.2...10.0.0-rc.3) (2020-04-22) + +### Bug Fixes +* **common:** format day-periods that cross midnight ([#36611](https://github.com/angular/angular/issues/36611)) ([c6e5fc4](https://github.com/angular/angular/commit/c6e5fc4)), closes [#36566](https://github.com/angular/angular/issues/36566) diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_change_tag_format.md b/tests/commands/test_changelog_command/test_changelog_incremental_change_tag_format.md new file mode 100644 index 0000000..2f0cc29 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_change_tag_format.md @@ -0,0 +1,17 @@ +## v0.3.0 (2024-11-18) + +### Feat + +- another new file + +## older-0.2.0 (2024-11-18) + +### Feat + +- new file + +## older-0.1.0 (2024-11-18) + +### Feat + +- new file diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_keep_a_changelog_sample.md b/tests/commands/test_changelog_command/test_changelog_incremental_keep_a_changelog_sample.md new file mode 100644 index 0000000..56e2cf8 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_keep_a_changelog_sample.md @@ -0,0 +1,32 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Feat + +- add more stuff +- add new output + +### Fix + +- mama gotta work +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_keep_a_changelog_sample_with_annotated_tag.md b/tests/commands/test_changelog_command/test_changelog_incremental_keep_a_changelog_sample_with_annotated_tag.md new file mode 100644 index 0000000..56e2cf8 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_keep_a_changelog_sample_with_annotated_tag.md @@ -0,0 +1,32 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Feat + +- add more stuff +- add new output + +### Fix + +- mama gotta work +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_merge_prerelease_alpha_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_merge_prerelease_alpha_.md new file mode 100644 index 0000000..8e81f62 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_merge_prerelease_alpha_.md @@ -0,0 +1,37 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Feat + +- add more stuff + +### Fix + +- mama gotta work +- output glitch + +## 0.2.0a0 (2023-04-16) + +### Feat + +- add new output + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_merge_prerelease_beta_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_merge_prerelease_beta_.md new file mode 100644 index 0000000..65f14c0 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_merge_prerelease_beta_.md @@ -0,0 +1,37 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Feat + +- add more stuff + +### Fix + +- mama gotta work +- output glitch + +## 0.2.0b0 (2023-04-16) + +### Feat + +- add new output + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_merge_prerelease_rc_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_merge_prerelease_rc_.md new file mode 100644 index 0000000..0987e11 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_merge_prerelease_rc_.md @@ -0,0 +1,37 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Feat + +- add more stuff + +### Fix + +- mama gotta work +- output glitch + +## 0.2.0rc0 (2023-04-16) + +### Feat + +- add new output + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_alpha_alpha_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_alpha_alpha_.md new file mode 100644 index 0000000..cfaca36 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_alpha_alpha_.md @@ -0,0 +1,32 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.2.0a1 (2021-06-11) + +## 0.2.0a0 (2021-06-11) + +### Feat + +- add new output + +### Fix + +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_alpha_beta_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_alpha_beta_.md new file mode 100644 index 0000000..013e16f --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_alpha_beta_.md @@ -0,0 +1,32 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.2.0b0 (2021-06-11) + +## 0.2.0a0 (2021-06-11) + +### Feat + +- add new output + +### Fix + +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_alpha_rc_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_alpha_rc_.md new file mode 100644 index 0000000..25cc276 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_alpha_rc_.md @@ -0,0 +1,32 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.2.0rc0 (2021-06-11) + +## 0.2.0a0 (2021-06-11) + +### Feat + +- add new output + +### Fix + +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_beta_alpha_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_beta_alpha_.md new file mode 100644 index 0000000..ec9ab11 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_beta_alpha_.md @@ -0,0 +1,32 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.2.0b1 (2021-06-11) + +## 0.2.0b0 (2021-06-11) + +### Feat + +- add new output + +### Fix + +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_beta_beta_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_beta_beta_.md new file mode 100644 index 0000000..ec9ab11 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_beta_beta_.md @@ -0,0 +1,32 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.2.0b1 (2021-06-11) + +## 0.2.0b0 (2021-06-11) + +### Feat + +- add new output + +### Fix + +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_beta_rc_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_beta_rc_.md new file mode 100644 index 0000000..22b1e14 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_beta_rc_.md @@ -0,0 +1,32 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.2.0rc0 (2021-06-11) + +## 0.2.0b0 (2021-06-11) + +### Feat + +- add new output + +### Fix + +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_alpha_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_alpha_.md new file mode 100644 index 0000000..e1c20b7 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_alpha_.md @@ -0,0 +1,32 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.2.0rc1 (2021-06-11) + +## 0.2.0rc0 (2021-06-11) + +### Feat + +- add new output + +### Fix + +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_beta_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_beta_.md new file mode 100644 index 0000000..e1c20b7 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_beta_.md @@ -0,0 +1,32 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.2.0rc1 (2021-06-11) + +## 0.2.0rc0 (2021-06-11) + +### Feat + +- add new output + +### Fix + +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_rc_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_rc_.md new file mode 100644 index 0000000..e1c20b7 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_prerelease_version_to_prerelease_version_rc_rc_.md @@ -0,0 +1,32 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.2.0rc1 (2021-06-11) + +## 0.2.0rc0 (2021-06-11) + +### Feat + +- add new output + +### Fix + +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_alpha_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_alpha_.md new file mode 100644 index 0000000..9a598e6 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_alpha_.md @@ -0,0 +1,40 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Feat + +- add more stuff + +### Fix + +- mama gotta work + +## 0.2.0a0 (2021-06-11) + +### Feat + +- add new output + +### Fix + +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_beta_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_beta_.md new file mode 100644 index 0000000..1f8de4f --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_beta_.md @@ -0,0 +1,40 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Feat + +- add more stuff + +### Fix + +- mama gotta work + +## 0.2.0b0 (2021-06-11) + +### Feat + +- add new output + +### Fix + +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_rc_.md b/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_rc_.md new file mode 100644 index 0000000..3ba4eb2 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_incremental_with_release_candidate_version_rc_.md @@ -0,0 +1,40 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Feat + +- add more stuff + +### Fix + +- mama gotta work + +## 0.2.0rc0 (2021-06-11) + +### Feat + +- add new output + +### Fix + +- output glitch + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. + +### Changed +- Start using "changelog" over "change log" since it's the common usage. + +### Removed +- Section about "changelog" vs "CHANGELOG". + +## [0.3.0] - 2015-12-03 +### Added +- RU translation from [@aishek](https://github.com/aishek). diff --git a/tests/commands/test_changelog_command/test_changelog_is_persisted_using_incremental.md b/tests/commands/test_changelog_command/test_changelog_is_persisted_using_incremental.md new file mode 100644 index 0000000..721d11d --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_is_persisted_using_incremental.md @@ -0,0 +1,21 @@ +## Unreleased + +### Feat + +- add more stuff + +### Fix + +- mama gotta work + +## 0.2.0 (2022-08-14) + +### Feat + +- add new output + +### Fix + +- output glitch + +note: this should be persisted using increment diff --git a/tests/commands/test_changelog_command/test_changelog_multiple_incremental_do_not_add_new_lines.md b/tests/commands/test_changelog_command/test_changelog_multiple_incremental_do_not_add_new_lines.md new file mode 100644 index 0000000..85834c2 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_multiple_incremental_do_not_add_new_lines.md @@ -0,0 +1,11 @@ +## Unreleased + +### Feat + +- add more stuff +- add new output + +### Fix + +- no more explosions +- output glitch diff --git a/tests/commands/test_changelog_command/test_changelog_prerelease_rev_with_use_scheme_semver.md b/tests/commands/test_changelog_command/test_changelog_prerelease_rev_with_use_scheme_semver.md new file mode 100644 index 0000000..9a66210 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_prerelease_rev_with_use_scheme_semver.md @@ -0,0 +1,7 @@ +## 0.3.0-a0 (2022-02-13) + +### Feat + +- another feature +- after 0.2.0 + diff --git a/tests/commands/test_changelog_command/test_changelog_prerelease_rev_with_use_scheme_semver.second-prerelease.md b/tests/commands/test_changelog_command/test_changelog_prerelease_rev_with_use_scheme_semver.second-prerelease.md new file mode 100644 index 0000000..09fd10e --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_prerelease_rev_with_use_scheme_semver.second-prerelease.md @@ -0,0 +1,2 @@ +## 0.3.0-a1 (2022-02-13) + diff --git a/tests/commands/test_changelog_command/test_changelog_release_candidate_version_with_merge_prerelease_alpha_.md b/tests/commands/test_changelog_command/test_changelog_release_candidate_version_with_merge_prerelease_alpha_.md new file mode 100644 index 0000000..dca7824 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_release_candidate_version_with_merge_prerelease_alpha_.md @@ -0,0 +1,13 @@ +## Unreleased + +### Feat + +- add more stuff +- add new output + +### Fix + +- mama gotta work +- output glitch + +## 1.0.0 (1970-01-01) diff --git a/tests/commands/test_changelog_command/test_changelog_release_candidate_version_with_merge_prerelease_beta_.md b/tests/commands/test_changelog_command/test_changelog_release_candidate_version_with_merge_prerelease_beta_.md new file mode 100644 index 0000000..dca7824 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_release_candidate_version_with_merge_prerelease_beta_.md @@ -0,0 +1,13 @@ +## Unreleased + +### Feat + +- add more stuff +- add new output + +### Fix + +- mama gotta work +- output glitch + +## 1.0.0 (1970-01-01) diff --git a/tests/commands/test_changelog_command/test_changelog_release_candidate_version_with_merge_prerelease_rc_.md b/tests/commands/test_changelog_command/test_changelog_release_candidate_version_with_merge_prerelease_rc_.md new file mode 100644 index 0000000..dca7824 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_release_candidate_version_with_merge_prerelease_rc_.md @@ -0,0 +1,13 @@ +## Unreleased + +### Feat + +- add more stuff +- add new output + +### Fix + +- mama gotta work +- output glitch + +## 1.0.0 (1970-01-01) diff --git a/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_asciidoc_.adoc b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_asciidoc_.adoc new file mode 100644 index 0000000..2e789bc --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_asciidoc_.adoc @@ -0,0 +1,19 @@ +== Unreleased + +=== Feat + +* add more stuff + +=== Fix + +* mama gotta work + +== 0.2.0 (2022-08-14) + +=== Feat + +* add new output + +=== Fix + +* output glitch diff --git a/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_markdown_.md b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_markdown_.md new file mode 100644 index 0000000..8fca3c6 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_markdown_.md @@ -0,0 +1,19 @@ +## Unreleased + +### Feat + +- add more stuff + +### Fix + +- mama gotta work + +## 0.2.0 (2022-08-14) + +### Feat + +- add new output + +### Fix + +- output glitch diff --git a/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_restructuredtext_.rst b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_restructuredtext_.rst new file mode 100644 index 0000000..ca0077a --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_restructuredtext_.rst @@ -0,0 +1,25 @@ +Unreleased +========== + +Feat +---- + +- add more stuff + +Fix +--- + +- mama gotta work + +0.2.0 (2022-08-14) +================== + +Feat +---- + +- add new output + +Fix +--- + +- output glitch diff --git a/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_textile_.textile b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_textile_.textile new file mode 100644 index 0000000..07f2ba5 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_replacing_unreleased_using_incremental_textile_.textile @@ -0,0 +1,19 @@ +h2. Unreleased + +h3. Feat + +- add more stuff + +h3. Fix + +- mama gotta work + +h2. 0.2.0 (2022-08-14) + +h3. Feat + +- add new output + +h3. Fix + +- output glitch diff --git a/tests/commands/test_changelog_command/test_changelog_with_customized_change_type_order.md b/tests/commands/test_changelog_command/test_changelog_with_customized_change_type_order.md new file mode 100644 index 0000000..358908e --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_with_customized_change_type_order.md @@ -0,0 +1,20 @@ +## 0.4.0 (2022-02-13) + +### Perf + +- perf improvement + +### Feat + +- getting ready for this + +## 0.3.0 (2022-02-13) + +### Fix + +- fix bug + +### Feat + +- another feature +- after 0.2.0 diff --git a/tests/commands/test_changelog_command/test_changelog_with_different_cz.md b/tests/commands/test_changelog_command/test_changelog_with_different_cz.md new file mode 100644 index 0000000..e9d3062 --- /dev/null +++ b/tests/commands/test_changelog_command/test_changelog_with_different_cz.md @@ -0,0 +1,6 @@ +## Unreleased + + +- JRA-35 #time 1w 2d 4h 30m Total work logged +- JRA-34 #comment corrected indent issue + diff --git a/tests/commands/test_check_command.py b/tests/commands/test_check_command.py new file mode 100644 index 0000000..f1db446 --- /dev/null +++ b/tests/commands/test_check_command.py @@ -0,0 +1,454 @@ +from __future__ import annotations + +import sys +from io import StringIO + +import pytest +from pytest_mock import MockFixture + +from commitizen import cli, commands, git +from commitizen.exceptions import ( + InvalidCommandArgumentError, + InvalidCommitMessageError, + NoCommitsFoundError, +) +from tests.utils import create_file_and_commit, skip_below_py_3_13 + +COMMIT_LOG = [ + "refactor: A code change that neither fixes a bug nor adds a feature", + r"refactor(cz/connventional_commit): use \S to check scope", + "refactor(git): remove unnecessary dot between git range", + "bump: version 1.16.3 โ†’ 1.16.4", + ( + "Merge pull request #139 from Lee-W/fix-init-clean-config-file\n\n" + "Fix init clean config file" + ), + "ci(pyproject.toml): add configuration for coverage", + "fix(commands/init): fix clean up file when initialize commitizen config\n\n#138", + "refactor(defaults): split config files into long term support and deprecated ones", + "bump: version 1.16.2 โ†’ 1.16.3", + ( + "Merge pull request #136 from Lee-W/remove-redundant-readme\n\n" + "Remove redundant readme" + ), + "fix: replace README.rst with docs/README.md in config files", + ( + "refactor(docs): remove README.rst and use docs/README.md\n\n" + "By removing README.rst, we no longer need to maintain " + "two document with almost the same content\n" + "Github can read docs/README.md as README for the project." + ), + "docs(check): pin pre-commit to v1.16.2", + "docs(check): fix pre-commit setup", + "bump: version 1.16.1 โ†’ 1.16.2", + "Merge pull request #135 from Lee-W/fix-pre-commit-hook\n\nFix pre commit hook", + "docs(check): enforce cz check only when committing", + ( + 'Revert "fix(pre-commit): set pre-commit check stage to commit-msg"\n\n' + "This reverts commit afc70133e4a81344928561fbf3bb20738dfc8a0b." + ), + "feat!: add user stuff", + "fixup! test(commands): ignore fixup! prefix", + "fixup! test(commands): ignore squash! prefix", +] + + +def _build_fake_git_commits(commit_msgs: list[str]) -> list[git.GitCommit]: + return [git.GitCommit("test_rev", commit_msg) for commit_msg in commit_msgs] + + +def test_check_jira_fails(mocker: MockFixture): + testargs = ["cz", "-n", "cz_jira", "check", "--commit-msg-file", "some_file"] + mocker.patch.object(sys, "argv", testargs) + mocker.patch( + "commitizen.commands.check.open", + mocker.mock_open(read_data="random message for J-2 #fake_command blah"), + ) + with pytest.raises(InvalidCommitMessageError) as excinfo: + cli.main() + assert "commit validation: failed!" in str(excinfo.value) + + +def test_check_jira_command_after_issue_one_space(mocker: MockFixture, capsys): + testargs = ["cz", "-n", "cz_jira", "check", "--commit-msg-file", "some_file"] + mocker.patch.object(sys, "argv", testargs) + mocker.patch( + "commitizen.commands.check.open", + mocker.mock_open(read_data="JR-23 #command some arguments etc"), + ) + cli.main() + out, _ = capsys.readouterr() + assert "Commit validation: successful!" in out + + +def test_check_jira_command_after_issue_two_spaces(mocker: MockFixture, capsys): + testargs = ["cz", "-n", "cz_jira", "check", "--commit-msg-file", "some_file"] + mocker.patch.object(sys, "argv", testargs) + mocker.patch( + "commitizen.commands.check.open", + mocker.mock_open(read_data="JR-2 #command some arguments etc"), + ) + cli.main() + out, _ = capsys.readouterr() + assert "Commit validation: successful!" in out + + +def test_check_jira_text_between_issue_and_command(mocker: MockFixture, capsys): + testargs = ["cz", "-n", "cz_jira", "check", "--commit-msg-file", "some_file"] + mocker.patch.object(sys, "argv", testargs) + mocker.patch( + "commitizen.commands.check.open", + mocker.mock_open(read_data="JR-234 some text #command some arguments etc"), + ) + cli.main() + out, _ = capsys.readouterr() + assert "Commit validation: successful!" in out + + +def test_check_jira_multiple_commands(mocker: MockFixture, capsys): + testargs = ["cz", "-n", "cz_jira", "check", "--commit-msg-file", "some_file"] + mocker.patch.object(sys, "argv", testargs) + mocker.patch( + "commitizen.commands.check.open", + mocker.mock_open(read_data="JRA-23 some text #command1 args #command2 args"), + ) + cli.main() + out, _ = capsys.readouterr() + assert "Commit validation: successful!" in out + + +def test_check_conventional_commit_succeeds(mocker: MockFixture, capsys): + testargs = ["cz", "check", "--commit-msg-file", "some_file"] + mocker.patch.object(sys, "argv", testargs) + mocker.patch( + "commitizen.commands.check.open", + mocker.mock_open(read_data="fix(scope): some commit message"), + ) + cli.main() + out, _ = capsys.readouterr() + assert "Commit validation: successful!" in out + + +@pytest.mark.parametrize( + "commit_msg", + ( + "feat!(lang): removed polish language", + "no conventional commit", + ( + "ci: check commit message on merge\n" + "testing with more complex commit mes\n\n" + "age with error" + ), + ), +) +def test_check_no_conventional_commit(commit_msg, config, mocker: MockFixture, tmpdir): + with pytest.raises(InvalidCommitMessageError): + error_mock = mocker.patch("commitizen.out.error") + + tempfile = tmpdir.join("temp_commit_file") + tempfile.write(commit_msg) + + check_cmd = commands.Check( + config=config, arguments={"commit_msg_file": tempfile} + ) + check_cmd() + error_mock.assert_called_once() + + +@pytest.mark.parametrize( + "commit_msg", + ( + "feat(lang)!: removed polish language", + "feat(lang): added polish language", + "feat: add polish language", + "bump: 0.0.1 -> 1.0.0", + ), +) +def test_check_conventional_commit(commit_msg, config, mocker: MockFixture, tmpdir): + success_mock = mocker.patch("commitizen.out.success") + + tempfile = tmpdir.join("temp_commit_file") + tempfile.write(commit_msg) + + check_cmd = commands.Check(config=config, arguments={"commit_msg_file": tempfile}) + + check_cmd() + success_mock.assert_called_once() + + +def test_check_command_when_commit_file_not_found(config): + with pytest.raises(FileNotFoundError): + commands.Check(config=config, arguments={"commit_msg_file": "no_such_file"})() + + +def test_check_a_range_of_git_commits(config, mocker: MockFixture): + success_mock = mocker.patch("commitizen.out.success") + mocker.patch( + "commitizen.git.get_commits", return_value=_build_fake_git_commits(COMMIT_LOG) + ) + + check_cmd = commands.Check( + config=config, arguments={"rev_range": "HEAD~10..master"} + ) + + check_cmd() + success_mock.assert_called_once() + + +def test_check_a_range_of_git_commits_and_failed(config, mocker: MockFixture): + error_mock = mocker.patch("commitizen.out.error") + mocker.patch( + "commitizen.git.get_commits", + return_value=_build_fake_git_commits(["This commit does not follow rule"]), + ) + check_cmd = commands.Check( + config=config, arguments={"rev_range": "HEAD~10..master"} + ) + + with pytest.raises(InvalidCommitMessageError): + check_cmd() + error_mock.assert_called_once() + + +def test_check_command_with_invalid_argument(config): + with pytest.raises(InvalidCommandArgumentError) as excinfo: + commands.Check( + config=config, + arguments={"commit_msg_file": "some_file", "rev_range": "HEAD~10..master"}, + ) + assert ( + "Only one of --rev-range, --message, and --commit-msg-file is permitted by check command!" + in str(excinfo.value) + ) + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_check_command_with_empty_range(config, mocker: MockFixture): + # must initialize git with a commit + create_file_and_commit("feat: initial") + + check_cmd = commands.Check(config=config, arguments={"rev_range": "master..master"}) + with pytest.raises(NoCommitsFoundError) as excinfo: + check_cmd() + + assert "No commit found with range: 'master..master'" in str(excinfo) + + +def test_check_a_range_of_failed_git_commits(config, mocker: MockFixture): + ill_formated_commits_msgs = [ + "First commit does not follow rule", + "Second commit does not follow rule", + ("Third commit does not follow rule\nIll-formatted commit with body"), + ] + mocker.patch( + "commitizen.git.get_commits", + return_value=_build_fake_git_commits(ill_formated_commits_msgs), + ) + check_cmd = commands.Check( + config=config, arguments={"rev_range": "HEAD~10..master"} + ) + + with pytest.raises(InvalidCommitMessageError) as excinfo: + check_cmd() + assert all([msg in str(excinfo.value) for msg in ill_formated_commits_msgs]) + + +def test_check_command_with_valid_message(config, mocker: MockFixture): + success_mock = mocker.patch("commitizen.out.success") + check_cmd = commands.Check( + config=config, arguments={"message": "fix(scope): some commit message"} + ) + + check_cmd() + success_mock.assert_called_once() + + +def test_check_command_with_invalid_message(config, mocker: MockFixture): + error_mock = mocker.patch("commitizen.out.error") + check_cmd = commands.Check(config=config, arguments={"message": "bad commit"}) + + with pytest.raises(InvalidCommitMessageError): + check_cmd() + error_mock.assert_called_once() + + +def test_check_command_with_empty_message(config, mocker: MockFixture): + error_mock = mocker.patch("commitizen.out.error") + check_cmd = commands.Check(config=config, arguments={"message": ""}) + + with pytest.raises(InvalidCommitMessageError): + check_cmd() + error_mock.assert_called_once() + + +def test_check_command_with_allow_abort_arg(config, mocker: MockFixture): + success_mock = mocker.patch("commitizen.out.success") + check_cmd = commands.Check( + config=config, arguments={"message": "", "allow_abort": True} + ) + + check_cmd() + success_mock.assert_called_once() + + +def test_check_command_with_allow_abort_config(config, mocker: MockFixture): + success_mock = mocker.patch("commitizen.out.success") + config.settings["allow_abort"] = True + check_cmd = commands.Check(config=config, arguments={"message": ""}) + + check_cmd() + success_mock.assert_called_once() + + +def test_check_command_override_allow_abort_config(config, mocker: MockFixture): + error_mock = mocker.patch("commitizen.out.error") + config.settings["allow_abort"] = True + check_cmd = commands.Check( + config=config, arguments={"message": "", "allow_abort": False} + ) + + with pytest.raises(InvalidCommitMessageError): + check_cmd() + error_mock.assert_called_once() + + +def test_check_command_with_allowed_prefixes_arg(config, mocker: MockFixture): + success_mock = mocker.patch("commitizen.out.success") + check_cmd = commands.Check( + config=config, + arguments={"message": "custom! test", "allowed_prefixes": ["custom!"]}, + ) + + check_cmd() + success_mock.assert_called_once() + + +def test_check_command_with_allowed_prefixes_config(config, mocker: MockFixture): + success_mock = mocker.patch("commitizen.out.success") + config.settings["allowed_prefixes"] = ["custom!"] + check_cmd = commands.Check(config=config, arguments={"message": "custom! test"}) + + check_cmd() + success_mock.assert_called_once() + + +def test_check_command_override_allowed_prefixes_config(config, mocker: MockFixture): + error_mock = mocker.patch("commitizen.out.error") + config.settings["allow_abort"] = ["fixup!"] + check_cmd = commands.Check( + config=config, + arguments={"message": "fixup! test", "allowed_prefixes": ["custom!"]}, + ) + + with pytest.raises(InvalidCommitMessageError): + check_cmd() + error_mock.assert_called_once() + + +def test_check_command_with_pipe_message(mocker: MockFixture, capsys): + testargs = ["cz", "check"] + mocker.patch.object(sys, "argv", testargs) + mocker.patch("sys.stdin", StringIO("fix(scope): some commit message")) + + cli.main() + out, _ = capsys.readouterr() + assert "Commit validation: successful!" in out + + +def test_check_command_with_pipe_message_and_failed(mocker: MockFixture): + testargs = ["cz", "check"] + mocker.patch.object(sys, "argv", testargs) + mocker.patch("sys.stdin", StringIO("bad commit message")) + + with pytest.raises(InvalidCommitMessageError) as excinfo: + cli.main() + assert "commit validation: failed!" in str(excinfo.value) + + +def test_check_command_with_comment_in_messege_file(mocker: MockFixture, capsys): + testargs = ["cz", "check", "--commit-msg-file", "some_file"] + mocker.patch.object(sys, "argv", testargs) + mocker.patch( + "commitizen.commands.check.open", + mocker.mock_open( + read_data="# <type>: (If applied, this commit will...) <subject>\n" + "# |<---- Try to Limit to a Max of 50 char ---->|\n" + "ci: add commitizen pre-commit hook\n" + "\n" + "# Explain why this change is being made\n" + "# |<---- Try To Limit Each Line to a Max Of 72 Char ---->|\n" + "This pre-commit hook will check our commits automatically." + ), + ) + cli.main() + out, _ = capsys.readouterr() + assert "Commit validation: successful!" in out + + +def test_check_conventional_commit_succeed_with_git_diff(mocker, capsys): + commit_msg = ( + "feat: This is a test commit\n" + "# Please enter the commit message for your changes. Lines starting\n" + "# with '#' will be ignored, and an empty message aborts the commit.\n" + "#\n" + "# On branch ...\n" + "# Changes to be committed:\n" + "# modified: ...\n" + "#\n" + "# ------------------------ >8 ------------------------\n" + "# Do not modify or remove the line above.\n" + "# Everything below it will be ignored.\n" + "diff --git a/... b/...\n" + "index f1234c..1c5678 1234\n" + "--- a/...\n" + "+++ b/...\n" + "@@ -92,3 +92,4 @@ class Command(BaseCommand):\n" + '+ "this is a test"\n' + ) + testargs = ["cz", "check", "--commit-msg-file", "some_file"] + mocker.patch.object(sys, "argv", testargs) + mocker.patch( + "commitizen.commands.check.open", + mocker.mock_open(read_data=commit_msg), + ) + cli.main() + out, _ = capsys.readouterr() + assert "Commit validation: successful!" in out + + +@skip_below_py_3_13 +def test_check_command_shows_description_when_use_help_option( + mocker: MockFixture, capsys, file_regression +): + testargs = ["cz", "check", "--help"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(SystemExit): + cli.main() + + out, _ = capsys.readouterr() + file_regression.check(out, extension=".txt") + + +def test_check_command_with_message_length_limit(config, mocker: MockFixture): + success_mock = mocker.patch("commitizen.out.success") + message = "fix(scope): some commit message" + check_cmd = commands.Check( + config=config, + arguments={"message": message, "message_length_limit": len(message) + 1}, + ) + + check_cmd() + success_mock.assert_called_once() + + +def test_check_command_with_message_length_limit_exceeded(config, mocker: MockFixture): + error_mock = mocker.patch("commitizen.out.error") + message = "fix(scope): some commit message" + check_cmd = commands.Check( + config=config, + arguments={"message": message, "message_length_limit": len(message) - 1}, + ) + + with pytest.raises(InvalidCommitMessageError): + check_cmd() + error_mock.assert_called_once() diff --git a/tests/commands/test_check_command/test_check_command_shows_description_when_use_help_option.txt b/tests/commands/test_check_command/test_check_command_shows_description_when_use_help_option.txt new file mode 100644 index 0000000..85f42f6 --- /dev/null +++ b/tests/commands/test_check_command/test_check_command_shows_description_when_use_help_option.txt @@ -0,0 +1,25 @@ +usage: cz check [-h] [--commit-msg-file COMMIT_MSG_FILE | + --rev-range REV_RANGE | -m MESSAGE] [--allow-abort] + [--allowed-prefixes [ALLOWED_PREFIXES ...]] + [-l MESSAGE_LENGTH_LIMIT] + +validates that a commit message matches the commitizen schema + +options: + -h, --help show this help message and exit + --commit-msg-file COMMIT_MSG_FILE + ask for the name of the temporal file that contains + the commit message. Using it in a git hook script: + MSG_FILE=$1 + --rev-range REV_RANGE + a range of git rev to check. e.g, master..HEAD + -m, --message MESSAGE + commit message that needs to be checked + --allow-abort allow empty commit messages, which typically abort a + commit + --allowed-prefixes [ALLOWED_PREFIXES ...] + allowed commit message prefixes. If the message starts + by one of these prefixes, the message won't be checked + against the regex + -l, --message-length-limit MESSAGE_LENGTH_LIMIT + length limit of the commit message; 0 for no limit diff --git a/tests/commands/test_commit_command.py b/tests/commands/test_commit_command.py new file mode 100644 index 0000000..55751f6 --- /dev/null +++ b/tests/commands/test_commit_command.py @@ -0,0 +1,527 @@ +import os +import sys +from unittest.mock import ANY + +import pytest +from pytest_mock import MockFixture + +from commitizen import cli, cmd, commands +from commitizen.cz.exceptions import CzException +from commitizen.cz.utils import get_backup_file_path +from commitizen.exceptions import ( + CommitError, + CommitMessageLengthExceededError, + CustomError, + DryRunExit, + NoAnswersError, + NoCommitBackupError, + NotAGitProjectError, + NotAllowed, + NothingToCommitError, +) +from tests.utils import skip_below_py_3_13 + + +@pytest.fixture +def staging_is_clean(mocker: MockFixture, tmp_git_project): + is_staging_clean_mock = mocker.patch("commitizen.git.is_staging_clean") + is_staging_clean_mock.return_value = False + return tmp_git_project + + +@pytest.fixture +def backup_file(tmp_git_project): + with open(get_backup_file_path(), "w") as backup_file: + backup_file.write("backup commit") + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + commands.Commit(config, {})() + success_mock.assert_called_once() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_backup_on_failure(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "closes #21", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("", "error", b"", b"", 9) + error_mock = mocker.patch("commitizen.out.error") + + with pytest.raises(CommitError): + commit_cmd = commands.Commit(config, {}) + temp_file = commit_cmd.temp_file + commit_cmd() + + prompt_mock.assert_called_once() + error_mock.assert_called_once() + assert os.path.isfile(temp_file) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_retry_fails_no_backup(config, mocker: MockFixture): + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + + with pytest.raises(NoCommitBackupError) as excinfo: + commands.Commit(config, {"retry": True})() + + assert NoCommitBackupError.message in str(excinfo.value) + + +@pytest.mark.usefixtures("staging_is_clean", "backup_file") +def test_commit_retry_works(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + commit_cmd = commands.Commit(config, {"retry": True}) + temp_file = commit_cmd.temp_file + commit_cmd() + + commit_mock.assert_called_with("backup commit", args="") + prompt_mock.assert_not_called() + success_mock.assert_called_once() + assert not os.path.isfile(temp_file) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_retry_after_failure_no_backup(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "closes #21", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + config.settings["retry_after_failure"] = True + commands.Commit(config, {})() + + commit_mock.assert_called_with("feat: user created\n\ncloses #21", args="") + prompt_mock.assert_called_once() + success_mock.assert_called_once() + + +@pytest.mark.usefixtures("staging_is_clean", "backup_file") +def test_commit_retry_after_failure_works(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + config.settings["retry_after_failure"] = True + commit_cmd = commands.Commit(config, {}) + temp_file = commit_cmd.temp_file + commit_cmd() + + commit_mock.assert_called_with("backup commit", args="") + prompt_mock.assert_not_called() + success_mock.assert_called_once() + assert not os.path.isfile(temp_file) + + +@pytest.mark.usefixtures("staging_is_clean", "backup_file") +def test_commit_retry_after_failure_with_no_retry_works(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "closes #21", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + config.settings["retry_after_failure"] = True + commit_cmd = commands.Commit(config, {"no_retry": True}) + temp_file = commit_cmd.temp_file + commit_cmd() + + commit_mock.assert_called_with("feat: user created\n\ncloses #21", args="") + prompt_mock.assert_called_once() + success_mock.assert_called_once() + assert not os.path.isfile(temp_file) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_dry_run_option(config, mocker: MockFixture): + prompt_mock = mocker = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "closes #57", + "footer": "", + } + + with pytest.raises(DryRunExit): + commit_cmd = commands.Commit(config, {"dry_run": True}) + commit_cmd() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_write_message_to_file_option( + config, tmp_path, mocker: MockFixture +): + tmp_file = tmp_path / "message" + + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + commands.Commit(config, {"write_message_to_file": tmp_file})() + success_mock.assert_called_once() + assert tmp_file.exists() + assert tmp_file.read_text() == "feat: user created" + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_invalid_write_message_to_file_option( + config, tmp_path, mocker: MockFixture +): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + } + + with pytest.raises(NotAllowed): + commit_cmd = commands.Commit(config, {"write_message_to_file": tmp_path}) + commit_cmd() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_signoff_option(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + commands.Commit(config, {"signoff": True})() + + commit_mock.assert_called_once_with(ANY, args="-s") + success_mock.assert_called_once() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_always_signoff_enabled(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + config.settings["always_signoff"] = True + commands.Commit(config, {})() + + commit_mock.assert_called_once_with(ANY, args="-s") + success_mock.assert_called_once() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_gpgsign_and_always_signoff_enabled( + config, mocker: MockFixture +): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + config.settings["always_signoff"] = True + commands.Commit(config, {"extra_cli_args": "-S"})() + + commit_mock.assert_called_once_with(ANY, args="-S -s") + success_mock.assert_called_once() + + +@pytest.mark.usefixtures("tmp_git_project") +def test_commit_when_nothing_to_commit(config, mocker: MockFixture): + is_staging_clean_mock = mocker.patch("commitizen.git.is_staging_clean") + is_staging_clean_mock.return_value = True + + with pytest.raises(NothingToCommitError) as excinfo: + commit_cmd = commands.Commit(config, {}) + commit_cmd() + + assert "No files added to staging!" in str(excinfo.value) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_with_allow_empty(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "closes #21", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + commands.Commit(config, {"extra_cli_args": "--allow-empty"})() + + commit_mock.assert_called_with( + "feat: user created\n\ncloses #21", args="--allow-empty" + ) + success_mock.assert_called_once() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_with_signoff_and_allow_empty(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "closes #21", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + config.settings["always_signoff"] = True + commands.Commit(config, {"extra_cli_args": "--allow-empty"})() + + commit_mock.assert_called_with( + "feat: user created\n\ncloses #21", args="--allow-empty -s" + ) + success_mock.assert_called_once() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_when_customized_expected_raised(config, mocker: MockFixture, capsys): + _err = ValueError() + _err.__context__ = CzException("This is the root custom err") + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.side_effect = _err + + with pytest.raises(CustomError) as excinfo: + commit_cmd = commands.Commit(config, {}) + commit_cmd() + + # Assert only the content in the formatted text + assert "This is the root custom err" in str(excinfo.value) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_when_non_customized_expected_raised( + config, mocker: MockFixture, capsys +): + _err = ValueError() + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.side_effect = _err + + with pytest.raises(ValueError): + commit_cmd = commands.Commit(config, {}) + commit_cmd() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_when_no_user_answer(config, mocker: MockFixture, capsys): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = None + + with pytest.raises(NoAnswersError): + commit_cmd = commands.Commit(config, {}) + commit_cmd() + + +def test_commit_in_non_git_project(tmpdir, config): + with tmpdir.as_cwd(): + with pytest.raises(NotAGitProjectError): + commands.Commit(config, {}) + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_all_option(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + add_mock = mocker.patch("commitizen.git.add") + commands.Commit(config, {"all": True})() + add_mock.assert_called() + success_mock.assert_called_once() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_extra_args(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prompt_mock.return_value = { + "prefix": "feat", + "subject": "user created", + "scope": "", + "is_breaking_change": False, + "body": "", + "footer": "", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + commands.Commit(config, {"extra_cli_args": "-- -extra-args1 -extra-arg2"})() + commit_mock.assert_called_once_with(ANY, args="-- -extra-args1 -extra-arg2") + success_mock.assert_called_once() + + +@pytest.mark.usefixtures("staging_is_clean") +def test_commit_command_with_message_length_limit(config, mocker: MockFixture): + prompt_mock = mocker.patch("questionary.prompt") + prefix = "feat" + subject = "random subject" + message_length = len(prefix) + len(": ") + len(subject) + prompt_mock.return_value = { + "prefix": prefix, + "subject": subject, + "scope": "", + "is_breaking_change": False, + "body": "random body", + "footer": "random footer", + } + + commit_mock = mocker.patch("commitizen.git.commit") + commit_mock.return_value = cmd.Command("success", "", b"", b"", 0) + success_mock = mocker.patch("commitizen.out.success") + + commands.Commit(config, {"message_length_limit": message_length})() + success_mock.assert_called_once() + + with pytest.raises(CommitMessageLengthExceededError): + commands.Commit(config, {"message_length_limit": message_length - 1})() + + +@pytest.mark.usefixtures("staging_is_clean") +@pytest.mark.parametrize("editor", ["vim", None]) +def test_manual_edit(editor, config, mocker: MockFixture, tmp_path): + mocker.patch("commitizen.git.get_core_editor", return_value=editor) + subprocess_mock = mocker.patch("subprocess.call") + + mocker.patch("shutil.which", return_value=editor) + + test_message = "Initial commit message" + temp_file = tmp_path / "temp_commit_message" + temp_file.write_text(test_message) + + mock_temp_file = mocker.patch("tempfile.NamedTemporaryFile") + mock_temp_file.return_value.__enter__.return_value.name = str(temp_file) + + commit_cmd = commands.Commit(config, {"edit": True}) + + if editor is None: + with pytest.raises(RuntimeError): + commit_cmd.manual_edit(test_message) + else: + edited_message = commit_cmd.manual_edit(test_message) + + subprocess_mock.assert_called_once_with(["vim", str(temp_file)]) + + assert edited_message == test_message.strip() + + temp_file.unlink() + + +@skip_below_py_3_13 +def test_commit_command_shows_description_when_use_help_option( + mocker: MockFixture, capsys, file_regression +): + testargs = ["cz", "commit", "--help"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(SystemExit): + cli.main() + + out, _ = capsys.readouterr() + file_regression.check(out, extension=".txt") diff --git a/tests/commands/test_commit_command/test_commit_command_shows_description_when_use_help_option.txt b/tests/commands/test_commit_command/test_commit_command_shows_description_when_use_help_option.txt new file mode 100644 index 0000000..dd1f53f --- /dev/null +++ b/tests/commands/test_commit_command/test_commit_command_shows_description_when_use_help_option.txt @@ -0,0 +1,22 @@ +usage: cz commit [-h] [--retry] [--no-retry] [--dry-run] + [--write-message-to-file FILE_PATH] [-s] [-a] [-e] + [-l MESSAGE_LENGTH_LIMIT] [--] + +create new commit + +options: + -h, --help show this help message and exit + --retry retry last commit + --no-retry skip retry if retry_after_failure is set to true + --dry-run show output to stdout, no commit, no modified files + --write-message-to-file FILE_PATH + write message to file before committing (can be + combined with --dry-run) + -s, --signoff sign off the commit + -a, --all Tell the command to automatically stage files that + have been modified and deleted, but new files you have + not told Git about are not affected. + -e, --edit edit the commit message before committing + -l, --message-length-limit MESSAGE_LENGTH_LIMIT + length limit of the commit message; 0 for no limit + -- Positional arguments separator (recommended) diff --git a/tests/commands/test_example_command.py b/tests/commands/test_example_command.py new file mode 100644 index 0000000..0521679 --- /dev/null +++ b/tests/commands/test_example_command.py @@ -0,0 +1,26 @@ +import sys + +import pytest +from pytest_mock import MockerFixture + +from commitizen import cli, commands +from tests.utils import skip_below_py_3_10 + + +def test_example(config, mocker: MockerFixture): + write_mock = mocker.patch("commitizen.out.write") + commands.Example(config)() + write_mock.assert_called_once() + + +@skip_below_py_3_10 +def test_example_command_shows_description_when_use_help_option( + mocker: MockerFixture, capsys, file_regression +): + testargs = ["cz", "example", "--help"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(SystemExit): + cli.main() + + out, _ = capsys.readouterr() + file_regression.check(out, extension=".txt") diff --git a/tests/commands/test_example_command/test_example_command_shows_description_when_use_help_option.txt b/tests/commands/test_example_command/test_example_command_shows_description_when_use_help_option.txt new file mode 100644 index 0000000..b9bf7f8 --- /dev/null +++ b/tests/commands/test_example_command/test_example_command_shows_description_when_use_help_option.txt @@ -0,0 +1,6 @@ +usage: cz example [-h] + +show commit example + +options: + -h, --help show this help message and exit diff --git a/tests/commands/test_info_command.py b/tests/commands/test_info_command.py new file mode 100644 index 0000000..2bd1553 --- /dev/null +++ b/tests/commands/test_info_command.py @@ -0,0 +1,26 @@ +import sys + +import pytest +from pytest_mock import MockerFixture + +from commitizen import cli, commands +from tests.utils import skip_below_py_3_10 + + +def test_info(config, mocker: MockerFixture): + write_mock = mocker.patch("commitizen.out.write") + commands.Info(config)() + write_mock.assert_called_once() + + +@skip_below_py_3_10 +def test_info_command_shows_description_when_use_help_option( + mocker: MockerFixture, capsys, file_regression +): + testargs = ["cz", "info", "--help"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(SystemExit): + cli.main() + + out, _ = capsys.readouterr() + file_regression.check(out, extension=".txt") diff --git a/tests/commands/test_info_command/test_info_command_shows_description_when_use_help_option.txt b/tests/commands/test_info_command/test_info_command_shows_description_when_use_help_option.txt new file mode 100644 index 0000000..99b1ba8 --- /dev/null +++ b/tests/commands/test_info_command/test_info_command_shows_description_when_use_help_option.txt @@ -0,0 +1,6 @@ +usage: cz info [-h] + +show information about the cz + +options: + -h, --help show this help message and exit diff --git a/tests/commands/test_init_command.py b/tests/commands/test_init_command.py new file mode 100644 index 0000000..ea18e89 --- /dev/null +++ b/tests/commands/test_init_command.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import json +import os +import sys +from typing import Any + +import pytest +import yaml +from pytest_mock import MockFixture + +from commitizen import cli, commands +from commitizen.__version__ import __version__ +from commitizen.exceptions import InitFailedError, NoAnswersError +from tests.utils import skip_below_py_3_10 + + +class FakeQuestion: + def __init__(self, expected_return): + self.expected_return = expected_return + + def ask(self): + return self.expected_return + + def unsafe_ask(self): + return self.expected_return + + +pre_commit_config_filename = ".pre-commit-config.yaml" +cz_hook_config = { + "repo": "https://github.com/commitizen-tools/commitizen", + "rev": f"v{__version__}", + "hooks": [ + {"id": "commitizen"}, + {"id": "commitizen-branch", "stages": ["push"]}, + ], +} + +expected_config = ( + "[tool.commitizen]\n" + 'name = "cz_conventional_commits"\n' + 'tag_format = "$version"\n' + 'version_scheme = "semver"\n' + 'version = "0.0.1"\n' + "update_changelog_on_bump = true\n" + "major_version_zero = true\n" +) + +EXPECTED_DICT_CONFIG = { + "commitizen": { + "name": "cz_conventional_commits", + "tag_format": "$version", + "version_scheme": "semver", + "version": "0.0.1", + "update_changelog_on_bump": True, + "major_version_zero": True, + } +} + + +def test_init_without_setup_pre_commit_hook(tmpdir, mocker: MockFixture, config): + mocker.patch( + "questionary.select", + side_effect=[ + FakeQuestion("pyproject.toml"), + FakeQuestion("cz_conventional_commits"), + FakeQuestion("commitizen"), + FakeQuestion("semver"), + ], + ) + mocker.patch("questionary.confirm", return_value=FakeQuestion(True)) + mocker.patch("questionary.text", return_value=FakeQuestion("$version")) + # Return None to skip hook installation + mocker.patch("questionary.checkbox", return_value=FakeQuestion(None)) + + with tmpdir.as_cwd(): + commands.Init(config)() + + with open("pyproject.toml", encoding="utf-8") as toml_file: + config_data = toml_file.read() + assert config_data == expected_config + + assert not os.path.isfile(pre_commit_config_filename) + + +def test_init_when_config_already_exists(config, capsys): + # Set config path + path = os.sep.join(["tests", "pyproject.toml"]) + config.add_path(path) + + commands.Init(config)() + captured = capsys.readouterr() + assert captured.out == f"Config file {path} already exists\n" + + +def test_init_without_choosing_tag(config, mocker: MockFixture, tmpdir): + mocker.patch( + "commitizen.commands.init.get_tag_names", return_value=["0.0.2", "0.0.1"] + ) + mocker.patch("commitizen.commands.init.get_latest_tag_name", return_value="0.0.2") + mocker.patch( + "questionary.select", + side_effect=[ + FakeQuestion("pyproject.toml"), + FakeQuestion("cz_conventional_commits"), + FakeQuestion("commitizen"), + FakeQuestion(""), + ], + ) + mocker.patch("questionary.confirm", return_value=FakeQuestion(False)) + mocker.patch("questionary.text", return_value=FakeQuestion("y")) + + with tmpdir.as_cwd(): + with pytest.raises(NoAnswersError): + commands.Init(config)() + + +def test_executed_pre_commit_command(config): + init = commands.Init(config) + expected_cmd = "pre-commit install --hook-type commit-msg --hook-type pre-push" + assert init._gen_pre_commit_cmd(["commit-msg", "pre-push"]) == expected_cmd + + +@pytest.fixture(scope="function") +def pre_commit_installed(mocker: MockFixture): + # Assume the `pre-commit` is installed + mocker.patch( + "commitizen.commands.init.ProjectInfo.is_pre_commit_installed", + return_value=True, + ) + # And installation success (i.e. no exception raised) + mocker.patch( + "commitizen.commands.init.Init._exec_install_pre_commit_hook", + return_value=None, + ) + + +@pytest.fixture(scope="function", params=["pyproject.toml", ".cz.json", ".cz.yaml"]) +def default_choice(request, mocker: MockFixture): + mocker.patch( + "questionary.select", + side_effect=[ + FakeQuestion(request.param), + FakeQuestion("cz_conventional_commits"), + FakeQuestion("commitizen"), + FakeQuestion("semver"), + ], + ) + mocker.patch("questionary.confirm", return_value=FakeQuestion(True)) + mocker.patch("questionary.text", return_value=FakeQuestion("$version")) + mocker.patch( + "questionary.checkbox", + return_value=FakeQuestion(["commit-msg", "pre-push"]), + ) + yield request.param + + +def check_cz_config(config: str): + """ + Check the content of commitizen config is as expected + + Args: + config: The config path + """ + with open(config) as file: + if "json" in config: + assert json.load(file) == EXPECTED_DICT_CONFIG + elif "yaml" in config: + assert yaml.load(file, Loader=yaml.FullLoader) == EXPECTED_DICT_CONFIG + else: + config_data = file.read() + assert config_data == expected_config + + +def check_pre_commit_config(expected: list[dict[str, Any]]): + """ + Check the content of pre-commit config is as expected + """ + with open(pre_commit_config_filename) as pre_commit_file: + pre_commit_config_data = yaml.safe_load(pre_commit_file.read()) + assert pre_commit_config_data == {"repos": expected} + + +@pytest.mark.usefixtures("pre_commit_installed") +class TestPreCommitCases: + def test_no_existing_pre_commit_conifg(_, default_choice, tmpdir, config): + with tmpdir.as_cwd(): + commands.Init(config)() + check_cz_config(default_choice) + check_pre_commit_config([cz_hook_config]) + + def test_empty_pre_commit_config(_, default_choice, tmpdir, config): + with tmpdir.as_cwd(): + p = tmpdir.join(pre_commit_config_filename) + p.write("") + + commands.Init(config)() + check_cz_config(default_choice) + check_pre_commit_config([cz_hook_config]) + + def test_pre_commit_config_without_cz_hook(_, default_choice, tmpdir, config): + existing_hook_config = { + "repo": "https://github.com/pre-commit/pre-commit-hooks", + "rev": "v1.2.3", + "hooks": [{"id", "trailing-whitespace"}], + } + + with tmpdir.as_cwd(): + p = tmpdir.join(pre_commit_config_filename) + p.write(yaml.safe_dump({"repos": [existing_hook_config]})) + + commands.Init(config)() + check_cz_config(default_choice) + check_pre_commit_config([existing_hook_config, cz_hook_config]) + + def test_cz_hook_exists_in_pre_commit_config(_, default_choice, tmpdir, config): + with tmpdir.as_cwd(): + p = tmpdir.join(pre_commit_config_filename) + p.write(yaml.safe_dump({"repos": [cz_hook_config]})) + + commands.Init(config)() + check_cz_config(default_choice) + # check that config is not duplicated + check_pre_commit_config([cz_hook_config]) + + +class TestNoPreCommitInstalled: + def test_pre_commit_not_installed( + _, mocker: MockFixture, config, default_choice, tmpdir + ): + # Assume `pre-commit` is not installed + mocker.patch( + "commitizen.commands.init.ProjectInfo.is_pre_commit_installed", + return_value=False, + ) + with tmpdir.as_cwd(): + with pytest.raises(InitFailedError): + commands.Init(config)() + + def test_pre_commit_exec_failed( + _, mocker: MockFixture, config, default_choice, tmpdir + ): + # Assume `pre-commit` is installed + mocker.patch( + "commitizen.commands.init.ProjectInfo.is_pre_commit_installed", + return_value=True, + ) + # But pre-commit installation will fail + mocker.patch( + "commitizen.commands.init.Init._exec_install_pre_commit_hook", + side_effect=InitFailedError("Mock init failed error."), + ) + with tmpdir.as_cwd(): + with pytest.raises(InitFailedError): + commands.Init(config)() + + +@skip_below_py_3_10 +def test_init_command_shows_description_when_use_help_option( + mocker: MockFixture, capsys, file_regression +): + testargs = ["cz", "init", "--help"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(SystemExit): + cli.main() + + out, _ = capsys.readouterr() + file_regression.check(out, extension=".txt") diff --git a/tests/commands/test_init_command/test_init_command_shows_description_when_use_help_option.txt b/tests/commands/test_init_command/test_init_command_shows_description_when_use_help_option.txt new file mode 100644 index 0000000..0f72042 --- /dev/null +++ b/tests/commands/test_init_command/test_init_command_shows_description_when_use_help_option.txt @@ -0,0 +1,6 @@ +usage: cz init [-h] + +init commitizen configuration + +options: + -h, --help show this help message and exit diff --git a/tests/commands/test_ls_command.py b/tests/commands/test_ls_command.py new file mode 100644 index 0000000..7225d2a --- /dev/null +++ b/tests/commands/test_ls_command.py @@ -0,0 +1,26 @@ +import sys + +import pytest +from pytest_mock import MockerFixture + +from commitizen import cli, commands +from tests.utils import skip_below_py_3_10 + + +def test_list_cz(config, mocker: MockerFixture): + write_mock = mocker.patch("commitizen.out.write") + commands.ListCz(config)() + write_mock.assert_called_once() + + +@skip_below_py_3_10 +def test_ls_command_shows_description_when_use_help_option( + mocker: MockerFixture, capsys, file_regression +): + testargs = ["cz", "ls", "--help"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(SystemExit): + cli.main() + + out, _ = capsys.readouterr() + file_regression.check(out, extension=".txt") diff --git a/tests/commands/test_ls_command/test_ls_command_shows_description_when_use_help_option.txt b/tests/commands/test_ls_command/test_ls_command_shows_description_when_use_help_option.txt new file mode 100644 index 0000000..5fa8fe1 --- /dev/null +++ b/tests/commands/test_ls_command/test_ls_command_shows_description_when_use_help_option.txt @@ -0,0 +1,6 @@ +usage: cz ls [-h] + +show available commitizens + +options: + -h, --help show this help message and exit diff --git a/tests/commands/test_schema_command.py b/tests/commands/test_schema_command.py new file mode 100644 index 0000000..5e57172 --- /dev/null +++ b/tests/commands/test_schema_command.py @@ -0,0 +1,26 @@ +import sys + +import pytest +from pytest_mock import MockerFixture + +from commitizen import cli, commands +from tests.utils import skip_below_py_3_10 + + +def test_schema(config, mocker: MockerFixture): + write_mock = mocker.patch("commitizen.out.write") + commands.Schema(config)() + write_mock.assert_called_once() + + +@skip_below_py_3_10 +def test_schema_command_shows_description_when_use_help_option( + mocker: MockerFixture, capsys, file_regression +): + testargs = ["cz", "schema", "--help"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(SystemExit): + cli.main() + + out, _ = capsys.readouterr() + file_regression.check(out, extension=".txt") diff --git a/tests/commands/test_schema_command/test_schema_command_shows_description_when_use_help_option.txt b/tests/commands/test_schema_command/test_schema_command_shows_description_when_use_help_option.txt new file mode 100644 index 0000000..6666db4 --- /dev/null +++ b/tests/commands/test_schema_command/test_schema_command_shows_description_when_use_help_option.txt @@ -0,0 +1,6 @@ +usage: cz schema [-h] + +show commit schema + +options: + -h, --help show this help message and exit diff --git a/tests/commands/test_version_command.py b/tests/commands/test_version_command.py new file mode 100644 index 0000000..927cf55 --- /dev/null +++ b/tests/commands/test_version_command.py @@ -0,0 +1,122 @@ +import platform +import sys + +import pytest +from pytest_mock import MockerFixture + +from commitizen import cli, commands +from commitizen.__version__ import __version__ +from commitizen.config.base_config import BaseConfig +from tests.utils import skip_below_py_3_10 + + +def test_version_for_showing_project_version(config, capsys): + # No version exist + commands.Version( + config, + {"report": False, "project": True, "commitizen": False, "verbose": False}, + )() + captured = capsys.readouterr() + assert "No project information in this project." in captured.err + + config.settings["version"] = "v0.0.1" + commands.Version( + config, + {"report": False, "project": True, "commitizen": False, "verbose": False}, + )() + captured = capsys.readouterr() + assert "v0.0.1" in captured.out + + +def test_version_for_showing_commitizen_version(config, capsys): + commands.Version( + config, + {"report": False, "project": False, "commitizen": True, "verbose": False}, + )() + captured = capsys.readouterr() + assert f"{__version__}" in captured.out + + # default showing commitizen version + commands.Version( + config, + {"report": False, "project": False, "commitizen": False, "verbose": False}, + )() + captured = capsys.readouterr() + assert f"{__version__}" in captured.out + + +def test_version_for_showing_both_versions(config, capsys): + commands.Version( + config, + {"report": False, "project": False, "commitizen": False, "verbose": True}, + )() + captured = capsys.readouterr() + assert f"Installed Commitizen Version: {__version__}" in captured.out + assert "No project information in this project." in captured.err + + config.settings["version"] = "v0.0.1" + commands.Version( + config, + {"report": False, "project": False, "commitizen": False, "verbose": True}, + )() + captured = capsys.readouterr() + expected_out = ( + f"Installed Commitizen Version: {__version__}\nProject Version: v0.0.1" + ) + assert expected_out in captured.out + + +def test_version_for_showing_commitizen_system_info(config, capsys): + commands.Version( + config, + {"report": True, "project": False, "commitizen": False, "verbose": False}, + )() + captured = capsys.readouterr() + assert f"Commitizen Version: {__version__}" in captured.out + assert f"Python Version: {sys.version}" in captured.out + assert f"Operating System: {platform.system()}" in captured.out + + +@pytest.mark.parametrize("project", (True, False)) +@pytest.mark.usefixtures("tmp_git_project") +def test_version_use_version_provider( + mocker: MockerFixture, + config: BaseConfig, + capsys: pytest.CaptureFixture, + project: bool, +): + version = "0.0.0" + mock = mocker.MagicMock(name="provider") + mock.get_version.return_value = version + get_provider = mocker.patch( + "commitizen.commands.version.get_provider", return_value=mock + ) + + commands.Version( + config, + { + "report": False, + "project": project, + "commitizen": False, + "verbose": not project, + }, + )() + captured = capsys.readouterr() + + assert version in captured.out + get_provider.assert_called_once() + mock.get_version.assert_called_once() + mock.set_version.assert_not_called() + + +@skip_below_py_3_10 +def test_version_command_shows_description_when_use_help_option( + mocker: MockerFixture, capsys, file_regression +): + testargs = ["cz", "version", "--help"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(SystemExit): + cli.main() + + out, _ = capsys.readouterr() + file_regression.check(out, extension=".txt") diff --git a/tests/commands/test_version_command/test_version_command_shows_description_when_use_help_option.txt b/tests/commands/test_version_command/test_version_command_shows_description_when_use_help_option.txt new file mode 100644 index 0000000..c461b10 --- /dev/null +++ b/tests/commands/test_version_command/test_version_command_shows_description_when_use_help_option.txt @@ -0,0 +1,12 @@ +usage: cz version [-h] [-r | -p | -c | -v] + +get the version of the installed commitizen or the current project (default: +installed commitizen) + +options: + -h, --help show this help message and exit + -r, --report get system information for reporting bugs + -p, --project get the version of the current project + -c, --commitizen get the version of the installed commitizen + -v, --verbose get the version of both the installed commitizen and the + current project diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3d88f19 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import os +import re +import tempfile +from collections.abc import Iterator +from pathlib import Path + +import pytest +from pytest_mock import MockerFixture + +from commitizen import cmd, defaults +from commitizen.changelog_formats import ( + ChangelogFormat, + get_changelog_format, +) +from commitizen.config import BaseConfig +from commitizen.cz import registry +from commitizen.cz.base import BaseCommitizen +from tests.utils import create_file_and_commit + +SIGNER = "GitHub Action" +SIGNER_MAIL = "action@github.com" + + +@pytest.fixture(autouse=True) +def git_sandbox(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + """Ensure git commands are executed without the current user settings""" + # Clear any GIT_ prefixed environment variable + for var in os.environ: + if var.startswith("GIT_"): + monkeypatch.delenv(var) + + # Define a dedicated temporary git config + gitconfig = tmp_path / ".git" / "config" + if not gitconfig.parent.exists(): + gitconfig.parent.mkdir() + + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(gitconfig)) + + r = cmd.run(f"git config --file {gitconfig} user.name {SIGNER}") + assert r.return_code == 0, r.err + r = cmd.run(f"git config --file {gitconfig} user.email {SIGNER_MAIL}") + assert r.return_code == 0, r.err + + r = cmd.run(f"git config --file {gitconfig} safe.directory '*'") + assert r.return_code == 0, r.err + + r = cmd.run("git config --global init.defaultBranch master") + assert r.return_code == 0, r.err + + +@pytest.fixture +def chdir(tmp_path: Path) -> Iterator[Path]: + cwd = os.getcwd() + os.chdir(tmp_path) + yield tmp_path + os.chdir(cwd) + + +@pytest.fixture(scope="function") +def tmp_git_project(tmpdir): + with tmpdir.as_cwd(): + cmd.run("git init") + + yield tmpdir + + +@pytest.fixture(scope="function") +def tmp_commitizen_project(tmp_git_project): + tmp_commitizen_cfg_file = tmp_git_project.join("pyproject.toml") + tmp_commitizen_cfg_file.write('[tool.commitizen]\nversion="0.1.0"\n') + + yield tmp_git_project + + +@pytest.fixture(scope="function") +def tmp_commitizen_project_initial(tmp_git_project): + def _initial( + config_extra: str | None = None, + version="0.1.0", + initial_commit="feat: new user interface", + ): + with tmp_git_project.as_cwd(): + tmp_commitizen_cfg_file = tmp_git_project.join("pyproject.toml") + tmp_commitizen_cfg_file.write(f'[tool.commitizen]\nversion="{version}"\n') + tmp_version_file = tmp_git_project.join("__version__.py") + tmp_version_file.write(version) + tmp_commitizen_cfg_file = tmp_git_project.join("pyproject.toml") + tmp_version_file_string = str(tmp_version_file).replace("\\", "/") + tmp_commitizen_cfg_file.write( + f"{tmp_commitizen_cfg_file.read()}\n" + f'version_files = ["{tmp_version_file_string}"]\n' + ) + if config_extra: + tmp_commitizen_cfg_file.write(config_extra, mode="a") + create_file_and_commit(initial_commit) + + return tmp_git_project + + yield _initial + + +def _get_gpg_keyid(signer_mail): + _new_key = cmd.run(f"gpg --list-secret-keys {signer_mail}") + _m = re.search( + r"[a-zA-Z0-9 \[\]-_]*\n[ ]*([0-9A-Za-z]*)\n[\na-zA-Z0-9 \[\]-_<>@]*", + _new_key.out, + ) + return _m.group(1) if _m else None + + +@pytest.fixture(scope="function") +def tmp_commitizen_project_with_gpg(tmp_commitizen_project): + # create a temporary GPGHOME to store a temporary keyring. + # Home path must be less than 104 characters + gpg_home = tempfile.TemporaryDirectory(suffix="_cz") + if os.name != "nt": + os.environ["GNUPGHOME"] = gpg_home.name # tempdir = temp keyring + + # create a key (a keyring will be generated within GPUPGHOME) + c = cmd.run( + f"gpg --batch --yes --debug-quick-random --passphrase '' --quick-gen-key '{SIGNER} {SIGNER_MAIL}'" + ) + if c.return_code != 0: + raise Exception(f"gpg keygen failed with err: '{c.err}'") + key_id = _get_gpg_keyid(SIGNER_MAIL) + assert key_id + + # configure git to use gpg signing + cmd.run("git config commit.gpgsign true") + cmd.run(f"git config user.signingkey {key_id}") + + yield tmp_commitizen_project + + +@pytest.fixture() +def config(): + _config = BaseConfig() + _config.settings.update({"name": defaults.DEFAULT_SETTINGS["name"]}) + return _config + + +@pytest.fixture() +def config_path() -> str: + return os.path.join(os.getcwd(), "pyproject.toml") + + +class SemverCommitizen(BaseCommitizen): + """A minimal cz rules used to test changelog and bump. + + Samples: + ``` + minor(users): add email to user + major: removed user profile + patch(deps): updated dependency for security + ``` + """ + + bump_pattern = r"^(patch|minor|major)" + bump_map = { + "major": "MAJOR", + "minor": "MINOR", + "patch": "PATCH", + } + bump_map_major_version_zero = { + "major": "MINOR", + "minor": "MINOR", + "patch": "PATCH", + } + changelog_pattern = r"^(patch|minor|major)" + commit_parser = r"^(?P<change_type>patch|minor|major)(?:\((?P<scope>[^()\r\n]*)\)|\()?:?\s(?P<message>.+)" # noqa + change_type_map = { + "major": "Breaking Changes", + "minor": "Features", + "patch": "Bugs", + } + + def questions(self) -> list: + return [ + { + "type": "list", + "name": "prefix", + "message": "Select the type of change you are committing", + "choices": [ + { + "value": "patch", + "name": "patch: a bug fix", + "key": "p", + }, + { + "value": "minor", + "name": "minor: a new feature, non-breaking", + "key": "m", + }, + { + "value": "major", + "name": "major: a breaking change", + "key": "b", + }, + ], + }, + { + "type": "input", + "name": "subject", + "message": ( + "Write a short and imperative summary of the code changes: (lower case and no period)\n" + ), + }, + ] + + def message(self, answers: dict) -> str: + prefix = answers["prefix"] + subject = answers.get("subject", "default message").trim() + return f"{prefix}: {subject}" + + +@pytest.fixture() +def use_cz_semver(mocker): + new_cz = {**registry, "cz_semver": SemverCommitizen} + mocker.patch.dict("commitizen.cz.registry", new_cz) + + +class MockPlugin(BaseCommitizen): + def questions(self) -> defaults.Questions: + return [] + + def message(self, answers: dict) -> str: + return "" + + +@pytest.fixture +def mock_plugin(mocker: MockerFixture, config: BaseConfig) -> BaseCommitizen: + mock = MockPlugin(config) + mocker.patch("commitizen.factory.commiter_factory", return_value=mock) + return mock + + +SUPPORTED_FORMATS = ("markdown", "textile", "asciidoc", "restructuredtext") + + +@pytest.fixture(params=SUPPORTED_FORMATS) +def changelog_format( + config: BaseConfig, request: pytest.FixtureRequest +) -> ChangelogFormat: + """For tests relying on formats specifics""" + format: str = request.param + config.settings["changelog_format"] = format + if "tmp_commitizen_project" in request.fixturenames: + tmp_commitizen_project = request.getfixturevalue("tmp_commitizen_project") + pyproject = tmp_commitizen_project / "pyproject.toml" + pyproject.write(f'{pyproject.read()}\nchangelog_format = "{format}"\n') + return get_changelog_format(config) + + +@pytest.fixture +def any_changelog_format(config: BaseConfig) -> ChangelogFormat: + """For test not relying on formats specifics, use the default""" + config.settings["changelog_format"] = defaults.CHANGELOG_FORMAT + return get_changelog_format(config) diff --git a/tests/data/inconsistent_version.py b/tests/data/inconsistent_version.py new file mode 100644 index 0000000..4764676 --- /dev/null +++ b/tests/data/inconsistent_version.py @@ -0,0 +1,4 @@ +__title__ = "requests" +__description__ = "Python HTTP for Humans." +__url__ = "http://python-requests.org" +__version__ = "2.10.3" diff --git a/tests/data/multiple_versions_to_update_pyproject.toml b/tests/data/multiple_versions_to_update_pyproject.toml new file mode 100644 index 0000000..de4ead0 --- /dev/null +++ b/tests/data/multiple_versions_to_update_pyproject.toml @@ -0,0 +1,27 @@ +[[package]] +name = "to-update-1" +version = "1.2.9" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "to-update-2" +version = "1.2.9" diff --git a/tests/data/multiple_versions_to_update_pyproject_wo_eol.toml b/tests/data/multiple_versions_to_update_pyproject_wo_eol.toml new file mode 100644 index 0000000..e2746fa --- /dev/null +++ b/tests/data/multiple_versions_to_update_pyproject_wo_eol.toml @@ -0,0 +1,27 @@ +[[package]] +name = "to-update-1" +version = "1.2.9" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "to-update-2" +version = "1.2.9" \ No newline at end of file diff --git a/tests/data/repeated_version_number.json b/tests/data/repeated_version_number.json new file mode 100644 index 0000000..8421026 --- /dev/null +++ b/tests/data/repeated_version_number.json @@ -0,0 +1,7 @@ +{ + "name": "magictool", + "version": "1.2.3", + "dependencies": { + "lodash": "1.2.3" + } +} diff --git a/tests/data/sample_cargo.lock b/tests/data/sample_cargo.lock new file mode 100644 index 0000000..d9dbd79 --- /dev/null +++ b/tests/data/sample_cargo.lock @@ -0,0 +1,11 @@ +[[package]] +name = "textwrap" +version = "1.2.3" + +[[package]] +name = "there-i-fixed-it" +version = "1.2.3" # automatically bumped by Commitizen + +[[package]] +name = "other-project" +version = "1.2.3" diff --git a/tests/data/sample_docker_compose.yaml b/tests/data/sample_docker_compose.yaml new file mode 100644 index 0000000..9da8caf --- /dev/null +++ b/tests/data/sample_docker_compose.yaml @@ -0,0 +1,6 @@ +version: "3.3" + +services: + app: + image: my-repo/my-container:v1.2.3 + command: my-command diff --git a/tests/data/sample_pyproject.toml b/tests/data/sample_pyproject.toml new file mode 100644 index 0000000..9f50155 --- /dev/null +++ b/tests/data/sample_pyproject.toml @@ -0,0 +1,3 @@ +[tool.poetry] +name = "commitizen" +version = "1.2.3" diff --git a/tests/data/sample_version.py b/tests/data/sample_version.py new file mode 100644 index 0000000..4dd4512 --- /dev/null +++ b/tests/data/sample_version.py @@ -0,0 +1,4 @@ +__title__ = "requests" +__description__ = "Python HTTP for Humans." +__url__ = "http://python-requests.org" +__version__ = "1.2.3" diff --git a/tests/providers/conftest.py b/tests/providers/conftest.py new file mode 100644 index 0000000..f73cdb7 --- /dev/null +++ b/tests/providers/conftest.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import os +from collections.abc import Iterator +from pathlib import Path + +import pytest + + +@pytest.fixture +def chdir(tmp_path: Path) -> Iterator[Path]: + cwd = Path() + os.chdir(tmp_path) + yield tmp_path + os.chdir(cwd) diff --git a/tests/providers/test_base_provider.py b/tests/providers/test_base_provider.py new file mode 100644 index 0000000..482bd69 --- /dev/null +++ b/tests/providers/test_base_provider.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import pytest + +from commitizen.config.base_config import BaseConfig +from commitizen.exceptions import VersionProviderUnknown +from commitizen.providers import get_provider +from commitizen.providers.commitizen_provider import CommitizenProvider + + +def test_default_version_provider_is_commitizen_config(config: BaseConfig): + provider = get_provider(config) + + assert isinstance(provider, CommitizenProvider) + + +def test_raise_for_unknown_provider(config: BaseConfig): + config.settings["version_provider"] = "unknown" + with pytest.raises(VersionProviderUnknown): + get_provider(config) diff --git a/tests/providers/test_cargo_provider.py b/tests/providers/test_cargo_provider.py new file mode 100644 index 0000000..646ef3a --- /dev/null +++ b/tests/providers/test_cargo_provider.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +import pytest + +from commitizen.config.base_config import BaseConfig +from commitizen.providers import get_provider +from commitizen.providers.cargo_provider import CargoProvider + +CARGO_TOML = """\ +[package] +name = "whatever" +version = "0.1.0" +""" + +CARGO_EXPECTED = """\ +[package] +name = "whatever" +version = "42.1" +""" + +CARGO_WORKSPACE_TOML = """\ +[workspace.package] +name = "whatever" +version = "0.1.0" +""" + +CARGO_WORKSPACE_EXPECTED = """\ +[workspace.package] +name = "whatever" +version = "42.1" +""" + + +@pytest.mark.parametrize( + "content, expected", + ( + (CARGO_TOML, CARGO_EXPECTED), + (CARGO_WORKSPACE_TOML, CARGO_WORKSPACE_EXPECTED), + ), +) +def test_cargo_provider( + config: BaseConfig, + chdir: Path, + content: str, + expected: str, +): + filename = CargoProvider.filename + file = chdir / filename + file.write_text(dedent(content)) + config.settings["version_provider"] = "cargo" + + provider = get_provider(config) + assert isinstance(provider, CargoProvider) + assert provider.get_version() == "0.1.0" + + provider.set_version("42.1") + assert file.read_text() == dedent(expected) diff --git a/tests/providers/test_commitizen_provider.py b/tests/providers/test_commitizen_provider.py new file mode 100644 index 0000000..b8df60d --- /dev/null +++ b/tests/providers/test_commitizen_provider.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from commitizen.config.base_config import BaseConfig +from commitizen.providers.commitizen_provider import CommitizenProvider + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + + +def test_commitizen_provider(config: BaseConfig, mocker: MockerFixture): + config.settings["version"] = "42" + mock = mocker.patch.object(config, "set_key") + + provider = CommitizenProvider(config) + assert provider.get_version() == "42" + + provider.set_version("43.1") + mock.assert_called_once_with("version", "43.1") diff --git a/tests/providers/test_composer_provider.py b/tests/providers/test_composer_provider.py new file mode 100644 index 0000000..45cbc8a --- /dev/null +++ b/tests/providers/test_composer_provider.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +import pytest + +from commitizen.config.base_config import BaseConfig +from commitizen.providers import get_provider +from commitizen.providers.composer_provider import ComposerProvider + +COMPOSER_JSON = """\ +{ + "name": "whatever", + "version": "0.1.0" +} +""" + +COMPOSER_EXPECTED = """\ +{ + "name": "whatever", + "version": "42.1" +} +""" + + +@pytest.mark.parametrize( + "content, expected", + ((COMPOSER_JSON, COMPOSER_EXPECTED),), +) +def test_composer_provider( + config: BaseConfig, + chdir: Path, + content: str, + expected: str, +): + filename = ComposerProvider.filename + file = chdir / filename + file.write_text(dedent(content)) + config.settings["version_provider"] = "composer" + + provider = get_provider(config) + assert isinstance(provider, ComposerProvider) + assert provider.get_version() == "0.1.0" + + provider.set_version("42.1") + assert file.read_text() == dedent(expected) diff --git a/tests/providers/test_npm_provider.py b/tests/providers/test_npm_provider.py new file mode 100644 index 0000000..bc93999 --- /dev/null +++ b/tests/providers/test_npm_provider.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +import pytest + +from commitizen.config.base_config import BaseConfig +from commitizen.providers import get_provider +from commitizen.providers.npm_provider import NpmProvider + +NPM_PACKAGE_JSON = """\ +{ + "name": "whatever", + "version": "0.1.0" +} +""" + +NPM_PACKAGE_EXPECTED = """\ +{ + "name": "whatever", + "version": "42.1" +} +""" + +NPM_LOCKFILE_JSON = """\ +{ + "name": "whatever", + "version": "0.1.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "whatever", + "version": "0.1.0" + }, + "someotherpackage": { + "version": "0.1.0" + } + } +} +""" + +NPM_LOCKFILE_EXPECTED = """\ +{ + "name": "whatever", + "version": "42.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "whatever", + "version": "42.1" + }, + "someotherpackage": { + "version": "0.1.0" + } + } +} +""" + + +@pytest.mark.parametrize( + "pkg_shrinkwrap_content, pkg_shrinkwrap_expected", + ((NPM_LOCKFILE_JSON, NPM_LOCKFILE_EXPECTED), (None, None)), +) +@pytest.mark.parametrize( + "pkg_lock_content, pkg_lock_expected", + ((NPM_LOCKFILE_JSON, NPM_LOCKFILE_EXPECTED), (None, None)), +) +def test_npm_provider( + config: BaseConfig, + chdir: Path, + pkg_lock_content: str, + pkg_lock_expected: str, + pkg_shrinkwrap_content: str, + pkg_shrinkwrap_expected: str, +): + pkg = chdir / NpmProvider.package_filename + pkg.write_text(dedent(NPM_PACKAGE_JSON)) + if pkg_lock_content: + pkg_lock = chdir / NpmProvider.lock_filename + pkg_lock.write_text(dedent(pkg_lock_content)) + if pkg_shrinkwrap_content: + pkg_shrinkwrap = chdir / NpmProvider.shrinkwrap_filename + pkg_shrinkwrap.write_text(dedent(pkg_shrinkwrap_content)) + config.settings["version_provider"] = "npm" + + provider = get_provider(config) + assert isinstance(provider, NpmProvider) + assert provider.get_version() == "0.1.0" + + provider.set_version("42.1") + assert pkg.read_text() == dedent(NPM_PACKAGE_EXPECTED) + if pkg_lock_content: + assert pkg_lock.read_text() == dedent(pkg_lock_expected) + if pkg_shrinkwrap_content: + assert pkg_shrinkwrap.read_text() == dedent(pkg_shrinkwrap_expected) diff --git a/tests/providers/test_pep621_provider.py b/tests/providers/test_pep621_provider.py new file mode 100644 index 0000000..16bb479 --- /dev/null +++ b/tests/providers/test_pep621_provider.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +import pytest + +from commitizen.config.base_config import BaseConfig +from commitizen.providers import get_provider +from commitizen.providers.pep621_provider import Pep621Provider + +PEP621_TOML = """\ +[project] +version = "0.1.0" +""" + +PEP621_EXPECTED = """\ +[project] +version = "42.1" +""" + + +@pytest.mark.parametrize( + "content, expected", + ((PEP621_TOML, PEP621_EXPECTED),), +) +def test_cargo_provider( + config: BaseConfig, + chdir: Path, + content: str, + expected: str, +): + filename = Pep621Provider.filename + file = chdir / filename + file.write_text(dedent(content)) + config.settings["version_provider"] = "pep621" + + provider = get_provider(config) + assert isinstance(provider, Pep621Provider) + assert provider.get_version() == "0.1.0" + + provider.set_version("42.1") + assert file.read_text() == dedent(expected) diff --git a/tests/providers/test_poetry_provider.py b/tests/providers/test_poetry_provider.py new file mode 100644 index 0000000..e26e2a4 --- /dev/null +++ b/tests/providers/test_poetry_provider.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +import pytest + +from commitizen.config.base_config import BaseConfig +from commitizen.providers import get_provider +from commitizen.providers.poetry_provider import PoetryProvider + +POETRY_TOML = """\ +[tool.poetry] +version = "0.1.0" +""" + +POETRY_EXPECTED = """\ +[tool.poetry] +version = "42.1" +""" + + +@pytest.mark.parametrize( + "content, expected", + ((POETRY_TOML, POETRY_EXPECTED),), +) +def test_cargo_provider( + config: BaseConfig, + chdir: Path, + content: str, + expected: str, +): + filename = PoetryProvider.filename + file = chdir / filename + file.write_text(dedent(content)) + config.settings["version_provider"] = "poetry" + + provider = get_provider(config) + assert isinstance(provider, PoetryProvider) + assert provider.get_version() == "0.1.0" + + provider.set_version("42.1") + assert file.read_text() == dedent(expected) diff --git a/tests/providers/test_scm_provider.py b/tests/providers/test_scm_provider.py new file mode 100644 index 0000000..9d955b2 --- /dev/null +++ b/tests/providers/test_scm_provider.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import pytest + +from commitizen.config.base_config import BaseConfig +from commitizen.providers import get_provider +from commitizen.providers.scm_provider import ScmProvider +from tests.utils import ( + create_branch, + create_file_and_commit, + create_tag, + merge_branch, + switch_branch, +) + + +@pytest.mark.parametrize( + "tag_format,tag,expected_version", + ( + # If tag_format is $version (the default), version_scheme.parser is used. + # Its DEFAULT_VERSION_PARSER allows a v prefix, but matches PEP440 otherwise. + ("$version", "no-match-because-version-scheme-is-strict", "0.0.0"), + ("$version", "0.1.0", "0.1.0"), + ("$version", "v0.1.0", "0.1.0"), + ("$version", "v-0.1.0", "0.0.0"), + # If tag_format is not None or $version, TAG_FORMAT_REGEXS are used, which are + # much more lenient but require a v prefix. + ("v$version", "v0.1.0", "0.1.0"), + ("v$version", "no-match-because-no-v-prefix", "0.0.0"), + # no match because not a valid version + ("v$version", "v-match-TAG_FORMAT_REGEXS", "0.0.0"), + ("version-$version", "version-0.1.0", "0.1.0"), + ("version-$version", "version-0.1", "0.1"), + ("version-$version", "version-0.1.0rc1", "0.1.0rc1"), + ("v$minor.$major.$patch", "v1.0.0", "0.1.0"), + ("version-$major.$minor.$patch", "version-0.1.0", "0.1.0"), + ("v$major.$minor$prerelease$devrelease", "v1.0rc1", "1.0rc1"), + ("v$major.$minor.$patch$prerelease$devrelease", "v0.1.0", "0.1.0"), + ("v$major.$minor.$patch$prerelease$devrelease", "v0.1.0rc1", "0.1.0rc1"), + ("v$major.$minor.$patch$prerelease$devrelease", "v1.0.0.dev0", "1.0.0.dev0"), + ), +) +@pytest.mark.usefixtures("tmp_git_project") +def test_scm_provider( + config: BaseConfig, tag_format: str, tag: str, expected_version: str +): + create_file_and_commit("test: fake commit") + create_tag(tag) + create_file_and_commit("test: fake commit") + create_tag("should-not-match") + + config.settings["version_provider"] = "scm" + config.settings["tag_format"] = tag_format + + provider = get_provider(config) + assert isinstance(provider, ScmProvider) + actual_version = provider.get_version() + assert actual_version == expected_version + + # Should not fail on set_version() + provider.set_version("43.1") + + +@pytest.mark.usefixtures("tmp_git_project") +def test_scm_provider_default_without_commits_and_tags(config: BaseConfig): + config.settings["version_provider"] = "scm" + + provider = get_provider(config) + assert isinstance(provider, ScmProvider) + assert provider.get_version() == "0.0.0" + + +@pytest.mark.usefixtures("tmp_git_project") +def test_scm_provider_default_with_commits_and_tags(config: BaseConfig): + config.settings["version_provider"] = "scm" + + provider = get_provider(config) + assert isinstance(provider, ScmProvider) + assert provider.get_version() == "0.0.0" + + create_file_and_commit("Initial state") + create_tag("1.0.0") + # create develop + create_branch("develop") + switch_branch("develop") + + # add a feature to develop + create_file_and_commit("develop: add beta feature1") + assert provider.get_version() == "1.0.0" + create_tag("1.1.0b0") + + # create staging + create_branch("staging") + switch_branch("staging") + create_file_and_commit("staging: Starting release candidate") + assert provider.get_version() == "1.1.0b0" + create_tag("1.1.0rc0") + + # add another feature to develop + switch_branch("develop") + create_file_and_commit("develop: add beta feature2") + assert provider.get_version() == "1.1.0b0" + create_tag("1.2.0b0") + + # add a hotfix to master + switch_branch("master") + create_file_and_commit("master: add hotfix") + assert provider.get_version() == "1.0.0" + create_tag("1.0.1") + + # merge the hotfix to staging + switch_branch("staging") + merge_branch("master") + + assert provider.get_version() == "1.1.0rc0" + + +@pytest.mark.usefixtures("tmp_git_project") +def test_scm_provider_detect_legacy_tags(config: BaseConfig): + config.settings["version_provider"] = "scm" + config.settings["tag_format"] = "v${version}" + config.settings["legacy_tag_formats"] = [ + "legacy-${version}", + "old-${version}", + ] + provider = get_provider(config) + + create_file_and_commit("test: fake commit") + create_tag("old-0.4.1") + assert provider.get_version() == "0.4.1" + + create_file_and_commit("test: fake commit") + create_tag("legacy-0.4.2") + assert provider.get_version() == "0.4.2" + + create_file_and_commit("test: fake commit") + create_tag("v0.5.0") + assert provider.get_version() == "0.5.0" diff --git a/tests/providers/test_uv_provider.py b/tests/providers/test_uv_provider.py new file mode 100644 index 0000000..4093709 --- /dev/null +++ b/tests/providers/test_uv_provider.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from commitizen.config.base_config import BaseConfig +from commitizen.providers import get_provider +from commitizen.providers.uv_provider import UvProvider + +if TYPE_CHECKING: + from pytest_regressions.file_regression import FileRegressionFixture + + +PYPROJECT_TOML = """ +[project] +name = "test-uv" +version = "4.2.1" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = ["commitizen==4.2.1"] +""" + +UV_LOCK_SIMPLIFIED = """ +version = 1 +revision = 1 +requires-python = ">=3.13" + +[[package]] +name = "commitizen" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "charset-normalizer" }, + { name = "colorama" }, + { name = "decli" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "questionary" }, + { name = "termcolor" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/a3/77ffc9aee014cbf46c84c9f156a1ddef2d4c7cfb87d567decf2541464245/commitizen-4.2.1.tar.gz", hash = "sha256:5255416f6d6071068159f0b97605777f3e25d00927ff157b7a8d01efeda7b952", size = 50645 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/ce/2f5d8ebe8376991b5f805e9f33d20c7f4c9ca6155bdbda761117dc41dff1/commitizen-4.2.1-py3-none-any.whl", hash = "sha256:a347889e0fe408c3b920a34130d8f35616be3ea8ac6b7b20c5b9aac19762661b", size = 72646 }, +] + +[[package]] +name = "decli" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/a0/a4658f93ecb589f479037b164dc13c68d108b50bf6594e54c820749f97ac/decli-0.6.2.tar.gz", hash = "sha256:36f71eb55fd0093895efb4f416ec32b7f6e00147dda448e3365cf73ceab42d6f", size = 7424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/70/3ea48dc9e958d7d66c44c9944809181f1ca79aaef25703c023b5092d34ff/decli-0.6.2-py3-none-any.whl", hash = "sha256:2fc84106ce9a8f523ed501ca543bdb7e416c064917c12a59ebdc7f311a97b7ed", size = 7854 }, +] + +[[package]] +name = "test-uv" +version = "4.2.1" +source = { virtual = "." } +dependencies = [ + { name = "commitizen" }, +] +""" + + +def test_uv_provider( + config: BaseConfig, tmpdir, file_regression: FileRegressionFixture +): + with tmpdir.as_cwd(): + pyproject_toml_file = tmpdir / UvProvider.filename + pyproject_toml_file.write_text(PYPROJECT_TOML, encoding="utf-8") + + uv_lock_file = tmpdir / UvProvider.lock_filename + uv_lock_file.write_text(UV_LOCK_SIMPLIFIED, encoding="utf-8") + + config.settings["version_provider"] = "uv" + + provider = get_provider(config) + assert isinstance(provider, UvProvider) + assert provider.get_version() == "4.2.1" + + provider.set_version("100.100.100") + assert provider.get_version() == "100.100.100" + + updated_pyproject_toml_content = pyproject_toml_file.read_text(encoding="utf-8") + updated_uv_lock_content = uv_lock_file.read_text(encoding="utf-8") + + for content in (updated_pyproject_toml_content, updated_uv_lock_content): + # updated project version + assert "100.100.100" in content + # commitizen version which was the same as project version and should not be affected + assert "4.2.1" in content + + file_regression.check(updated_pyproject_toml_content, extension=".toml") + file_regression.check(updated_uv_lock_content, extension=".lock") diff --git a/tests/providers/test_uv_provider/test_uv_provider.lock b/tests/providers/test_uv_provider/test_uv_provider.lock new file mode 100644 index 0000000..d353763 --- /dev/null +++ b/tests/providers/test_uv_provider/test_uv_provider.lock @@ -0,0 +1,42 @@ + +version = 1 +revision = 1 +requires-python = ">=3.13" + +[[package]] +name = "commitizen" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "charset-normalizer" }, + { name = "colorama" }, + { name = "decli" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "questionary" }, + { name = "termcolor" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/a3/77ffc9aee014cbf46c84c9f156a1ddef2d4c7cfb87d567decf2541464245/commitizen-4.2.1.tar.gz", hash = "sha256:5255416f6d6071068159f0b97605777f3e25d00927ff157b7a8d01efeda7b952", size = 50645 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/ce/2f5d8ebe8376991b5f805e9f33d20c7f4c9ca6155bdbda761117dc41dff1/commitizen-4.2.1-py3-none-any.whl", hash = "sha256:a347889e0fe408c3b920a34130d8f35616be3ea8ac6b7b20c5b9aac19762661b", size = 72646 }, +] + +[[package]] +name = "decli" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/a0/a4658f93ecb589f479037b164dc13c68d108b50bf6594e54c820749f97ac/decli-0.6.2.tar.gz", hash = "sha256:36f71eb55fd0093895efb4f416ec32b7f6e00147dda448e3365cf73ceab42d6f", size = 7424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/70/3ea48dc9e958d7d66c44c9944809181f1ca79aaef25703c023b5092d34ff/decli-0.6.2-py3-none-any.whl", hash = "sha256:2fc84106ce9a8f523ed501ca543bdb7e416c064917c12a59ebdc7f311a97b7ed", size = 7854 }, +] + +[[package]] +name = "test-uv" +version = "100.100.100" +source = { virtual = "." } +dependencies = [ + { name = "commitizen" }, +] diff --git a/tests/providers/test_uv_provider/test_uv_provider.toml b/tests/providers/test_uv_provider/test_uv_provider.toml new file mode 100644 index 0000000..9fdb6eb --- /dev/null +++ b/tests/providers/test_uv_provider/test_uv_provider.toml @@ -0,0 +1,8 @@ + +[project] +name = "test-uv" +version = "100.100.100" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = ["commitizen==4.2.1"] diff --git a/tests/test_bump_create_commit_message.py b/tests/test_bump_create_commit_message.py new file mode 100644 index 0000000..0002659 --- /dev/null +++ b/tests/test_bump_create_commit_message.py @@ -0,0 +1,160 @@ +import sys +from pathlib import Path +from textwrap import dedent + +import pytest +from pytest_mock import MockFixture + +from commitizen import bump, cli, cmd, exceptions + +conversion = [ + ( + ("1.2.3", "1.3.0", "bump: $current_version -> $new_version [skip ci]"), + "bump: 1.2.3 -> 1.3.0 [skip ci]", + ), + (("1.2.3", "1.3.0", None), "bump: version 1.2.3 โ†’ 1.3.0"), + (("1.2.3", "1.3.0", "release $new_version"), "release 1.3.0"), +] + + +@pytest.mark.parametrize("test_input,expected", conversion) +def test_create_tag(test_input, expected): + current_version, new_version, message_template = test_input + new_tag = bump.create_commit_message(current_version, new_version, message_template) + assert new_tag == expected + + +@pytest.mark.parametrize( + "retry", + ( + pytest.param( + True, + marks=pytest.mark.skipif( + sys.version_info >= (3, 13), + reason="mirrors-prettier is not supported with Python 3.13 or higher", + ), + ), + False, + ), +) +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_pre_commit_changelog(mocker: MockFixture, freezer, retry): + freezer.move_to("2022-04-01") + testargs = ["cz", "bump", "--changelog", "--yes"] + if retry: + testargs.append("--retry") + else: + pytest.xfail("it will fail because pre-commit will reformat CHANGELOG.md") + mocker.patch.object(sys, "argv", testargs) + # Configure prettier as a pre-commit hook + Path(".pre-commit-config.yaml").write_text( + dedent( + """\ + repos: + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.0.3 + hooks: + - id: prettier + stages: [commit] + """ + ) + ) + # Prettier inherits editorconfig + Path(".editorconfig").write_text( + dedent( + """\ + [*] + indent_size = 4 + """ + ) + ) + cmd.run("git add -A") + cmd.run('git commit -m "fix: _test"') + cmd.run("pre-commit install") + cli.main() + # Pre-commit fixed last line adding extra indent and "\" char + assert Path("CHANGELOG.md").read_text() == dedent( + """\ + ## 0.1.1 (2022-04-01) + + ### Fix + + - \\_test + """ + ) + + +@pytest.mark.parametrize("retry", (True, False)) +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_pre_commit_changelog_fails_always(mocker: MockFixture, freezer, retry): + freezer.move_to("2022-04-01") + testargs = ["cz", "bump", "--changelog", "--yes"] + if retry: + testargs.append("--retry") + mocker.patch.object(sys, "argv", testargs) + Path(".pre-commit-config.yaml").write_text( + dedent( + """\ + repos: + - repo: local + hooks: + - id: forbid-changelog + name: changelogs are forbidden + entry: changelogs are forbidden + language: fail + files: CHANGELOG.md + """ + ) + ) + cmd.run("git add -A") + cmd.run('git commit -m "feat: forbid changelogs"') + cmd.run("pre-commit install") + with pytest.raises(exceptions.BumpCommitFailedError): + cli.main() + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_bump_with_build_metadata(mocker: MockFixture, freezer): + def _add_entry(test_str: str, args: list): + Path(test_str).write_text("") + cmd.run("git add -A") + cmd.run(f'git commit -m "fix: test-{test_str}"') + cz_args = ["cz", "bump", "--changelog", "--yes"] + args + mocker.patch.object(sys, "argv", cz_args) + cli.main() + + freezer.move_to("2024-01-01") + + _add_entry("a", ["--build-metadata", "a.b.c"]) + _add_entry("b", []) + _add_entry("c", ["--build-metadata", "alongmetadatastring"]) + _add_entry("d", []) + + # Pre-commit fixed last line adding extra indent and "\" char + assert Path("CHANGELOG.md").read_text() == dedent( + """\ + ## 0.1.4 (2024-01-01) + + ### Fix + + - test-d + + ## 0.1.3+alongmetadatastring (2024-01-01) + + ### Fix + + - test-c + + ## 0.1.2 (2024-01-01) + + ### Fix + + - test-b + + ## 0.1.1+a.b.c (2024-01-01) + + ### Fix + + - test-a + """ + ) diff --git a/tests/test_bump_find_increment.py b/tests/test_bump_find_increment.py new file mode 100644 index 0000000..ff24ff1 --- /dev/null +++ b/tests/test_bump_find_increment.py @@ -0,0 +1,124 @@ +""" +CC: Conventional commits +SVE: Semantic version at the end +""" + +import pytest + +from commitizen import bump +from commitizen.cz.conventional_commits import ConventionalCommitsCz +from commitizen.git import GitCommit + +NONE_INCREMENT_CC = [ + "docs(README): motivation", + "ci: added travis", + "performance. Remove or disable the reimplemented linters", + "refactor that how this line starts", +] + +PATCH_INCREMENTS_CC = [ + "fix(setup.py): future is now required for every python version", + "docs(README): motivation", +] + +MINOR_INCREMENTS_CC = [ + "feat(cli): added version", + "docs(README): motivation", + "fix(setup.py): future is now required for every python version", + "perf: app is much faster", + "refactor: app is much faster", +] + +MAJOR_INCREMENTS_BREAKING_CHANGE_CC = [ + "feat(cli): added version", + "docs(README): motivation", + "BREAKING CHANGE: `extends` key in config file is now used for extending other config files", # noqa + "fix(setup.py): future is now required for every python version", +] + +MAJOR_INCREMENTS_BREAKING_CHANGE_ALT_CC = [ + "feat(cli): added version", + "docs(README): motivation", + "BREAKING-CHANGE: `extends` key in config file is now used for extending other config files", # noqa + "fix(setup.py): future is now required for every python version", +] + +MAJOR_INCREMENTS_EXCLAMATION_CC = [ + "feat(cli)!: added version", + "docs(README): motivation", + "fix(setup.py): future is now required for every python version", +] + +MAJOR_INCREMENTS_EXCLAMATION_CC_SAMPLE_2 = [ + "feat(pipeline)!: some text with breaking change" +] + +MAJOR_INCREMENTS_EXCLAMATION_OTHER_TYPE_CC = [ + "chore!: drop support for Python 3.9", + "docs(README): motivation", + "fix(setup.py): future is now required for every python version", +] + +MAJOR_INCREMENTS_EXCLAMATION_OTHER_TYPE_WITH_SCOPE_CC = [ + "chore(deps)!: drop support for Python 3.9", + "docs(README): motivation", + "fix(setup.py): future is now required for every python version", +] + +PATCH_INCREMENTS_SVE = ["readme motivation PATCH", "fix setup.py PATCH"] + +MINOR_INCREMENTS_SVE = [ + "readme motivation PATCH", + "fix setup.py PATCH", + "added version to cli MINOR", +] + +MAJOR_INCREMENTS_SVE = [ + "readme motivation PATCH", + "fix setup.py PATCH", + "added version to cli MINOR", + "extends key is used for other config files MAJOR", +] + +semantic_version_pattern = r"(MAJOR|MINOR|PATCH)" +semantic_version_map = {"MAJOR": "MAJOR", "MINOR": "MINOR", "PATCH": "PATCH"} + + +@pytest.mark.parametrize( + "messages, expected_type", + ( + (PATCH_INCREMENTS_CC, "PATCH"), + (MINOR_INCREMENTS_CC, "MINOR"), + (MAJOR_INCREMENTS_BREAKING_CHANGE_CC, "MAJOR"), + (MAJOR_INCREMENTS_BREAKING_CHANGE_ALT_CC, "MAJOR"), + (MAJOR_INCREMENTS_EXCLAMATION_OTHER_TYPE_CC, "MAJOR"), + (MAJOR_INCREMENTS_EXCLAMATION_OTHER_TYPE_WITH_SCOPE_CC, "MAJOR"), + (MAJOR_INCREMENTS_EXCLAMATION_CC, "MAJOR"), + (MAJOR_INCREMENTS_EXCLAMATION_CC_SAMPLE_2, "MAJOR"), + (NONE_INCREMENT_CC, None), + ), +) +def test_find_increment(messages, expected_type): + commits = [GitCommit(rev="test", title=message) for message in messages] + increment_type = bump.find_increment( + commits, + regex=ConventionalCommitsCz.bump_pattern, + increments_map=ConventionalCommitsCz.bump_map, + ) + assert increment_type == expected_type + + +@pytest.mark.parametrize( + "messages, expected_type", + ( + (PATCH_INCREMENTS_SVE, "PATCH"), + (MINOR_INCREMENTS_SVE, "MINOR"), + (MAJOR_INCREMENTS_SVE, "MAJOR"), + ), +) +def test_find_increment_sve(messages, expected_type): + commits = [GitCommit(rev="test", title=message) for message in messages] + increment_type = bump.find_increment( + commits, regex=semantic_version_pattern, increments_map=semantic_version_map + ) + assert increment_type == expected_type diff --git a/tests/test_bump_hooks.py b/tests/test_bump_hooks.py new file mode 100644 index 0000000..70ed7fe --- /dev/null +++ b/tests/test_bump_hooks.py @@ -0,0 +1,42 @@ +import os +from unittest.mock import call + +import pytest +from pytest_mock import MockFixture + +from commitizen import cmd, hooks +from commitizen.exceptions import RunHookError + + +def test_run(mocker: MockFixture): + bump_hooks = ["pre_bump_hook", "pre_bump_hook_1"] + + cmd_run_mock = mocker.Mock() + cmd_run_mock.return_value.return_code = 0 + mocker.patch.object(cmd, "run", cmd_run_mock) + + hooks.run(bump_hooks) + + cmd_run_mock.assert_has_calls( + [ + call("pre_bump_hook", env=dict(os.environ)), + call("pre_bump_hook_1", env=dict(os.environ)), + ] + ) + + +def test_run_error(mocker: MockFixture): + bump_hooks = ["pre_bump_hook", "pre_bump_hook_1"] + + cmd_run_mock = mocker.Mock() + cmd_run_mock.return_value.return_code = 1 + mocker.patch.object(cmd, "run", cmd_run_mock) + + with pytest.raises(RunHookError): + hooks.run(bump_hooks) + + +def test_format_env(): + result = hooks._format_env("TEST_", {"foo": "bar", "bar": "baz"}) + assert "TEST_FOO" in result and result["TEST_FOO"] == "bar" + assert "TEST_BAR" in result and result["TEST_BAR"] == "baz" diff --git a/tests/test_bump_normalize_tag.py b/tests/test_bump_normalize_tag.py new file mode 100644 index 0000000..895acbd --- /dev/null +++ b/tests/test_bump_normalize_tag.py @@ -0,0 +1,23 @@ +import pytest + +from commitizen.tags import TagRules + +conversion = [ + (("1.2.3", "v$version"), "v1.2.3"), + (("1.2.3a2", "v$version"), "v1.2.3a2"), + (("1.2.3b2", "v$version"), "v1.2.3b2"), + (("1.2.3", "ver$major.$minor.$patch"), "ver1.2.3"), + (("1.2.3a0", "ver$major.$minor.$patch.$prerelease"), "ver1.2.3.a0"), + (("1.2.3rc2", "$major.$minor.$patch.$prerelease-majestic"), "1.2.3.rc2-majestic"), + (("1.2.3+1.0.0", "v$version"), "v1.2.3+1.0.0"), + (("1.2.3+1.0.0", "v$version-local"), "v1.2.3+1.0.0-local"), + (("1.2.3+1.0.0", "ver$major.$minor.$patch"), "ver1.2.3"), +] + + +@pytest.mark.parametrize("test_input,expected", conversion) +def test_create_tag(test_input, expected): + version, format = test_input + rules = TagRules() + new_tag = rules.normalize_tag(version, format) + assert new_tag == expected diff --git a/tests/test_bump_update_version_in_files.py b/tests/test_bump_update_version_in_files.py new file mode 100644 index 0000000..850b59c --- /dev/null +++ b/tests/test_bump_update_version_in_files.py @@ -0,0 +1,227 @@ +from pathlib import Path +from shutil import copyfile + +import pytest + +from commitizen import bump +from commitizen.exceptions import CurrentVersionNotFoundError + +MULTIPLE_VERSIONS_INCREASE_STRING = 'version = "1.2.9"\n' * 30 +MULTIPLE_VERSIONS_REDUCE_STRING = 'version = "1.2.10"\n' * 30 + +TESTING_FILE_PREFIX = "tests/data" + + +def _copy_sample_file_to_tmpdir( + tmp_path: Path, source_filename: str, dest_filename: str +) -> Path: + tmp_file = tmp_path / dest_filename + copyfile(f"{TESTING_FILE_PREFIX}/{source_filename}", tmp_file) + return tmp_file + + +@pytest.fixture(scope="function") +def commitizen_config_file(tmpdir): + return _copy_sample_file_to_tmpdir( + tmpdir, "sample_pyproject.toml", "pyproject.toml" + ) + + +@pytest.fixture(scope="function") +def python_version_file(tmpdir, request): + return _copy_sample_file_to_tmpdir(tmpdir, "sample_version.py", "__version__.py") + + +@pytest.fixture(scope="function") +def inconsistent_python_version_file(tmpdir): + return _copy_sample_file_to_tmpdir( + tmpdir, "inconsistent_version.py", "__version__.py" + ) + + +@pytest.fixture(scope="function") +def random_location_version_file(tmpdir): + return _copy_sample_file_to_tmpdir(tmpdir, "sample_cargo.lock", "Cargo.lock") + + +@pytest.fixture(scope="function") +def version_repeated_file(tmpdir): + return _copy_sample_file_to_tmpdir( + tmpdir, "repeated_version_number.json", "package.json" + ) + + +@pytest.fixture(scope="function") +def docker_compose_file(tmpdir): + return _copy_sample_file_to_tmpdir( + tmpdir, "sample_docker_compose.yaml", "docker-compose.yaml" + ) + + +@pytest.fixture( + scope="function", + params=( + "multiple_versions_to_update_pyproject.toml", + "multiple_versions_to_update_pyproject_wo_eol.toml", + ), + ids=("with_eol", "without_eol"), +) +def multiple_versions_to_update_poetry_lock(tmpdir, request): + return _copy_sample_file_to_tmpdir(tmpdir, request.param, "pyproject.toml") + + +@pytest.fixture(scope="function") +def multiple_versions_increase_string(tmpdir): + tmp_file = tmpdir.join("anyfile") + tmp_file.write(MULTIPLE_VERSIONS_INCREASE_STRING) + return str(tmp_file) + + +@pytest.fixture(scope="function") +def multiple_versions_reduce_string(tmpdir): + tmp_file = tmpdir.join("anyfile") + tmp_file.write(MULTIPLE_VERSIONS_REDUCE_STRING) + return str(tmp_file) + + +@pytest.fixture(scope="function") +def version_files( + commitizen_config_file, + python_version_file, + version_repeated_file, + docker_compose_file, +): + return ( + commitizen_config_file, + python_version_file, + version_repeated_file, + docker_compose_file, + ) + + +def test_update_version_in_files(version_files, file_regression): + old_version = "1.2.3" + new_version = "2.0.0" + bump.update_version_in_files( + old_version, new_version, version_files, encoding="utf-8" + ) + + file_contents = "" + for filepath in version_files: + with open(filepath, encoding="utf-8") as f: + file_contents += f.read() + file_regression.check(file_contents, extension=".txt") + + +def test_partial_update_of_file(version_repeated_file, file_regression): + old_version = "1.2.3" + new_version = "2.0.0" + regex = "version" + location = f"{version_repeated_file}:{regex}" + + bump.update_version_in_files(old_version, new_version, [location], encoding="utf-8") + with open(version_repeated_file, encoding="utf-8") as f: + file_regression.check(f.read(), extension=".json") + + +def test_random_location(random_location_version_file, file_regression): + old_version = "1.2.3" + new_version = "2.0.0" + location = f"{random_location_version_file}:version.+Commitizen" + + bump.update_version_in_files(old_version, new_version, [location], encoding="utf-8") + with open(random_location_version_file, encoding="utf-8") as f: + file_regression.check(f.read(), extension=".lock") + + +def test_duplicates_are_change_with_no_regex( + random_location_version_file, file_regression +): + old_version = "1.2.3" + new_version = "2.0.0" + location = f"{random_location_version_file}:version" + + bump.update_version_in_files(old_version, new_version, [location], encoding="utf-8") + with open(random_location_version_file, encoding="utf-8") as f: + file_regression.check(f.read(), extension=".lock") + + +def test_version_bump_increase_string_length( + multiple_versions_increase_string, file_regression +): + old_version = "1.2.9" + new_version = "1.2.10" + location = f"{multiple_versions_increase_string}:version" + + bump.update_version_in_files(old_version, new_version, [location], encoding="utf-8") + with open(multiple_versions_increase_string, encoding="utf-8") as f: + file_regression.check(f.read(), extension=".txt") + + +def test_version_bump_reduce_string_length( + multiple_versions_reduce_string, file_regression +): + old_version = "1.2.10" + new_version = "2.0.0" + location = f"{multiple_versions_reduce_string}:version" + + bump.update_version_in_files(old_version, new_version, [location], encoding="utf-8") + with open(multiple_versions_reduce_string, encoding="utf-8") as f: + file_regression.check(f.read(), extension=".txt") + + +def test_file_version_inconsistent_error( + commitizen_config_file, inconsistent_python_version_file, version_repeated_file +): + version_files = [ + commitizen_config_file, + inconsistent_python_version_file, + version_repeated_file, + ] + old_version = "1.2.3" + new_version = "2.0.0" + with pytest.raises(CurrentVersionNotFoundError) as excinfo: + bump.update_version_in_files( + old_version, + new_version, + version_files, + check_consistency=True, + encoding="utf-8", + ) + + expected_msg = ( + f"Current version 1.2.3 is not found in {inconsistent_python_version_file}.\n" + "The version defined in commitizen configuration and the ones in " + "version_files are possibly inconsistent." + ) + assert expected_msg in str(excinfo.value) + + +def test_multiplt_versions_to_bump( + multiple_versions_to_update_poetry_lock, file_regression +): + old_version = "1.2.9" + new_version = "1.2.10" + location = f"{multiple_versions_to_update_poetry_lock}:version" + + bump.update_version_in_files(old_version, new_version, [location], encoding="utf-8") + with open(multiple_versions_to_update_poetry_lock, encoding="utf-8") as f: + file_regression.check(f.read(), extension=".toml") + + +def test_update_version_in_globbed_files(commitizen_config_file, file_regression): + old_version = "1.2.3" + new_version = "2.0.0" + other = commitizen_config_file.dirpath("other.toml") + print(commitizen_config_file, other) + copyfile(commitizen_config_file, other) + + # Prepend full ppath as test assume absolute paths or cwd-relative + version_files = [commitizen_config_file.dirpath("*.toml")] + + bump.update_version_in_files( + old_version, new_version, version_files, encoding="utf-8" + ) + + for file in commitizen_config_file, other: + file_regression.check(file.read_text("utf-8"), extension=".toml") diff --git a/tests/test_bump_update_version_in_files/test_duplicates_are_change_with_no_regex.lock b/tests/test_bump_update_version_in_files/test_duplicates_are_change_with_no_regex.lock new file mode 100644 index 0000000..8fe75b8 --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_duplicates_are_change_with_no_regex.lock @@ -0,0 +1,11 @@ +[[package]] +name = "textwrap" +version = "2.0.0" + +[[package]] +name = "there-i-fixed-it" +version = "2.0.0" # automatically bumped by Commitizen + +[[package]] +name = "other-project" +version = "2.0.0" diff --git a/tests/test_bump_update_version_in_files/test_multiple_versions_to_bump_with_eol_.toml b/tests/test_bump_update_version_in_files/test_multiple_versions_to_bump_with_eol_.toml new file mode 100644 index 0000000..f279eb4 --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_multiple_versions_to_bump_with_eol_.toml @@ -0,0 +1,27 @@ +[[package]] +name = "to-update-1" +version = "1.2.10" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "to-update-2" +version = "1.2.10" diff --git a/tests/test_bump_update_version_in_files/test_multiple_versions_to_bump_without_eol_.toml b/tests/test_bump_update_version_in_files/test_multiple_versions_to_bump_without_eol_.toml new file mode 100644 index 0000000..47092b9 --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_multiple_versions_to_bump_without_eol_.toml @@ -0,0 +1,27 @@ +[[package]] +name = "to-update-1" +version = "1.2.10" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "to-update-2" +version = "1.2.10" \ No newline at end of file diff --git a/tests/test_bump_update_version_in_files/test_multiplt_versions_to_bump_with_eol_.toml b/tests/test_bump_update_version_in_files/test_multiplt_versions_to_bump_with_eol_.toml new file mode 100644 index 0000000..f279eb4 --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_multiplt_versions_to_bump_with_eol_.toml @@ -0,0 +1,27 @@ +[[package]] +name = "to-update-1" +version = "1.2.10" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "to-update-2" +version = "1.2.10" diff --git a/tests/test_bump_update_version_in_files/test_multiplt_versions_to_bump_without_eol_.toml b/tests/test_bump_update_version_in_files/test_multiplt_versions_to_bump_without_eol_.toml new file mode 100644 index 0000000..47092b9 --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_multiplt_versions_to_bump_without_eol_.toml @@ -0,0 +1,27 @@ +[[package]] +name = "to-update-1" +version = "1.2.10" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "not-to-update" +version = "1.3.3" + +[[package]] +name = "to-update-2" +version = "1.2.10" \ No newline at end of file diff --git a/tests/test_bump_update_version_in_files/test_partial_update_of_file.json b/tests/test_bump_update_version_in_files/test_partial_update_of_file.json new file mode 100644 index 0000000..59224bc --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_partial_update_of_file.json @@ -0,0 +1,7 @@ +{ + "name": "magictool", + "version": "2.0.0", + "dependencies": { + "lodash": "1.2.3" + } +} diff --git a/tests/test_bump_update_version_in_files/test_random_location.lock b/tests/test_bump_update_version_in_files/test_random_location.lock new file mode 100644 index 0000000..20dfe7f --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_random_location.lock @@ -0,0 +1,11 @@ +[[package]] +name = "textwrap" +version = "1.2.3" + +[[package]] +name = "there-i-fixed-it" +version = "2.0.0" # automatically bumped by Commitizen + +[[package]] +name = "other-project" +version = "1.2.3" diff --git a/tests/test_bump_update_version_in_files/test_update_version_in_files.txt b/tests/test_bump_update_version_in_files/test_update_version_in_files.txt new file mode 100644 index 0000000..c4e527a --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_update_version_in_files.txt @@ -0,0 +1,20 @@ +[tool.poetry] +name = "commitizen" +version = "2.0.0" +__title__ = "requests" +__description__ = "Python HTTP for Humans." +__url__ = "http://python-requests.org" +__version__ = "2.0.0" +{ + "name": "magictool", + "version": "2.0.0", + "dependencies": { + "lodash": "2.0.0" + } +} +version: "3.3" + +services: + app: + image: my-repo/my-container:v2.0.0 + command: my-command diff --git a/tests/test_bump_update_version_in_files/test_update_version_in_globbed_files.toml b/tests/test_bump_update_version_in_files/test_update_version_in_globbed_files.toml new file mode 100644 index 0000000..bf82cfe --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_update_version_in_globbed_files.toml @@ -0,0 +1,3 @@ +[tool.poetry] +name = "commitizen" +version = "2.0.0" diff --git a/tests/test_bump_update_version_in_files/test_version_bump_increase_string_length.txt b/tests/test_bump_update_version_in_files/test_version_bump_increase_string_length.txt new file mode 100644 index 0000000..4b6d6d6 --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_version_bump_increase_string_length.txt @@ -0,0 +1,30 @@ +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" +version = "1.2.10" diff --git a/tests/test_bump_update_version_in_files/test_version_bump_reduce_string_length.txt b/tests/test_bump_update_version_in_files/test_version_bump_reduce_string_length.txt new file mode 100644 index 0000000..8e619de --- /dev/null +++ b/tests/test_bump_update_version_in_files/test_version_bump_reduce_string_length.txt @@ -0,0 +1,30 @@ +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" +version = "2.0.0" diff --git a/tests/test_changelog.py b/tests/test_changelog.py new file mode 100644 index 0000000..df42b82 --- /dev/null +++ b/tests/test_changelog.py @@ -0,0 +1,1633 @@ +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +import pytest +from jinja2 import FileSystemLoader + +from commitizen import changelog, git +from commitizen.changelog_formats import ChangelogFormat +from commitizen.cz.conventional_commits.conventional_commits import ( + ConventionalCommitsCz, +) +from commitizen.exceptions import InvalidConfigurationError +from commitizen.version_schemes import Pep440 + +COMMITS_DATA: list[dict[str, Any]] = [ + { + "rev": "141ee441c9c9da0809c554103a558eb17c30ed17", + "parents": ["6c4948501031b7d6405b54b21d3d635827f9421b"], + "title": "bump: version 1.1.1 โ†’ 1.2.0", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "6c4948501031b7d6405b54b21d3d635827f9421b", + "parents": ["ddd220ad515502200fe2dde443614c1075d26238"], + "title": "docs: how to create custom bumps", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "ddd220ad515502200fe2dde443614c1075d26238", + "parents": ["ad17acff2e3a2e141cbc3c6efd7705e4e6de9bfc"], + "title": "feat: custom cz plugins now support bumping version", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "ad17acff2e3a2e141cbc3c6efd7705e4e6de9bfc", + "parents": ["56c8a8da84e42b526bcbe130bd194306f7c7e813"], + "title": "docs: added bump gif", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "56c8a8da84e42b526bcbe130bd194306f7c7e813", + "parents": ["74c6134b1b2e6bb8b07ed53410faabe99b204f36"], + "title": "bump: version 1.1.0 โ†’ 1.1.1", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "74c6134b1b2e6bb8b07ed53410faabe99b204f36", + "parents": ["cbc7b5f22c4e74deff4bc92d14e19bd93524711e"], + "title": "refactor: changed stdout statements", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "cbc7b5f22c4e74deff4bc92d14e19bd93524711e", + "parents": ["1ba46f2a63cb9d6e7472eaece21528c8cd28b118"], + "title": "fix(bump): commit message now fits better with semver", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "1ba46f2a63cb9d6e7472eaece21528c8cd28b118", + "parents": ["c35dbffd1bb98bb0b3d1593797e79d1c3366af8f"], + "title": "fix: conventional commit 'breaking change' in body instead of title", + "body": "closes #16", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "c35dbffd1bb98bb0b3d1593797e79d1c3366af8f", + "parents": ["25313397a4ac3dc5b5c986017bee2a614399509d"], + "title": "refactor(schema): command logic removed from commitizen base", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "25313397a4ac3dc5b5c986017bee2a614399509d", + "parents": ["d2f13ac41b4e48995b3b619d931c82451886e6ff"], + "title": "refactor(info): command logic removed from commitizen base", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "d2f13ac41b4e48995b3b619d931c82451886e6ff", + "parents": ["d839e317e5b26671b010584ad8cc6bf362400fa1"], + "title": "refactor(example): command logic removed from commitizen base", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "d839e317e5b26671b010584ad8cc6bf362400fa1", + "parents": ["12d0e65beda969f7983c444ceedc2a01584f4e08"], + "title": "refactor(commit): moved most of the commit logic to the commit command", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "12d0e65beda969f7983c444ceedc2a01584f4e08", + "parents": ["fb4c85abe51c228e50773e424cbd885a8b6c610d"], + "title": "docs(README): updated documentation url)", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "fb4c85abe51c228e50773e424cbd885a8b6c610d", + "parents": ["17efb44d2cd16f6621413691a543e467c7d2dda6"], + "title": "docs: mkdocs documentation", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "17efb44d2cd16f6621413691a543e467c7d2dda6", + "parents": ["6012d9eecfce8163d75c8fff179788e9ad5347da"], + "title": "Bump version 1.0.0 โ†’ 1.1.0", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "6012d9eecfce8163d75c8fff179788e9ad5347da", + "parents": ["0c7fb0ca0168864dfc55d83c210da57771a18319"], + "title": "test: fixed issues with conf", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "0c7fb0ca0168864dfc55d83c210da57771a18319", + "parents": ["cb1dd2019d522644da5bdc2594dd6dee17122d7f"], + "title": "docs(README): some new information about bump", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "cb1dd2019d522644da5bdc2594dd6dee17122d7f", + "parents": ["9c7450f85df6bf6be508e79abf00855a30c3c73c"], + "title": "feat: new working bump command", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "9c7450f85df6bf6be508e79abf00855a30c3c73c", + "parents": ["9f3af3772baab167e3fd8775d37f041440184251"], + "title": "feat: create version tag", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "9f3af3772baab167e3fd8775d37f041440184251", + "parents": ["b0d6a3defbfde14e676e7eb34946409297d0221b"], + "title": "docs: added new changelog", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "b0d6a3defbfde14e676e7eb34946409297d0221b", + "parents": ["d630d07d912e420f0880551f3ac94e933f9d3beb"], + "title": "feat: update given files with new version", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "d630d07d912e420f0880551f3ac94e933f9d3beb", + "parents": ["1792b8980c58787906dbe6836f93f31971b1ec2d"], + "title": "fix: removed all from commit", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "1792b8980c58787906dbe6836f93f31971b1ec2d", + "parents": ["52def1ea3555185ba4b936b463311949907e31ec"], + "title": "feat(config): new set key, used to set version to cfg", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "52def1ea3555185ba4b936b463311949907e31ec", + "parents": ["3127e05077288a5e2b62893345590bf1096141b7"], + "title": "feat: support for pyproject.toml", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "3127e05077288a5e2b62893345590bf1096141b7", + "parents": ["fd480ed90a80a6ffa540549408403d5b60d0e90c"], + "title": "feat: first semantic version bump implementation", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "fd480ed90a80a6ffa540549408403d5b60d0e90c", + "parents": ["e4840a059731c0bf488381ffc77e989e85dd81ad"], + "title": "fix: fix config file not working", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "e4840a059731c0bf488381ffc77e989e85dd81ad", + "parents": ["aa44a92d68014d0da98965c0c2cb8c07957d4362"], + "title": "refactor: added commands folder, better integration with decli", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "aa44a92d68014d0da98965c0c2cb8c07957d4362", + "parents": ["58bb709765380dbd46b74ce6e8978515764eb955"], + "title": "Bump version: 1.0.0b2 โ†’ 1.0.0", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "58bb709765380dbd46b74ce6e8978515764eb955", + "parents": ["97afb0bb48e72b6feca793091a8a23c706693257"], + "title": "docs(README): new badges", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "97afb0bb48e72b6feca793091a8a23c706693257", + "parents": [ + "9cecb9224aa7fa68d4afeac37eba2a25770ef251", + "e004a90b81ea5b374f118759bce5951202d03d69", + ], + "title": "Merge pull request #10 from Woile/feat/decli", + "body": "Feat/decli", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "9cecb9224aa7fa68d4afeac37eba2a25770ef251", + "parents": ["f5781d1a2954d71c14ade2a6a1a95b91310b2577"], + "title": "style: black to files", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "f5781d1a2954d71c14ade2a6a1a95b91310b2577", + "parents": ["80105fb3c6d45369bc0cbf787bd329fba603864c"], + "title": "ci: added travis", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "80105fb3c6d45369bc0cbf787bd329fba603864c", + "parents": ["a96008496ffefb6b1dd9b251cb479eac6a0487f7"], + "title": "refactor: removed delegator, added decli and many tests", + "body": "BREAKING CHANGE: API is stable", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "a96008496ffefb6b1dd9b251cb479eac6a0487f7", + "parents": ["aab33d13110f26604fb786878856ec0b9e5fc32b"], + "title": "docs: updated test command", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "aab33d13110f26604fb786878856ec0b9e5fc32b", + "parents": ["b73791563d2f218806786090fb49ef70faa51a3a"], + "title": "Bump version: 1.0.0b1 โ†’ 1.0.0b2", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "b73791563d2f218806786090fb49ef70faa51a3a", + "parents": ["7aa06a454fb717408b3657faa590731fb4ab3719"], + "title": "docs(README): updated to reflect current state", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "7aa06a454fb717408b3657faa590731fb4ab3719", + "parents": [ + "7c7e96b723c2aaa1aec3a52561f680adf0b60e97", + "9589a65880016996cff156b920472b9d28d771ca", + ], + "title": "Merge pull request #9 from Woile/dev", + "body": "feat: py3 only, tests and conventional commits 1.0", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "7c7e96b723c2aaa1aec3a52561f680adf0b60e97", + "parents": ["ed830019581c83ba633bfd734720e6758eca6061"], + "title": "Bump version: 0.9.11 โ†’ 1.0.0b1", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "ed830019581c83ba633bfd734720e6758eca6061", + "parents": ["c52eca6f74f844ab3ffbde61d98ef96071e132b7"], + "title": "feat: py3 only, tests and conventional commits 1.0", + "body": "more tests\npyproject instead of Pipfile\nquestionary instead of whaaaaat (promptkit 2.0.0 support)", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "c52eca6f74f844ab3ffbde61d98ef96071e132b7", + "parents": ["0326652b2657083929507ee66d4d1a0899e861ba"], + "title": "Bump version: 0.9.10 โ†’ 0.9.11", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "0326652b2657083929507ee66d4d1a0899e861ba", + "parents": ["b3f89892222340150e32631ae6b7aab65230036f"], + "title": "fix(config): load config reads in order without failing if there is no commitizen section", + "body": "Closes #8", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "b3f89892222340150e32631ae6b7aab65230036f", + "parents": ["5e837bf8ef0735193597372cd2d85e31a8f715b9"], + "title": "Bump version: 0.9.9 โ†’ 0.9.10", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "5e837bf8ef0735193597372cd2d85e31a8f715b9", + "parents": ["684e0259cc95c7c5e94854608cd3dcebbd53219e"], + "title": "fix: parse scope (this is my punishment for not having tests)", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "684e0259cc95c7c5e94854608cd3dcebbd53219e", + "parents": ["ca38eac6ff09870851b5c76a6ff0a2a8e5ecda15"], + "title": "Bump version: 0.9.8 โ†’ 0.9.9", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "ca38eac6ff09870851b5c76a6ff0a2a8e5ecda15", + "parents": ["64168f18d4628718c49689ee16430549e96c5d4b"], + "title": "fix: parse scope empty", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "64168f18d4628718c49689ee16430549e96c5d4b", + "parents": ["9d4def716ef235a1fa5ae61614366423fbc8256f"], + "title": "Bump version: 0.9.7 โ†’ 0.9.8", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "9d4def716ef235a1fa5ae61614366423fbc8256f", + "parents": ["33b0bf1a0a4dc60aac45ed47476d2e5473add09e"], + "title": "fix(scope): parse correctly again", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "33b0bf1a0a4dc60aac45ed47476d2e5473add09e", + "parents": ["696885e891ec35775daeb5fec3ba2ab92c2629e1"], + "title": "Bump version: 0.9.6 โ†’ 0.9.7", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "696885e891ec35775daeb5fec3ba2ab92c2629e1", + "parents": ["bef4a86761a3bda309c962bae5d22ce9b57119e4"], + "title": "fix(scope): parse correctly", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "bef4a86761a3bda309c962bae5d22ce9b57119e4", + "parents": ["72472efb80f08ee3fd844660afa012c8cb256e4b"], + "title": "Bump version: 0.9.5 โ†’ 0.9.6", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "72472efb80f08ee3fd844660afa012c8cb256e4b", + "parents": ["b5561ce0ab3b56bb87712c8f90bcf37cf2474f1b"], + "title": "refactor(conventionalCommit): moved filters to questions instead of message", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "b5561ce0ab3b56bb87712c8f90bcf37cf2474f1b", + "parents": ["3e31714dc737029d96898f412e4ecd2be1bcd0ce"], + "title": "fix(manifest): included missing files", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "3e31714dc737029d96898f412e4ecd2be1bcd0ce", + "parents": ["9df721e06595fdd216884c36a28770438b4f4a39"], + "title": "Bump version: 0.9.4 โ†’ 0.9.5", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "9df721e06595fdd216884c36a28770438b4f4a39", + "parents": ["0cf6ada372470c8d09e6c9e68ebf94bbd5a1656f"], + "title": "fix(config): home path for python versions between 3.0 and 3.5", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "0cf6ada372470c8d09e6c9e68ebf94bbd5a1656f", + "parents": ["973c6b3e100f6f69a3fe48bd8ee55c135b96c318"], + "title": "Bump version: 0.9.3 โ†’ 0.9.4", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "973c6b3e100f6f69a3fe48bd8ee55c135b96c318", + "parents": ["dacc86159b260ee98eb5f57941c99ba731a01399"], + "title": "feat(cli): added version", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "dacc86159b260ee98eb5f57941c99ba731a01399", + "parents": ["4368f3c3cbfd4a1ced339212230d854bc5bab496"], + "title": "Bump version: 0.9.2 โ†’ 0.9.3", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "4368f3c3cbfd4a1ced339212230d854bc5bab496", + "parents": ["da94133288727d35dae9b91866a25045038f2d38"], + "title": "feat(committer): conventional commit is a bit more intelligent now", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "da94133288727d35dae9b91866a25045038f2d38", + "parents": ["1541f54503d2e1cf39bd777c0ca5ab5eb78772ba"], + "title": "docs(README): motivation", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "1541f54503d2e1cf39bd777c0ca5ab5eb78772ba", + "parents": ["ddc855a637b7879108308b8dbd85a0fd27c7e0e7"], + "title": "Bump version: 0.9.1 โ†’ 0.9.2", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "ddc855a637b7879108308b8dbd85a0fd27c7e0e7", + "parents": ["46e9032e18a819e466618c7a014bcb0e9981af9e"], + "title": "refactor: renamed conventional_changelog to conventional_commits, not backward compatible", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "46e9032e18a819e466618c7a014bcb0e9981af9e", + "parents": ["0fef73cd7dc77a25b82e197e7c1d3144a58c1350"], + "title": "Bump version: 0.9.0 โ†’ 0.9.1", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, + { + "rev": "0fef73cd7dc77a25b82e197e7c1d3144a58c1350", + "parents": [], + "title": "fix(setup.py): future is now required for every python version", + "body": "", + "author": "Commitizen", + "author_email": "author@cz.dev", + }, +] + + +TAGS = [ + ("v1.2.0", "141ee441c9c9da0809c554103a558eb17c30ed17", "2019-04-19"), + ("v1.1.1", "56c8a8da84e42b526bcbe130bd194306f7c7e813", "2019-04-18"), + ("v1.1.0", "17efb44d2cd16f6621413691a543e467c7d2dda6", "2019-04-14"), + ("v1.0.0", "aa44a92d68014d0da98965c0c2cb8c07957d4362", "2019-03-01"), + ("1.0.0b2", "aab33d13110f26604fb786878856ec0b9e5fc32b", "2019-01-18"), + ("v1.0.0b1", "7c7e96b723c2aaa1aec3a52561f680adf0b60e97", "2019-01-17"), + ("v0.9.11", "c52eca6f74f844ab3ffbde61d98ef96071e132b7", "2018-12-17"), + ("v0.9.10", "b3f89892222340150e32631ae6b7aab65230036f", "2018-09-22"), + ("v0.9.9", "684e0259cc95c7c5e94854608cd3dcebbd53219e", "2018-09-22"), + ("v0.9.8", "64168f18d4628718c49689ee16430549e96c5d4b", "2018-09-22"), + ("v0.9.7", "33b0bf1a0a4dc60aac45ed47476d2e5473add09e", "2018-09-22"), + ("v0.9.6", "bef4a86761a3bda309c962bae5d22ce9b57119e4", "2018-09-19"), + ("v0.9.5", "3e31714dc737029d96898f412e4ecd2be1bcd0ce", "2018-08-24"), + ("v0.9.4", "0cf6ada372470c8d09e6c9e68ebf94bbd5a1656f", "2018-08-02"), + ("v0.9.3", "dacc86159b260ee98eb5f57941c99ba731a01399", "2018-07-28"), + ("v0.9.2", "1541f54503d2e1cf39bd777c0ca5ab5eb78772ba", "2017-11-11"), + ("v0.9.1", "46e9032e18a819e466618c7a014bcb0e9981af9e", "2017-11-11"), +] + + +@pytest.fixture +def gitcommits() -> list: + commits = [ + git.GitCommit( + commit["rev"], + commit["title"], + commit["body"], + commit["author"], + commit["author_email"], + commit["parents"], + ) + for commit in COMMITS_DATA + ] + return commits + + +@pytest.fixture +def tags() -> list: + tags = [git.GitTag(*tag) for tag in TAGS] + return tags + + +@pytest.fixture +def changelog_content() -> str: + changelog_path = "tests/CHANGELOG_FOR_TEST.md" + with open(changelog_path, encoding="utf-8") as f: + return f.read() + + +def test_get_commit_tag_is_a_version(gitcommits, tags): + commit = gitcommits[0] + tag = git.GitTag(*TAGS[0]) + current_key = changelog.get_commit_tag(commit, tags) + assert current_key == tag + + +def test_get_commit_tag_is_None(gitcommits, tags): + commit = gitcommits[1] + current_key = changelog.get_commit_tag(commit, tags) + assert current_key is None + + +@pytest.mark.parametrize("test_input", TAGS) +def test_valid_tag_included_in_changelog(test_input): + tag = git.GitTag(*test_input) + rules = changelog.TagRules() + assert rules.include_in_changelog(tag) + + +def test_invalid_tag_included_in_changelog(): + tag = git.GitTag("not_a_version", "rev", "date") + rules = changelog.TagRules() + assert not rules.include_in_changelog(tag) + + +COMMITS_TREE = ( + { + "version": "v1.2.0", + "date": "2019-04-19", + "changes": { + "feat": [ + { + "scope": None, + "breaking": None, + "message": "custom cz plugins now support bumping version", + } + ] + }, + }, + { + "version": "v1.1.1", + "date": "2019-04-18", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "changed stdout statements", + }, + { + "scope": "schema", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "info", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "example", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "commit", + "breaking": None, + "message": "moved most of the commit logic to the commit command", + }, + ], + "fix": [ + { + "scope": "bump", + "breaking": None, + "message": "commit message now fits better with semver", + }, + { + "scope": None, + "breaking": None, + "message": "conventional commit 'breaking change' in body instead of title", + }, + ], + }, + }, + { + "version": "v1.1.0", + "date": "2019-04-14", + "changes": { + "feat": [ + { + "scope": None, + "breaking": None, + "message": "new working bump command", + }, + {"scope": None, "breaking": None, "message": "create version tag"}, + { + "scope": None, + "breaking": None, + "message": "update given files with new version", + }, + { + "scope": "config", + "breaking": None, + "message": "new set key, used to set version to cfg", + }, + { + "scope": None, + "breaking": None, + "message": "support for pyproject.toml", + }, + { + "scope": None, + "breaking": None, + "message": "first semantic version bump implementation", + }, + ], + "fix": [ + { + "scope": None, + "breaking": None, + "message": "removed all from commit", + }, + { + "scope": None, + "breaking": None, + "message": "fix config file not working", + }, + ], + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "added commands folder, better integration with decli", + } + ], + }, + }, + { + "version": "v1.0.0", + "date": "2019-03-01", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "removed delegator, added decli and many tests", + } + ], + "BREAKING CHANGE": [ + {"scope": None, "breaking": None, "message": "API is stable"} + ], + }, + }, + {"version": "1.0.0b2", "date": "2019-01-18", "changes": {}}, + { + "version": "v1.0.0b1", + "date": "2019-01-17", + "changes": { + "feat": [ + { + "scope": None, + "breaking": None, + "message": "py3 only, tests and conventional commits 1.0", + } + ] + }, + }, + { + "version": "v0.9.11", + "date": "2018-12-17", + "changes": { + "fix": [ + { + "scope": "config", + "breaking": None, + "message": "load config reads in order without failing if there is no commitizen section", + } + ] + }, + }, + { + "version": "v0.9.10", + "date": "2018-09-22", + "changes": { + "fix": [ + { + "scope": None, + "breaking": None, + "message": "parse scope (this is my punishment for not having tests)", + } + ] + }, + }, + { + "version": "v0.9.9", + "date": "2018-09-22", + "changes": { + "fix": [{"scope": None, "breaking": None, "message": "parse scope empty"}] + }, + }, + { + "version": "v0.9.8", + "date": "2018-09-22", + "changes": { + "fix": [ + { + "scope": "scope", + "breaking": None, + "message": "parse correctly again", + } + ] + }, + }, + { + "version": "v0.9.7", + "date": "2018-09-22", + "changes": { + "fix": [{"scope": "scope", "breaking": None, "message": "parse correctly"}] + }, + }, + { + "version": "v0.9.6", + "date": "2018-09-19", + "changes": { + "refactor": [ + { + "scope": "conventionalCommit", + "breaking": None, + "message": "moved filters to questions instead of message", + } + ], + "fix": [ + { + "scope": "manifest", + "breaking": None, + "message": "included missing files", + } + ], + }, + }, + { + "version": "v0.9.5", + "date": "2018-08-24", + "changes": { + "fix": [ + { + "scope": "config", + "breaking": None, + "message": "home path for python versions between 3.0 and 3.5", + } + ] + }, + }, + { + "version": "v0.9.4", + "date": "2018-08-02", + "changes": { + "feat": [{"scope": "cli", "breaking": None, "message": "added version"}] + }, + }, + { + "version": "v0.9.3", + "date": "2018-07-28", + "changes": { + "feat": [ + { + "scope": "committer", + "breaking": None, + "message": "conventional commit is a bit more intelligent now", + } + ] + }, + }, + { + "version": "v0.9.2", + "date": "2017-11-11", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "renamed conventional_changelog to conventional_commits, not backward compatible", + } + ] + }, + }, + { + "version": "v0.9.1", + "date": "2017-11-11", + "changes": { + "fix": [ + { + "scope": "setup.py", + "breaking": None, + "message": "future is now required for every python version", + } + ] + }, + }, +) + +COMMITS_TREE_AFTER_MERGED_PRERELEASES = ( + { + "version": "v1.2.0", + "date": "2019-04-19", + "changes": { + "feat": [ + { + "scope": None, + "breaking": None, + "message": "custom cz plugins now support bumping version", + } + ] + }, + }, + { + "version": "v1.1.1", + "date": "2019-04-18", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "changed stdout statements", + }, + { + "scope": "schema", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "info", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "example", + "breaking": None, + "message": "command logic removed from commitizen base", + }, + { + "scope": "commit", + "breaking": None, + "message": "moved most of the commit logic to the commit command", + }, + ], + "fix": [ + { + "scope": "bump", + "breaking": None, + "message": "commit message now fits better with semver", + }, + { + "scope": None, + "breaking": None, + "message": "conventional commit 'breaking change' in body instead of title", + }, + ], + }, + }, + { + "version": "v1.1.0", + "date": "2019-04-14", + "changes": { + "feat": [ + { + "scope": None, + "breaking": None, + "message": "new working bump command", + }, + {"scope": None, "breaking": None, "message": "create version tag"}, + { + "scope": None, + "breaking": None, + "message": "update given files with new version", + }, + { + "scope": "config", + "breaking": None, + "message": "new set key, used to set version to cfg", + }, + { + "scope": None, + "breaking": None, + "message": "support for pyproject.toml", + }, + { + "scope": None, + "breaking": None, + "message": "first semantic version bump implementation", + }, + ], + "fix": [ + { + "scope": None, + "breaking": None, + "message": "removed all from commit", + }, + { + "scope": None, + "breaking": None, + "message": "fix config file not working", + }, + ], + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "added commands folder, better integration with decli", + } + ], + }, + }, + { + "version": "v1.0.0", + "date": "2019-03-01", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "removed delegator, added decli and many tests", + } + ], + "feat": [ + { + "scope": None, + "breaking": None, + "message": "py3 only, tests and conventional commits 1.0", + } + ], + "BREAKING CHANGE": [ + {"scope": None, "breaking": None, "message": "API is stable"} + ], + }, + }, + { + "version": "v0.9.11", + "date": "2018-12-17", + "changes": { + "fix": [ + { + "scope": "config", + "breaking": None, + "message": "load config reads in order without failing if there is no commitizen section", + } + ] + }, + }, + { + "version": "v0.9.10", + "date": "2018-09-22", + "changes": { + "fix": [ + { + "scope": None, + "breaking": None, + "message": "parse scope (this is my punishment for not having tests)", + } + ] + }, + }, + { + "version": "v0.9.9", + "date": "2018-09-22", + "changes": { + "fix": [{"scope": None, "breaking": None, "message": "parse scope empty"}] + }, + }, + { + "version": "v0.9.8", + "date": "2018-09-22", + "changes": { + "fix": [ + { + "scope": "scope", + "breaking": None, + "message": "parse correctly again", + } + ] + }, + }, + { + "version": "v0.9.7", + "date": "2018-09-22", + "changes": { + "fix": [{"scope": "scope", "breaking": None, "message": "parse correctly"}] + }, + }, + { + "version": "v0.9.6", + "date": "2018-09-19", + "changes": { + "refactor": [ + { + "scope": "conventionalCommit", + "breaking": None, + "message": "moved filters to questions instead of message", + } + ], + "fix": [ + { + "scope": "manifest", + "breaking": None, + "message": "included missing files", + } + ], + }, + }, + { + "version": "v0.9.5", + "date": "2018-08-24", + "changes": { + "fix": [ + { + "scope": "config", + "breaking": None, + "message": "home path for python versions between 3.0 and 3.5", + } + ] + }, + }, + { + "version": "v0.9.4", + "date": "2018-08-02", + "changes": { + "feat": [{"scope": "cli", "breaking": None, "message": "added version"}] + }, + }, + { + "version": "v0.9.3", + "date": "2018-07-28", + "changes": { + "feat": [ + { + "scope": "committer", + "breaking": None, + "message": "conventional commit is a bit more intelligent now", + } + ] + }, + }, + { + "version": "v0.9.2", + "date": "2017-11-11", + "changes": { + "refactor": [ + { + "scope": None, + "breaking": None, + "message": "renamed conventional_changelog to conventional_commits, not backward compatible", + } + ] + }, + }, + { + "version": "v0.9.1", + "date": "2017-11-11", + "changes": { + "fix": [ + { + "scope": "setup.py", + "breaking": None, + "message": "future is now required for every python version", + } + ] + }, + }, +) + + +@pytest.mark.parametrize("merge_prereleases", (True, False)) +def test_generate_tree_from_commits(gitcommits, tags, merge_prereleases): + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.bump_pattern + rules = changelog.TagRules( + merge_prereleases=merge_prereleases, + ) + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern, rules=rules + ) + expected = ( + COMMITS_TREE_AFTER_MERGED_PRERELEASES if merge_prereleases else COMMITS_TREE + ) + + for release, expected_release in zip(tree, expected): + assert release["version"] == expected_release["version"] + assert release["date"] == expected_release["date"] + assert release["changes"].keys() == expected_release["changes"].keys() + for change_type in release["changes"]: + changes = release["changes"][change_type] + expected_changes = expected_release["changes"][change_type] + for change, expected_change in zip(changes, expected_changes): + assert change["scope"] == expected_change["scope"] + assert change["breaking"] == expected_change["breaking"] + assert change["message"] == expected_change["message"] + assert change["author"] == "Commitizen" + assert change["author_email"] in "author@cz.dev" + assert "sha1" in change + assert "parents" in change + + +def test_generate_tree_from_commits_with_no_commits(tags): + gitcommits = [] + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.bump_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + + assert tuple(tree) == ({"changes": {}, "date": "", "version": "Unreleased"},) + + +@pytest.mark.parametrize( + "change_type_order, expected_reordering", + ( + ([], {}), + ( + ["BREAKING CHANGE", "refactor"], + { + "1.1.0": { + "original": ["feat", "fix", "refactor"], + "sorted": ["refactor", "feat", "fix"], + }, + "1.0.0": { + "original": ["refactor", "BREAKING CHANGE"], + "sorted": ["BREAKING CHANGE", "refactor"], + }, + }, + ), + ), +) +def test_order_changelog_tree(change_type_order, expected_reordering): + tree = changelog.order_changelog_tree(COMMITS_TREE, change_type_order) + + for index, entry in enumerate(tuple(tree)): + version = tree[index]["version"] + if version in expected_reordering: + # Verify that all keys are present + assert [*tree[index].keys()] == [*COMMITS_TREE[index].keys()] + # Verify that the reorder only impacted the returned dict and not the original + expected = expected_reordering[version] + assert [*tree[index]["changes"].keys()] == expected["sorted"] + assert [*COMMITS_TREE[index]["changes"].keys()] == expected["original"] + else: + assert [*entry["changes"].keys()] == [*tree[index]["changes"].keys()] + + +def test_order_changelog_tree_raises(): + change_type_order = ["BREAKING CHANGE", "feat", "refactor", "feat"] + with pytest.raises(InvalidConfigurationError) as excinfo: + changelog.order_changelog_tree(COMMITS_TREE, change_type_order) + + assert "Change types contain duplicates types" in str(excinfo) + + +def test_render_changelog( + gitcommits, tags, changelog_content, any_changelog_format: ChangelogFormat +): + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + loader = ConventionalCommitsCz.template_loader + template = any_changelog_format.template + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + result = changelog.render_changelog(tree, loader, template) + assert result == changelog_content + + +def test_render_changelog_from_default_plugin_values( + gitcommits, tags, changelog_content, any_changelog_format: ChangelogFormat +): + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + loader = ConventionalCommitsCz.template_loader + template = any_changelog_format.template + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + result = changelog.render_changelog(tree, loader, template) + assert result == changelog_content + + +def test_render_changelog_override_loader(gitcommits, tags, tmp_path: Path): + loader = FileSystemLoader(tmp_path) + template = "tpl.j2" + tpl = "loader overridden" + (tmp_path / template).write_text(tpl) + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + result = changelog.render_changelog(tree, loader, template) + assert result == tpl + + +def test_render_changelog_override_template_from_cwd( + gitcommits, tags, chdir: Path, any_changelog_format: ChangelogFormat +): + tpl = "overridden from cwd" + template = any_changelog_format.template + (chdir / template).write_text(tpl) + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + loader = ConventionalCommitsCz.template_loader + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + result = changelog.render_changelog(tree, loader, template) + assert result == tpl + + +def test_render_changelog_override_template_from_cwd_with_custom_name( + gitcommits, tags, chdir: Path +): + tpl = "template overridden from cwd" + tpl_name = "tpl.j2" + (chdir / tpl_name).write_text(tpl) + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + loader = ConventionalCommitsCz.template_loader + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + result = changelog.render_changelog(tree, loader, tpl_name) + assert result == tpl + + +def test_render_changelog_override_loader_and_template( + gitcommits, tags, tmp_path: Path +): + loader = FileSystemLoader(tmp_path) + tpl = "loader and template overridden" + tpl_name = "tpl.j2" + (tmp_path / tpl_name).write_text(tpl) + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.bump_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + result = changelog.render_changelog(tree, loader, tpl_name) + assert result == tpl + + +def test_render_changelog_support_arbitrary_kwargs(gitcommits, tags, tmp_path: Path): + loader = FileSystemLoader(tmp_path) + tpl_name = "tpl.j2" + (tmp_path / tpl_name).write_text("{{ key }}") + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern + ) + result = changelog.render_changelog(tree, loader, tpl_name, key="value") + assert result == "value" + + +def test_render_changelog_unreleased(gitcommits, any_changelog_format: ChangelogFormat): + some_commits = gitcommits[:7] + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + loader = ConventionalCommitsCz.template_loader + template = any_changelog_format.template + tree = changelog.generate_tree_from_commits( + some_commits, [], parser, changelog_pattern + ) + result = changelog.render_changelog(tree, loader, template) + assert "Unreleased" in result + + +def test_render_changelog_tag_and_unreleased( + gitcommits, tags, any_changelog_format: ChangelogFormat +): + some_commits = gitcommits[:7] + single_tag = [ + tag for tag in tags if tag.rev == "56c8a8da84e42b526bcbe130bd194306f7c7e813" + ] + + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + loader = ConventionalCommitsCz.template_loader + template = any_changelog_format.template + tree = changelog.generate_tree_from_commits( + some_commits, single_tag, parser, changelog_pattern + ) + result = changelog.render_changelog(tree, loader, template) + + assert "Unreleased" in result + assert "## v1.1.1" in result + + +def test_render_changelog_with_change_type( + gitcommits, tags, any_changelog_format: ChangelogFormat +): + new_title = ":some-emoji: feature" + change_type_map = {"feat": new_title} + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + loader = ConventionalCommitsCz.template_loader + template = any_changelog_format.template + tree = changelog.generate_tree_from_commits( + gitcommits, tags, parser, changelog_pattern, change_type_map=change_type_map + ) + result = changelog.render_changelog(tree, loader, template) + assert new_title in result + + +def test_render_changelog_with_changelog_message_builder_hook( + gitcommits, tags, any_changelog_format: ChangelogFormat +): + def changelog_message_builder_hook(message: dict, commit: git.GitCommit) -> dict: + message["message"] = ( + f"{message['message']} [link](github.com/232323232) {commit.author} {commit.author_email}" + ) + return message + + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + loader = ConventionalCommitsCz.template_loader + template = any_changelog_format.template + tree = changelog.generate_tree_from_commits( + gitcommits, + tags, + parser, + changelog_pattern, + changelog_message_builder_hook=changelog_message_builder_hook, + ) + result = changelog.render_changelog(tree, loader, template) + + assert "[link](github.com/232323232) Commitizen author@cz.dev" in result + + +def test_changelog_message_builder_hook_can_remove_commits( + gitcommits, tags, any_changelog_format: ChangelogFormat +): + def changelog_message_builder_hook(message: dict, commit: git.GitCommit): + return None + + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + loader = ConventionalCommitsCz.template_loader + template = any_changelog_format.template + tree = changelog.generate_tree_from_commits( + gitcommits, + tags, + parser, + changelog_pattern, + changelog_message_builder_hook=changelog_message_builder_hook, + ) + result = changelog.render_changelog(tree, loader, template) + + RE_HEADER = re.compile(r"^## v?\d+\.\d+\.\d+(\w)* \(\d{4}-\d{2}-\d{2}\)$") + # Rendered changelog should be empty, only containing version headers + for no, line in enumerate(result.splitlines()): + if line := line.strip(): + assert RE_HEADER.match(line), f"Line {no} should not be there: {line}" + + +def test_render_changelog_with_changelog_message_builder_hook_multiple_entries( + gitcommits, tags, any_changelog_format: ChangelogFormat +): + def changelog_message_builder_hook(message: dict, commit: git.GitCommit): + messages = [message.copy(), message.copy(), message.copy()] + for idx, msg in enumerate(messages): + msg["message"] = "Message #{idx}" + return messages + + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + loader = ConventionalCommitsCz.template_loader + template = any_changelog_format.template + tree = changelog.generate_tree_from_commits( + gitcommits, + tags, + parser, + changelog_pattern, + changelog_message_builder_hook=changelog_message_builder_hook, + ) + result = changelog.render_changelog(tree, loader, template) + + for idx in range(3): + assert "Message #{idx}" in result + + +def test_changelog_message_builder_hook_can_access_and_modify_change_type( + gitcommits, tags, any_changelog_format: ChangelogFormat +): + def changelog_message_builder_hook(message: dict, commit: git.GitCommit): + assert "change_type" in message + message["change_type"] = "overridden" + return message + + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + loader = ConventionalCommitsCz.template_loader + template = any_changelog_format.template + tree = changelog.generate_tree_from_commits( + gitcommits, + tags, + parser, + changelog_pattern, + changelog_message_builder_hook=changelog_message_builder_hook, + ) + result = changelog.render_changelog(tree, loader, template) + + RE_HEADER = re.compile(r"^### (?P<type>.+)$") + # There should be only "overridden" change type headers + for no, line in enumerate(result.splitlines()): + if (line := line.strip()) and (match := RE_HEADER.match(line)): + change_type = match.group("type") + assert change_type == "overridden", ( + f"Line {no}: type {change_type} should have been overridden" + ) + + +def test_render_changelog_with_changelog_release_hook( + gitcommits, tags, any_changelog_format: ChangelogFormat +): + def changelog_release_hook(release: dict, tag: Optional[git.GitTag]) -> dict: + release["extra"] = "whatever" + return release + + parser = ConventionalCommitsCz.commit_parser + changelog_pattern = ConventionalCommitsCz.changelog_pattern + tree = changelog.generate_tree_from_commits( + gitcommits, + tags, + parser, + changelog_pattern, + changelog_release_hook=changelog_release_hook, + ) + for release in tree: + assert release["extra"] == "whatever" + + +def test_get_smart_tag_range_returns_an_extra_for_a_range(tags): + start, end = ( + tags[0], + tags[2], + ) # len here is 3, but we expect one more tag as designed + res = changelog.get_smart_tag_range(tags, start.name, end.name) + assert 4 == len(res) + + +def test_get_smart_tag_range_returns_an_extra_for_a_single_tag(tags): + start = tags[0] # len here is 1, but we expect one more tag as designed + res = changelog.get_smart_tag_range(tags, start.name) + assert 2 == len(res) + + +@dataclass +class TagDef: + name: str + is_version: bool + is_legacy: bool + is_ignored: bool + + +TAGS_PARAMS = ( + pytest.param(TagDef("1.2.3", True, False, False), id="version"), + # We test with `v-` prefix as `v` prefix is a special case kept for backward compatibility + pytest.param(TagDef("v-1.2.3", False, True, False), id="v-prefix"), + pytest.param(TagDef("project-1.2.3", False, True, False), id="project-prefix"), + pytest.param(TagDef("ignored", False, False, True), id="ignored"), + pytest.param(TagDef("unknown", False, False, False), id="unknown"), +) + + +@pytest.mark.parametrize("tag", TAGS_PARAMS) +def test_tag_rules_tag_format_only(tag: TagDef): + rules = changelog.TagRules(Pep440, "$version") + assert rules.is_version_tag(tag.name) is tag.is_version + + +@pytest.mark.parametrize("tag", TAGS_PARAMS) +def test_tag_rules_with_legacy_tags(tag: TagDef): + rules = changelog.TagRules( + scheme=Pep440, + tag_format="$version", + legacy_tag_formats=["v-$version", "project-${version}"], + ) + assert rules.is_version_tag(tag.name) is tag.is_version or tag.is_legacy + + +@pytest.mark.parametrize("tag", TAGS_PARAMS) +def test_tag_rules_with_ignored_tags(tag: TagDef): + rules = changelog.TagRules( + scheme=Pep440, tag_format="$version", ignored_tag_formats=["ignored"] + ) + assert rules.is_ignored_tag(tag.name) is tag.is_ignored + + +def test_tags_rules_get_version_tags(capsys: pytest.CaptureFixture): + tags = [ + git.GitTag("v1.1.0", "17efb44d2cd16f6621413691a543e467c7d2dda6", "2019-04-14"), + git.GitTag("v1.0.0", "aa44a92d68014d0da98965c0c2cb8c07957d4362", "2019-03-01"), + git.GitTag("1.0.0b2", "aab33d13110f26604fb786878856ec0b9e5fc32b", "2019-01-18"), + git.GitTag( + "project-not-a-version", + "7c7e96b723c2aaa1aec3a52561f680adf0b60e97", + "2019-01-17", + ), + git.GitTag( + "not-a-version", "c52eca6f74f844ab3ffbde61d98ef96071e132b7", "2018-12-17" + ), + git.GitTag( + "star-something", "c52eca6f74f844ab3ffbde61d98fe96071e132b2", "2018-11-12" + ), + git.GitTag("known", "b3f89892222340150e32631ae6b7aab65230036f", "2018-09-22"), + git.GitTag( + "ignored-0.9.3", "684e0259cc95c7c5e94854608cd3dcebbd53219e", "2018-09-22" + ), + git.GitTag( + "project-0.9.3", "dacc86159b260ee98eb5f57941c99ba731a01399", "2018-07-28" + ), + git.GitTag( + "anything-0.9", "5141f54503d2e1cf39bd666c0ca5ab5eb78772ab", "2018-01-10" + ), + git.GitTag( + "project-0.9.2", "1541f54503d2e1cf39bd777c0ca5ab5eb78772ba", "2017-11-11" + ), + git.GitTag( + "ignored-0.9.1", "46e9032e18a819e466618c7a014bcb0e9981af9e", "2017-11-11" + ), + ] + + rules = changelog.TagRules( + scheme=Pep440, + tag_format="v$version", + legacy_tag_formats=["$version", "project-${version}"], + ignored_tag_formats=[ + "known", + "ignored-${version}", + "star-*", + "*-${major}.${minor}", + ], + ) + + version_tags = rules.get_version_tags(tags, warn=True) + assert {t.name for t in version_tags} == { + "v1.1.0", + "v1.0.0", + "1.0.0b2", + "project-0.9.3", + "project-0.9.2", + } + + captured = capsys.readouterr() + assert captured.err.count("InvalidVersion") == 2 + assert captured.err.count("not-a-version") == 2 diff --git a/tests/test_changelog_format_asciidoc.py b/tests/test_changelog_format_asciidoc.py new file mode 100644 index 0000000..59ca561 --- /dev/null +++ b/tests/test_changelog_format_asciidoc.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from commitizen.changelog import Metadata +from commitizen.changelog_formats.asciidoc import AsciiDoc +from commitizen.config.base_config import BaseConfig + +CHANGELOG_A = """ += Changelog + +All notable changes to this project will be documented in this file. + +The format is based on https://keepachangelog.com/en/1.0.0/[Keep a Changelog], +and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Versioning]. + +== [Unreleased] +* Start using "changelog" over "change log" since it's the common usage. + +== [1.0.0] - 2017-06-20 +=== Added +* New visual identity by https://github.com/tylerfortune8[@tylerfortune8]. +* Version navigation. +""".strip() + +EXPECTED_A = Metadata( + latest_version="1.0.0", + latest_version_position=10, + unreleased_end=10, + unreleased_start=7, +) + + +CHANGELOG_B = """ +== [Unreleased] +* Start using "changelog" over "change log" since it's the common usage. + +== 1.2.0 +""".strip() + +EXPECTED_B = Metadata( + latest_version="1.2.0", + latest_version_position=3, + unreleased_end=3, + unreleased_start=0, +) + + +CHANGELOG_C = """ += Unreleased + +== v1.0.0 +""" +EXPECTED_C = Metadata( + latest_version="1.0.0", + latest_version_tag="v1.0.0", + latest_version_position=3, + unreleased_end=3, + unreleased_start=1, +) + +CHANGELOG_D = """ +== Unreleased +* Start using "changelog" over "change log" since it's the common usage. +""" + +EXPECTED_D = Metadata( + latest_version=None, + latest_version_position=None, + unreleased_end=2, + unreleased_start=1, +) + +CHANGELOG_E = """ += Changelog + +All notable changes to this project will be documented in this file. + +The format is based on https://keepachangelog.com/en/1.0.0/[Keep a Changelog], +and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Versioning]. + +== [Unreleased] +* Start using "changelog" over "change log" since it's the common usage. + +== [{tag_formatted_version}] - 2017-06-20 +=== Added +* New visual identity by https://github.com/tylerfortune8[@tylerfortune8]. +* Version navigation. +""".strip() + +EXPECTED_E = Metadata( + latest_version="1.0.0", + latest_version_position=10, + unreleased_end=10, + unreleased_start=7, +) + + +@pytest.fixture +def format(config: BaseConfig) -> AsciiDoc: + return AsciiDoc(config) + + +@pytest.fixture +def format_with_tags(config: BaseConfig, request) -> AsciiDoc: + config.settings["tag_format"] = request.param + config.settings["legacy_tag_formats"] = ["legacy-${version}"] + return AsciiDoc(config) + + +VERSIONS_EXAMPLES = [ + ("== [1.0.0] - 2017-06-20", ("1.0.0", "1.0.0")), + ( + "= https://github.com/angular/angular/compare/10.0.0-next.2...10.0.0-next.3[10.0.0-next.3] (2020-04-22)", + ("10.0.0-next.3", "10.0.0-next.3"), + ), + ("=== 0.19.1 (Jan 7, 2020)", ("0.19.1", "0.19.1")), + ("== 1.0.0", ("1.0.0", "1.0.0")), + ("== v1.0.0", ("1.0.0", "v1.0.0")), + ("== v1.0.0 - (2012-24-32)", ("1.0.0", "v1.0.0")), + ("= version 2020.03.24", ("2020.03.24", "2020.03.24")), + ("== [Unreleased]", None), + ("All notable changes to this project will be documented in this file.", None), + ("= Changelog", None), + ("=== Bug Fixes", None), +] + + +@pytest.mark.parametrize("line_from_changelog,output_version", VERSIONS_EXAMPLES) +def test_changelog_detect_version( + line_from_changelog: str, output_version: tuple[str, str] | None, format: AsciiDoc +): + version = format.parse_version_from_title(line_from_changelog) + assert version == output_version + + +TITLES_EXAMPLES = [ + ("== [1.0.0] - 2017-06-20", 2), + ("== [Unreleased]", 2), + ("= Unreleased", 1), +] + + +@pytest.mark.parametrize("line_from_changelog,output_title", TITLES_EXAMPLES) +def test_parse_title_type_of_line( + line_from_changelog: str, output_title: str, format: AsciiDoc +): + title = format.parse_title_level(line_from_changelog) + assert title == output_title + + +@pytest.mark.parametrize( + "content, expected", + ( + pytest.param(CHANGELOG_A, EXPECTED_A, id="A"), + pytest.param(CHANGELOG_B, EXPECTED_B, id="B"), + pytest.param(CHANGELOG_C, EXPECTED_C, id="C"), + pytest.param(CHANGELOG_D, EXPECTED_D, id="D"), + ), +) +def test_get_matadata( + tmp_path: Path, format: AsciiDoc, content: str, expected: Metadata +): + changelog = tmp_path / format.default_changelog_file + changelog.write_text(content) + + assert format.get_metadata(str(changelog)) == expected + + +@pytest.mark.parametrize( + "format_with_tags, tag_string, expected, ", + ( + pytest.param("${version}-example", "1.0.0-example", "1.0.0"), + pytest.param("${version}example", "1.0.0example", "1.0.0"), + pytest.param("example${version}", "example1.0.0", "1.0.0"), + pytest.param("example-${version}", "example-1.0.0", "1.0.0"), + pytest.param("example-${major}-${minor}-${patch}", "example-1-0-0", "1.0.0"), + pytest.param("example-${major}-${minor}", "example-1-0-0", None), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}-example", + "1-0-0-rc1-example", + "1.0.0-rc1", + ), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}${devrelease}-example", + "1-0-0-a1.dev1-example", + "1.0.0-a1.dev1", + ), + pytest.param("new-${version}", "legacy-1.0.0", "1.0.0"), + ), + indirect=["format_with_tags"], +) +def test_get_metadata_custom_tag_format( + tmp_path: Path, format_with_tags: AsciiDoc, tag_string: str, expected: Metadata +): + content = CHANGELOG_E.format(tag_formatted_version=tag_string) + changelog = tmp_path / format_with_tags.default_changelog_file + changelog.write_text(content) + assert format_with_tags.get_metadata(str(changelog)).latest_version == expected diff --git a/tests/test_changelog_format_markdown.py b/tests/test_changelog_format_markdown.py new file mode 100644 index 0000000..e1f0d67 --- /dev/null +++ b/tests/test_changelog_format_markdown.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from commitizen.changelog import Metadata +from commitizen.changelog_formats.markdown import Markdown +from commitizen.config.base_config import BaseConfig + +CHANGELOG_A = """ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +- Start using "changelog" over "change log" since it's the common usage. + +## [1.0.0] - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. +""".strip() + +EXPECTED_A = Metadata( + latest_version="1.0.0", + latest_version_position=10, + unreleased_end=10, + unreleased_start=7, +) + + +CHANGELOG_B = """ +## [Unreleased] +- Start using "changelog" over "change log" since it's the common usage. + +## 1.2.0 +""".strip() + +EXPECTED_B = Metadata( + latest_version="1.2.0", + latest_version_position=3, + unreleased_end=3, + unreleased_start=0, +) + + +CHANGELOG_C = """ +# Unreleased + +## v1.0.0 +""" +EXPECTED_C = Metadata( + latest_version="1.0.0", + latest_version_tag="v1.0.0", + latest_version_position=3, + unreleased_end=3, + unreleased_start=1, +) + +CHANGELOG_D = """ +## Unreleased +- Start using "changelog" over "change log" since it's the common usage. +""" + +EXPECTED_D = Metadata( + latest_version=None, + latest_version_position=None, + unreleased_end=2, + unreleased_start=1, +) + +CHANGELOG_E = """ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +- Start using "changelog" over "change log" since it's the common usage. + +## {tag_formatted_version} - 2017-06-20 +### Added +- New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +- Version navigation. +""".strip() + +EXPECTED_E = Metadata( + latest_version="1.0.0", + latest_version_position=10, + unreleased_end=10, + unreleased_start=7, +) + + +@pytest.fixture +def format(config: BaseConfig) -> Markdown: + return Markdown(config) + + +@pytest.fixture +def format_with_tags(config: BaseConfig, request) -> Markdown: + config.settings["tag_format"] = request.param + config.settings["legacy_tag_formats"] = ["legacy-${version}"] + return Markdown(config) + + +VERSIONS_EXAMPLES = [ + ("## [1.0.0] - 2017-06-20", ("1.0.0", "1.0.0")), + ( + "# [10.0.0-next.3](https://github.com/angular/angular/compare/10.0.0-next.2...10.0.0-next.3) (2020-04-22)", + ("10.0.0-next.3", "10.0.0-next.3"), + ), + ("### 0.19.1 (Jan 7, 2020)", ("0.19.1", "0.19.1")), + ("## 1.0.0", ("1.0.0", "1.0.0")), + ("## v1.0.0", ("1.0.0", "v1.0.0")), + ("## v1.0.0 - (2012-24-32)", ("1.0.0", "v1.0.0")), + ("# version 2020.03.24", ("2020.03.24", "2020.03.24")), + ("## [Unreleased]", None), + ("All notable changes to this project will be documented in this file.", None), + ("# Changelog", None), + ("### Bug Fixes", None), +] + + +@pytest.mark.parametrize("line_from_changelog,output_version", VERSIONS_EXAMPLES) +def test_changelog_detect_version( + line_from_changelog: str, output_version: tuple[str, str] | None, format: Markdown +): + version = format.parse_version_from_title(line_from_changelog) + assert version == output_version + + +TITLES_EXAMPLES = [ + ("## [1.0.0] - 2017-06-20", 2), + ("## [Unreleased]", 2), + ("# Unreleased", 1), +] + + +@pytest.mark.parametrize("line_from_changelog,output_title", TITLES_EXAMPLES) +def test_parse_title_type_of_line( + line_from_changelog: str, output_title: str, format: Markdown +): + title = format.parse_title_level(line_from_changelog) + assert title == output_title + + +@pytest.mark.parametrize( + "content, expected", + ( + pytest.param(CHANGELOG_A, EXPECTED_A, id="A"), + pytest.param(CHANGELOG_B, EXPECTED_B, id="B"), + pytest.param(CHANGELOG_C, EXPECTED_C, id="C"), + pytest.param(CHANGELOG_D, EXPECTED_D, id="D"), + ), +) +def test_get_matadata( + tmp_path: Path, format: Markdown, content: str, expected: Metadata +): + changelog = tmp_path / format.default_changelog_file + changelog.write_text(content) + + assert format.get_metadata(str(changelog)) == expected + + +@pytest.mark.parametrize( + "format_with_tags, tag_string, expected, ", + ( + pytest.param("${version}-example", "1.0.0-example", "1.0.0"), + pytest.param("${version}example", "1.0.0example", "1.0.0"), + pytest.param("example${version}", "example1.0.0", "1.0.0"), + pytest.param("example-${version}", "example-1.0.0", "1.0.0"), + pytest.param("example-${major}-${minor}-${patch}", "example-1-0-0", "1.0.0"), + pytest.param("example-${major}-${minor}", "example-1-0-0", None), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}-example", + "1-0-0-rc1-example", + "1.0.0-rc1", + ), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}-example", + "1-0-0-a1-example", + "1.0.0-a1", + ), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}${devrelease}-example", + "1-0-0-a1.dev1-example", + "1.0.0-a1.dev1", + ), + pytest.param("new-${version}", "legacy-1.0.0", "1.0.0"), + ), + indirect=["format_with_tags"], +) +def test_get_metadata_custom_tag_format( + tmp_path: Path, format_with_tags: Markdown, tag_string: str, expected: Metadata +): + content = CHANGELOG_E.format(tag_formatted_version=tag_string) + changelog = tmp_path / format_with_tags.default_changelog_file + changelog.write_text(content) + + assert format_with_tags.get_metadata(str(changelog)).latest_version == expected diff --git a/tests/test_changelog_format_restructuredtext.py b/tests/test_changelog_format_restructuredtext.py new file mode 100644 index 0000000..74b6b73 --- /dev/null +++ b/tests/test_changelog_format_restructuredtext.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent +from typing import TYPE_CHECKING + +import pytest + +from commitizen.changelog import Metadata +from commitizen.changelog_formats.restructuredtext import RestructuredText +from commitizen.config.base_config import BaseConfig + +if TYPE_CHECKING: + from _pytest.mark.structures import ParameterSet + + +CASES: list[ParameterSet] = [] + + +def case( + id: str, + content: str, + latest_version: str | None = None, + latest_version_position: int | None = None, + latest_version_tag: str | None = None, + unreleased_start: int | None = None, + unreleased_end: int | None = None, +): + CASES.append( + pytest.param( + dedent(content).strip(), + Metadata( + latest_version=latest_version, + latest_version_tag=latest_version_tag, + latest_version_position=latest_version_position, + unreleased_start=unreleased_start, + unreleased_end=unreleased_end, + ), + id=id, + ) + ) + + +case( + "underlined title with intro and unreleased section", + """ + Changelog + ######### + + All notable changes to this project will be documented in this file. + + The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.0.0/>`, + and this project adheres to `Semantic Versioning <https://semver.org/spec/v2.0.0.html>`. + + Unreleased + ========== + * Start using "changelog" over "change log" since it's the common usage. + + 1.0.0 - 2017-06-20 + ================== + Added + ----- + * New visual identity by `@tylerfortune8 <https://github.com/tylerfortune8>`. + * Version navigation. + """, + latest_version="1.0.0", + latest_version_position=12, + unreleased_start=8, + unreleased_end=12, +) + +case( + "unreleased section without preamble", + """ + Unreleased + ========== + * Start using "changelog" over "change log" since it's the common usage. + + 1.2.0 + ===== + """, + latest_version="1.2.0", + latest_version_position=4, + unreleased_start=0, + unreleased_end=4, +) + +case( + "basic underlined titles with v-prefixed version", + """ + Unreleased + ========== + + v1.0.0 + ====== + """, + latest_version="1.0.0", + latest_version_tag="v1.0.0", + latest_version_position=3, + unreleased_start=0, + unreleased_end=3, +) + +case( + "intermediate section in unreleased", + """ + Unreleased + ========== + + intermediate + ------------ + + 1.0.0 + ===== + """, + latest_version="1.0.0", + latest_version_position=6, + unreleased_start=0, + unreleased_end=6, +) + +case( + "weird section with different level than versions", + """ + Unreleased + ########## + + 1.0.0 + ===== + """, + latest_version="1.0.0", + latest_version_position=3, + unreleased_start=0, + unreleased_end=3, +) + +case( + "overlined title without release and intro", + """ + ========== + Unreleased + ========== + * Start using "changelog" over "change log" since it's the common usage. + """, + unreleased_start=0, + unreleased_end=4, +) + +case( + "underlined title with date", + """ + 1.0.0 - 2017-06-20 + ================== + """, + latest_version="1.0.0", + latest_version_position=0, +) + + +UNDERLINED_TITLES = ( + """ + title + ===== + """, + """ + title + ====== + """, + """ + title + ##### + """, + """ + title + ..... + """, + """ + title + !!!!! + """, +) + +NOT_UNDERLINED_TITLES = ( + """ + title + =.=.= + """, + """ + title + ==== + """, + """ + title + aaaaa + """, + """ + title + + """, +) + + +OVERLINED_TITLES = ( + """ + ===== + title + ===== + """, + """ + ====== + title + ====== + """, + """ + ##### + title + ##### + """, + """ + ..... + title + ..... + """, +) + +NOT_OVERLINED_TITLES = ( + """ + ==== + title + ===== + """, + """ + ===== + title + ==== + """, + """ + ==== + title + ==== + """, + """ + ===== + title + ##### + """, + """ + ##### + title + ===== + """, + """ + =.=.= + title + ===== + """, + """ + ===== + title + =.=.= + """, + """ + + title + ===== + """, + """ + ===== + title + + """, + """ + aaaaa + title + aaaaa + """, +) + +CHANGELOG = """ +Changelog + ######### + + All notable changes to this project will be documented in this file. + + The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.0.0/>`, + and this project adheres to `Semantic Versioning <https://semver.org/spec/v2.0.0.html>`. + + Unreleased + ========== + * Start using "changelog" over "change log" since it's the common usage. + + {tag_formatted_version} - 2017-06-20 + {underline} + Added + ----- + * New visual identity by `@tylerfortune8 <https://github.com/tylerfortune8>`. + * Version navigation. +""".strip() + + +@pytest.fixture +def format(config: BaseConfig) -> RestructuredText: + return RestructuredText(config) + + +@pytest.fixture +def format_with_tags(config: BaseConfig, request) -> RestructuredText: + config.settings["tag_format"] = request.param + config.settings["legacy_tag_formats"] = ["legacy-${version}"] + return RestructuredText(config) + + +@pytest.mark.parametrize("content, expected", CASES) +def test_get_matadata( + tmp_path: Path, format: RestructuredText, content: str, expected: Metadata +): + changelog = tmp_path / format.default_changelog_file + changelog.write_text(content) + + assert format.get_metadata(str(changelog)) == expected + + +@pytest.mark.parametrize( + "text, expected", + [(text, True) for text in UNDERLINED_TITLES] + + [(text, False) for text in NOT_UNDERLINED_TITLES], +) +def test_is_underlined_title(format: RestructuredText, text: str, expected: bool): + _, first, second = dedent(text).splitlines() + assert format.is_underlined_title(first, second) is expected + + +@pytest.mark.parametrize( + "text, expected", + [(text, True) for text in OVERLINED_TITLES] + + [(text, False) for text in NOT_OVERLINED_TITLES], +) +def test_is_overlined_title(format: RestructuredText, text: str, expected: bool): + _, first, second, third = dedent(text).splitlines() + + assert format.is_overlined_title(first, second, third) is expected + + +@pytest.mark.parametrize( + "format_with_tags, tag_string, expected, ", + ( + pytest.param("${version}-example", "1.0.0-example", "1.0.0"), + pytest.param("${version}", "1.0.0", "1.0.0"), + pytest.param("${version}example", "1.0.0example", "1.0.0"), + pytest.param("example${version}", "example1.0.0", "1.0.0"), + pytest.param("example-${version}", "example-1.0.0", "1.0.0"), + pytest.param("example-${major}-${minor}-${patch}", "example-1-0-0", "1.0.0"), + pytest.param("example-${major}-${minor}", "example-1-0-0", None), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}-example", + "1-0-0-rc1-example", + "1.0.0-rc1", + ), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}${devrelease}-example", + "1-0-0-a1.dev1-example", + "1.0.0-a1.dev1", + ), + pytest.param("new-${version}", "legacy-1.0.0", "1.0.0"), + ), + indirect=["format_with_tags"], +) +def test_get_metadata_custom_tag_format( + tmp_path: Path, + format_with_tags: RestructuredText, + tag_string: str, + expected: Metadata, +): + content = CHANGELOG.format( + tag_formatted_version=tag_string, + underline="=" * len(tag_string) + "=============", + ) + changelog = tmp_path / format_with_tags.default_changelog_file + changelog.write_text(content) + + assert format_with_tags.get_metadata(str(changelog)).latest_version == expected diff --git a/tests/test_changelog_format_textile.py b/tests/test_changelog_format_textile.py new file mode 100644 index 0000000..eb03484 --- /dev/null +++ b/tests/test_changelog_format_textile.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from commitizen.changelog import Metadata +from commitizen.changelog_formats.textile import Textile +from commitizen.config.base_config import BaseConfig + +CHANGELOG_A = """ +h1. Changelog + +All notable changes to this project will be documented in this file. + +The format is based on "Keep a Changelog":https://keepachangelog.com/en/1.0.0/, +and this project adheres to "Semantic Versioning":https://semver.org/spec/v2.0.0.html. + +h2. [Unreleased] +- Start using "changelog" over "change log" since it's the common usage. + +h2. [1.0.0] - 2017-06-20 +h3. Added +* New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +* Version navigation. +""".strip() + +EXPECTED_A = Metadata( + latest_version="1.0.0", + latest_version_position=10, + unreleased_end=10, + unreleased_start=7, +) + + +CHANGELOG_B = """ +h2. [Unreleased] +* Start using "changelog" over "change log" since it's the common usage. + +h2. 1.2.0 +""".strip() + +EXPECTED_B = Metadata( + latest_version="1.2.0", + latest_version_position=3, + unreleased_end=3, + unreleased_start=0, +) + + +CHANGELOG_C = """ +h1. Unreleased + +h2. v1.0.0 +""" +EXPECTED_C = Metadata( + latest_version="1.0.0", + latest_version_tag="v1.0.0", + latest_version_position=3, + unreleased_end=3, + unreleased_start=1, +) + +CHANGELOG_D = """ +h2. Unreleased +* Start using "changelog" over "change log" since it's the common usage. +""" + +EXPECTED_D = Metadata( + latest_version=None, + latest_version_position=None, + unreleased_end=2, + unreleased_start=1, +) + +CHANGELOG_E = """ +h1. Changelog + +All notable changes to this project will be documented in this file. + +The format is based on "Keep a Changelog":https://keepachangelog.com/en/1.0.0/, +and this project adheres to "Semantic Versioning":https://semver.org/spec/v2.0.0.html. + +h2. [Unreleased] +- Start using "changelog" over "change log" since it's the common usage. + +h2. [{tag_formatted_version}] - 2017-06-20 +h3. Added +* New visual identity by [@tylerfortune8](https://github.com/tylerfortune8). +* Version navigation. +""".strip() + + +@pytest.fixture +def format(config: BaseConfig) -> Textile: + return Textile(config) + + +@pytest.fixture +def format_with_tags(config: BaseConfig, request) -> Textile: + config.settings["tag_format"] = request.param + config.settings["legacy_tag_formats"] = ["legacy-${version}"] + return Textile(config) + + +VERSIONS_EXAMPLES = [ + ("h2. [1.0.0] - 2017-06-20", ("1.0.0", "1.0.0")), + ( + 'h1. "10.0.0-next.3":https://github.com/angular/angular/compare/10.0.0-next.2...10.0.0-next.3 (2020-04-22)', + ("10.0.0-next.3", "10.0.0-next.3"), + ), + ("h3. 0.19.1 (Jan 7, 2020)", ("0.19.1", "0.19.1")), + ("h2. 1.0.0", ("1.0.0", "1.0.0")), + ("h2. v1.0.0", ("1.0.0", "v1.0.0")), + ("h2. v1.0.0 - (2012-24-32)", ("1.0.0", "v1.0.0")), + ("h1. version 2020.03.24", ("2020.03.24", "2020.03.24")), + ("h2. [Unreleased]", None), + ("All notable changes to this project will be documented in this file.", None), + ("h1. Changelog", None), + ("h3. Bug Fixes", None), +] + + +@pytest.mark.parametrize("line_from_changelog,output_version", VERSIONS_EXAMPLES) +def test_changelog_detect_version( + line_from_changelog: str, output_version: tuple[str, str] | None, format: Textile +): + version = format.parse_version_from_title(line_from_changelog) + assert version == output_version + + +TITLES_EXAMPLES = [ + ("h2. [1.0.0] - 2017-06-20", 2), + ("h2. [Unreleased]", 2), + ("h1. Unreleased", 1), +] + + +@pytest.mark.parametrize("line_from_changelog,output_title", TITLES_EXAMPLES) +def test_parse_title_type_of_line( + line_from_changelog: str, output_title: str, format: Textile +): + title = format.parse_title_level(line_from_changelog) + assert title == output_title + + +@pytest.mark.parametrize( + "content, expected", + ( + pytest.param(CHANGELOG_A, EXPECTED_A, id="A"), + pytest.param(CHANGELOG_B, EXPECTED_B, id="B"), + pytest.param(CHANGELOG_C, EXPECTED_C, id="C"), + pytest.param(CHANGELOG_D, EXPECTED_D, id="D"), + ), +) +def test_get_matadata( + tmp_path: Path, format: Textile, content: str, expected: Metadata +): + changelog = tmp_path / format.default_changelog_file + changelog.write_text(content) + + assert format.get_metadata(str(changelog)) == expected + + +@pytest.mark.parametrize( + "format_with_tags, tag_string, expected, ", + ( + pytest.param("${version}-example", "1.0.0-example", "1.0.0"), + pytest.param("${version}example", "1.0.0example", "1.0.0"), + pytest.param("example${version}", "example1.0.0", "1.0.0"), + pytest.param("example-${version}", "example-1.0.0", "1.0.0"), + pytest.param("example-${major}-${minor}-${patch}", "example-1-0-0", "1.0.0"), + pytest.param("example-${major}-${minor}", "example-1-0-0", None), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}-example", + "1-0-0-rc1-example", + "1.0.0-rc1", + ), + pytest.param( + "${major}-${minor}-${patch}-${prerelease}${devrelease}-example", + "1-0-0-a1.dev1-example", + "1.0.0-a1.dev1", + ), + pytest.param("new-${version}", "legacy-1.0.0", "1.0.0"), + ), + indirect=["format_with_tags"], +) +def test_get_metadata_custom_tag_format( + tmp_path: Path, format_with_tags: Textile, tag_string: str, expected: Metadata +): + content = CHANGELOG_E.format(tag_formatted_version=tag_string) + changelog = tmp_path / format_with_tags.default_changelog_file + changelog.write_text(content) + + assert format_with_tags.get_metadata(str(changelog)).latest_version == expected diff --git a/tests/test_changelog_formats.py b/tests/test_changelog_formats.py new file mode 100644 index 0000000..dec2372 --- /dev/null +++ b/tests/test_changelog_formats.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import pytest + +from commitizen import defaults +from commitizen.changelog_formats import ( + KNOWN_CHANGELOG_FORMATS, + ChangelogFormat, + get_changelog_format, + guess_changelog_format, +) +from commitizen.config.base_config import BaseConfig +from commitizen.exceptions import ChangelogFormatUnknown + + +@pytest.mark.parametrize("format", KNOWN_CHANGELOG_FORMATS.values()) +def test_guess_format(format: type[ChangelogFormat]): + assert guess_changelog_format(f"CHANGELOG.{format.extension}") is format + for ext in format.alternative_extensions: + assert guess_changelog_format(f"CHANGELOG.{ext}") is format + + +@pytest.mark.parametrize("filename", ("CHANGELOG", "NEWS", "file.unknown", None)) +def test_guess_format_unknown(filename: str): + assert guess_changelog_format(filename) is None + + +@pytest.mark.parametrize( + "name, expected", + [ + pytest.param(name, format, id=name) + for name, format in KNOWN_CHANGELOG_FORMATS.items() + ], +) +def test_get_format(config: BaseConfig, name: str, expected: type[ChangelogFormat]): + config.settings["changelog_format"] = name + assert isinstance(get_changelog_format(config), expected) + + +@pytest.mark.parametrize("filename", (None, "")) +def test_get_format_empty_filename(config: BaseConfig, filename: str | None): + config.settings["changelog_format"] = defaults.CHANGELOG_FORMAT + assert isinstance( + get_changelog_format(config, filename), + KNOWN_CHANGELOG_FORMATS[defaults.CHANGELOG_FORMAT], + ) + + +@pytest.mark.parametrize("filename", (None, "")) +def test_get_format_empty_filename_no_setting(config: BaseConfig, filename: str | None): + config.settings["changelog_format"] = None + with pytest.raises(ChangelogFormatUnknown): + get_changelog_format(config, filename) + + +@pytest.mark.parametrize("filename", ("extensionless", "file.unknown")) +def test_get_format_unknown(config: BaseConfig, filename: str | None): + with pytest.raises(ChangelogFormatUnknown): + get_changelog_format(config, filename) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a91e633 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,184 @@ +import os +import subprocess +import sys +from functools import partial + +import pytest +from pytest_mock import MockFixture + +from commitizen import cli +from commitizen.exceptions import ( + ConfigFileNotFound, + ExpectedExit, + InvalidCommandArgumentError, + NoCommandFoundError, + NotAGitProjectError, +) + + +def test_sysexit_no_argv(mocker: MockFixture, capsys): + testargs = ["cz"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(ExpectedExit): + cli.main() + out, _ = capsys.readouterr() + assert out.startswith("usage") + + +def test_cz_config_file_without_correct_file_path(mocker: MockFixture, capsys): + testargs = ["cz", "--config", "./config/pyproject.toml", "example"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(ConfigFileNotFound) as excinfo: + cli.main() + assert "Cannot found the config file" in str(excinfo.value) + + +def test_cz_with_arg_but_without_command(mocker: MockFixture): + testargs = ["cz", "--name", "cz_jira"] + mocker.patch.object(sys, "argv", testargs) + + with pytest.raises(NoCommandFoundError) as excinfo: + cli.main() + assert "Command is required" in str(excinfo.value) + + +def test_name(mocker: MockFixture, capsys): + testargs = ["cz", "-n", "cz_jira", "example"] + mocker.patch.object(sys, "argv", testargs) + + cli.main() + out, _ = capsys.readouterr() + assert out.startswith("JRA") + + +@pytest.mark.usefixtures("tmp_git_project") +def test_name_default_value(mocker: MockFixture, capsys): + testargs = ["cz", "example"] + mocker.patch.object(sys, "argv", testargs) + + cli.main() + out, _ = capsys.readouterr() + assert out.startswith("fix: correct minor typos in code") + + +def test_ls(mocker: MockFixture, capsys): + testargs = ["cz", "-n", "cz_jira", "ls"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + out, err = capsys.readouterr() + + assert "cz_conventional_commits" in out + assert isinstance(out, str) + + +def test_arg_debug(mocker: MockFixture): + testargs = ["cz", "--debug", "info"] + mocker.patch.object(sys, "argv", testargs) + cli.main() + excepthook = sys.excepthook + # `sys.excepthook` is replaced by a `partial` in `cli.main` + # it's not a normal function + assert isinstance(excepthook, partial) + assert excepthook.keywords.get("debug") is True + + +def test_commitizen_excepthook(capsys): + with pytest.raises(SystemExit) as excinfo: + cli.commitizen_excepthook(NotAGitProjectError, NotAGitProjectError(), "") + + assert excinfo.type is SystemExit + assert excinfo.value.code == NotAGitProjectError.exit_code + + +def test_commitizen_debug_excepthook(capsys): + with pytest.raises(SystemExit) as excinfo: + cli.commitizen_excepthook( + NotAGitProjectError, + NotAGitProjectError(), + "", + debug=True, + ) + + assert excinfo.type is SystemExit + assert excinfo.value.code == NotAGitProjectError.exit_code + assert "NotAGitProjectError" in str(excinfo.traceback[0]) + + +@pytest.mark.skipif( + os.name == "nt", + reason="`argcomplete` does not support Git Bash on Windows.", +) +def test_argcomplete_activation(): + """ + This function is testing the one-time activation of argcomplete for + commitizen only. + + Equivalent to run: + $ eval "$(register-python-argcomplete pytest)" + """ + output = subprocess.run(["register-python-argcomplete", "cz"]) + + assert output.returncode == 0 + + +def test_commitizen_excepthook_no_raises(capsys): + with pytest.raises(SystemExit) as excinfo: + cli.commitizen_excepthook( + NotAGitProjectError, + NotAGitProjectError(), + "", + no_raise=[NotAGitProjectError.exit_code], + ) + + assert excinfo.type is SystemExit + assert excinfo.value.code == 0 + + +def test_parse_no_raise_single_integer(): + input_str = "1" + result = cli.parse_no_raise(input_str) + assert result == [1] + + +def test_parse_no_raise_integers(): + input_str = "1,2,3" + result = cli.parse_no_raise(input_str) + assert result == [1, 2, 3] + + +def test_parse_no_raise_error_code(): + input_str = "NO_COMMITIZEN_FOUND,NO_COMMITS_FOUND,NO_PATTERN_MAP" + result = cli.parse_no_raise(input_str) + assert result == [1, 3, 5] + + +def test_parse_no_raise_mix_integer_error_code(): + input_str = "NO_COMMITIZEN_FOUND,2,NO_COMMITS_FOUND,4" + result = cli.parse_no_raise(input_str) + assert result == [1, 2, 3, 4] + + +def test_parse_no_raise_mix_invalid_arg_is_skipped(): + input_str = "NO_COMMITIZEN_FOUND,2,nothing,4" + result = cli.parse_no_raise(input_str) + assert result == [1, 2, 4] + + +def test_unknown_args_raises(mocker: MockFixture): + testargs = ["cz", "c", "-this_arg_is_not_supported"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(InvalidCommandArgumentError) as excinfo: + cli.main() + assert "Invalid commitizen arguments were found" in str(excinfo.value) + + +def test_unknown_args_before_double_dash_raises(mocker: MockFixture): + testargs = ["cz", "c", "-this_arg_is_not_supported", "--"] + mocker.patch.object(sys, "argv", testargs) + with pytest.raises(InvalidCommandArgumentError) as excinfo: + cli.main() + assert "Invalid commitizen arguments were found before -- separator" in str( + excinfo.value + ) diff --git a/tests/test_cmd.py b/tests/test_cmd.py new file mode 100644 index 0000000..e8a869e --- /dev/null +++ b/tests/test_cmd.py @@ -0,0 +1,53 @@ +import pytest + +from commitizen import cmd +from commitizen.exceptions import CharacterSetDecodeError + + +# https://docs.python.org/3/howto/unicode.html +def test_valid_utf8_encoded_strings(): + valid_strings = ( + "", + "ascii", + "๐Ÿคฆ๐Ÿปโ€โ™‚๏ธ", + "๏ทฝ", + "\u0000", + ) + assert all(s == cmd._try_decode(s.encode("utf-8")) for s in valid_strings) + + +# A word of caution: just because an encoding can be guessed for a given +# sequence of bytes and because that guessed encoding may yield a decoded +# string, does not mean that that string was the original! For more, see: +# https://docs.python.org/3/library/codecs.html#standard-encodings + + +# Pick a random, non-utf8 encoding to test. +def test_valid_cp1250_encoded_strings(): + valid_strings = ( + "", + "ascii", + "รครถรผรŸ", + "รงa va", + "jak se mรกte", + ) + for s in valid_strings: + assert cmd._try_decode(s.encode("cp1250")) or True + + +def test_invalid_bytes(): + invalid_bytes = (b"\x73\xe2\x9d\xff\x00",) + for s in invalid_bytes: + with pytest.raises(CharacterSetDecodeError): + cmd._try_decode(s) + + +def test_always_fail_decode(): + class _bytes(bytes): + def decode(self, encoding="utf-8", errors="strict"): + raise UnicodeDecodeError( + encoding, self, 0, 0, "Failing intentionally for testing" + ) + + with pytest.raises(CharacterSetDecodeError): + cmd._try_decode(_bytes()) diff --git a/tests/test_conf.py b/tests/test_conf.py new file mode 100644 index 0000000..80d5898 --- /dev/null +++ b/tests/test_conf.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from commitizen import config, defaults, git +from commitizen.exceptions import ConfigFileIsEmpty, InvalidConfigurationError + +PYPROJECT = """ +[tool.commitizen] +name = "cz_jira" +version = "1.0.0" +version_files = [ + "commitizen/__version__.py", + "pyproject.toml" +] +style = [ + ["pointer", "reverse"], + ["question", "underline"] +] +pre_bump_hooks = [ + "scripts/generate_documentation.sh" +] +post_bump_hooks = ["scripts/slack_notification.sh"] + +[tool.black] +line-length = 88 +target-version = ['py36', 'py37', 'py38'] +""" + +DICT_CONFIG = { + "commitizen": { + "name": "cz_jira", + "version": "1.0.0", + "version_files": ["commitizen/__version__.py", "pyproject.toml"], + "style": [["pointer", "reverse"], ["question", "underline"]], + "pre_bump_hooks": ["scripts/generate_documentation.sh"], + "post_bump_hooks": ["scripts/slack_notification.sh"], + } +} + +JSON_STR = r""" + { + "commitizen": { + "name": "cz_jira", + "version": "1.0.0", + "version_files": [ + "commitizen/__version__.py", + "pyproject.toml" + ] + } + } +""" + +YAML_STR = """ +commitizen: + name: cz_jira + version: 1.0.0 + version_files: + - commitizen/__version__.py + - pyproject.toml +""" + +_settings: dict[str, Any] = { + "name": "cz_jira", + "version": "1.0.0", + "version_provider": "commitizen", + "version_scheme": None, + "tag_format": "$version", + "legacy_tag_formats": [], + "ignored_tag_formats": [], + "bump_message": None, + "retry_after_failure": False, + "allow_abort": False, + "allowed_prefixes": ["Merge", "Revert", "Pull request", "fixup!", "squash!"], + "version_files": ["commitizen/__version__.py", "pyproject.toml"], + "style": [["pointer", "reverse"], ["question", "underline"]], + "changelog_file": "CHANGELOG.md", + "changelog_format": None, + "changelog_incremental": False, + "changelog_start_rev": None, + "changelog_merge_prerelease": False, + "update_changelog_on_bump": False, + "use_shortcuts": False, + "major_version_zero": False, + "pre_bump_hooks": ["scripts/generate_documentation.sh"], + "post_bump_hooks": ["scripts/slack_notification.sh"], + "prerelease_offset": 0, + "encoding": "utf-8", + "always_signoff": False, + "template": None, + "extras": {}, +} + +_new_settings: dict[str, Any] = { + "name": "cz_jira", + "version": "2.0.0", + "version_provider": "commitizen", + "version_scheme": None, + "tag_format": "$version", + "legacy_tag_formats": [], + "ignored_tag_formats": [], + "bump_message": None, + "retry_after_failure": False, + "allow_abort": False, + "allowed_prefixes": ["Merge", "Revert", "Pull request", "fixup!", "squash!"], + "version_files": ["commitizen/__version__.py", "pyproject.toml"], + "style": [["pointer", "reverse"], ["question", "underline"]], + "changelog_file": "CHANGELOG.md", + "changelog_format": None, + "changelog_incremental": False, + "changelog_start_rev": None, + "changelog_merge_prerelease": False, + "update_changelog_on_bump": False, + "use_shortcuts": False, + "major_version_zero": False, + "pre_bump_hooks": ["scripts/generate_documentation.sh"], + "post_bump_hooks": ["scripts/slack_notification.sh"], + "prerelease_offset": 0, + "encoding": "utf-8", + "always_signoff": False, + "template": None, + "extras": {}, +} + + +@pytest.fixture +def config_files_manager(request, tmpdir): + with tmpdir.as_cwd(): + filename = request.param + with open(filename, "w", encoding="utf-8") as f: + if "toml" in filename: + f.write(PYPROJECT) + elif "json" in filename: + json.dump(DICT_CONFIG, f) + elif "yaml" in filename: + yaml.dump(DICT_CONFIG, f) + yield + + +def test_find_git_project_root(tmpdir): + assert git.find_git_project_root() == Path(os.getcwd()) + + with tmpdir.as_cwd() as _: + assert git.find_git_project_root() is None + + +@pytest.mark.parametrize( + "config_files_manager", defaults.config_files.copy(), indirect=True +) +def test_set_key(config_files_manager): + _conf = config.read_cfg() + _conf.set_key("version", "2.0.0") + cfg = config.read_cfg() + assert cfg.settings == _new_settings + + +class TestReadCfg: + @pytest.mark.parametrize( + "config_files_manager", defaults.config_files.copy(), indirect=True + ) + def test_load_conf(_, config_files_manager): + cfg = config.read_cfg() + assert cfg.settings == _settings + + def test_conf_returns_default_when_no_files(_, tmpdir): + with tmpdir.as_cwd(): + cfg = config.read_cfg() + assert cfg.settings == defaults.DEFAULT_SETTINGS + + def test_load_empty_pyproject_toml_and_cz_toml_with_config(_, tmpdir): + with tmpdir.as_cwd(): + p = tmpdir.join("pyproject.toml") + p.write("") + p = tmpdir.join(".cz.toml") + p.write(PYPROJECT) + + cfg = config.read_cfg() + assert cfg.settings == _settings + + def test_load_pyproject_toml_from_config_argument(_, tmpdir): + with tmpdir.as_cwd(): + _not_root_path = tmpdir.mkdir("not_in_root").join("pyproject.toml") + _not_root_path.write(PYPROJECT) + + cfg = config.read_cfg(filepath="./not_in_root/pyproject.toml") + assert cfg.settings == _settings + + def test_load_cz_json_not_from_config_argument(_, tmpdir): + with tmpdir.as_cwd(): + _not_root_path = tmpdir.mkdir("not_in_root").join(".cz.json") + _not_root_path.write(JSON_STR) + + cfg = config.read_cfg(filepath="./not_in_root/.cz.json") + json_cfg_by_class = config.JsonConfig(data=JSON_STR, path=_not_root_path) + assert cfg.settings == json_cfg_by_class.settings + + def test_load_cz_yaml_not_from_config_argument(_, tmpdir): + with tmpdir.as_cwd(): + _not_root_path = tmpdir.mkdir("not_in_root").join(".cz.yaml") + _not_root_path.write(YAML_STR) + + cfg = config.read_cfg(filepath="./not_in_root/.cz.yaml") + yaml_cfg_by_class = config.YAMLConfig(data=YAML_STR, path=_not_root_path) + assert cfg.settings == yaml_cfg_by_class._settings + + def test_load_empty_pyproject_toml_from_config_argument(_, tmpdir): + with tmpdir.as_cwd(): + _not_root_path = tmpdir.mkdir("not_in_root").join("pyproject.toml") + _not_root_path.write("") + + with pytest.raises(ConfigFileIsEmpty): + config.read_cfg(filepath="./not_in_root/pyproject.toml") + + +@pytest.mark.parametrize( + "config_file, exception_string", + [ + (".cz.toml", r"\.cz\.toml"), + ("cz.toml", r"cz\.toml"), + ("pyproject.toml", r"pyproject\.toml"), + ], + ids=[".cz.toml", "cz.toml", "pyproject.toml"], +) +class TestTomlConfig: + def test_init_empty_config_content(self, tmpdir, config_file, exception_string): + path = tmpdir.mkdir("commitizen").join(config_file) + toml_config = config.TomlConfig(data="", path=path) + toml_config.init_empty_config_content() + + with open(path, encoding="utf-8") as toml_file: + assert toml_file.read() == "[tool.commitizen]\n" + + def test_init_empty_config_content_with_existing_content( + self, tmpdir, config_file, exception_string + ): + existing_content = "[tool.black]\nline-length = 88\n" + + path = tmpdir.mkdir("commitizen").join(config_file) + path.write(existing_content) + toml_config = config.TomlConfig(data="", path=path) + toml_config.init_empty_config_content() + + with open(path, encoding="utf-8") as toml_file: + assert toml_file.read() == existing_content + "\n[tool.commitizen]\n" + + def test_init_with_invalid_config_content( + self, tmpdir, config_file, exception_string + ): + existing_content = "invalid toml content" + path = tmpdir.mkdir("commitizen").join(config_file) + + with pytest.raises(InvalidConfigurationError, match=exception_string): + config.TomlConfig(data=existing_content, path=path) + + +@pytest.mark.parametrize( + "config_file, exception_string", + [ + (".cz.json", r"\.cz\.json"), + ("cz.json", r"cz\.json"), + ], + ids=[".cz.json", "cz.json"], +) +class TestJsonConfig: + def test_init_empty_config_content(self, tmpdir, config_file, exception_string): + path = tmpdir.mkdir("commitizen").join(config_file) + json_config = config.JsonConfig(data="{}", path=path) + json_config.init_empty_config_content() + + with open(path, encoding="utf-8") as json_file: + assert json.load(json_file) == {"commitizen": {}} + + def test_init_with_invalid_config_content( + self, tmpdir, config_file, exception_string + ): + existing_content = "invalid json content" + path = tmpdir.mkdir("commitizen").join(config_file) + + with pytest.raises(InvalidConfigurationError, match=exception_string): + config.JsonConfig(data=existing_content, path=path) + + +@pytest.mark.parametrize( + "config_file, exception_string", + [ + (".cz.yaml", r"\.cz\.yaml"), + ("cz.yaml", r"cz\.yaml"), + ], + ids=[".cz.yaml", "cz.yaml"], +) +class TestYamlConfig: + def test_init_empty_config_content(self, tmpdir, config_file, exception_string): + path = tmpdir.mkdir("commitizen").join(config_file) + yaml_config = config.YAMLConfig(data="{}", path=path) + yaml_config.init_empty_config_content() + + with open(path) as yaml_file: + assert yaml.safe_load(yaml_file) == {"commitizen": {}} + + def test_init_with_invalid_content(self, tmpdir, config_file, exception_string): + existing_content = "invalid: .cz.yaml: content: maybe?" + path = tmpdir.mkdir("commitizen").join(config_file) + + with pytest.raises(InvalidConfigurationError, match=exception_string): + config.YAMLConfig(data=existing_content, path=path) diff --git a/tests/test_cz_base.py b/tests/test_cz_base.py new file mode 100644 index 0000000..4ee1cc6 --- /dev/null +++ b/tests/test_cz_base.py @@ -0,0 +1,50 @@ +import pytest + +from commitizen.cz.base import BaseCommitizen + + +class DummyCz(BaseCommitizen): + def questions(self): + return [{"type": "input", "name": "commit", "message": "Initial commit:\n"}] + + def message(self, answers: dict): + return answers["commit"] + + +def test_base_raises_error(config): + with pytest.raises(TypeError): + BaseCommitizen(config) + + +def test_questions(config): + cz = DummyCz(config) + assert isinstance(cz.questions(), list) + + +def test_message(config): + cz = DummyCz(config) + assert cz.message({"commit": "holis"}) == "holis" + + +def test_example(config): + cz = DummyCz(config) + with pytest.raises(NotImplementedError): + cz.example() + + +def test_schema(config): + cz = DummyCz(config) + with pytest.raises(NotImplementedError): + cz.schema() + + +def test_info(config): + cz = DummyCz(config) + with pytest.raises(NotImplementedError): + cz.info() + + +def test_process_commit(config): + cz = DummyCz(config) + message = cz.process_commit("test(test_scope): this is test msg") + assert message == "test(test_scope): this is test msg" diff --git a/tests/test_cz_conventional_commits.py b/tests/test_cz_conventional_commits.py new file mode 100644 index 0000000..6d4e0f7 --- /dev/null +++ b/tests/test_cz_conventional_commits.py @@ -0,0 +1,155 @@ +import pytest + +from commitizen.cz.conventional_commits.conventional_commits import ( + ConventionalCommitsCz, + parse_scope, + parse_subject, +) +from commitizen.cz.exceptions import AnswerRequiredError + +valid_scopes = ["", "simple", "dash-separated", "camelCaseUPPERCASE"] + +scopes_transformations = [["with spaces", "with-spaces"], [None, ""]] + +valid_subjects = ["this is a normal text", "aword"] + +subjects_transformations = [["with dot.", "with dot"]] + +invalid_subjects = ["", " ", ".", " .", "", None] + + +def test_parse_scope_valid_values(): + for valid_scope in valid_scopes: + assert valid_scope == parse_scope(valid_scope) + + +def test_scopes_transformations(): + for scopes_transformation in scopes_transformations: + invalid_scope, transformed_scope = scopes_transformation + assert transformed_scope == parse_scope(invalid_scope) + + +def test_parse_subject_valid_values(): + for valid_subject in valid_subjects: + assert valid_subject == parse_subject(valid_subject) + + +def test_parse_subject_invalid_values(): + for valid_subject in invalid_subjects: + with pytest.raises(AnswerRequiredError): + parse_subject(valid_subject) + + +def test_subject_transformations(): + for subject_transformation in subjects_transformations: + invalid_subject, transformed_subject = subject_transformation + assert transformed_subject == parse_subject(invalid_subject) + + +def test_questions(config): + conventional_commits = ConventionalCommitsCz(config) + questions = conventional_commits.questions() + assert isinstance(questions, list) + assert isinstance(questions[0], dict) + + +def test_choices_all_have_keyboard_shortcuts(config): + conventional_commits = ConventionalCommitsCz(config) + questions = conventional_commits.questions() + + list_questions = (q for q in questions if q["type"] == "list") + for select in list_questions: + assert all("key" in choice for choice in select["choices"]) + + +def test_small_answer(config): + conventional_commits = ConventionalCommitsCz(config) + answers = { + "prefix": "fix", + "scope": "users", + "subject": "email pattern corrected", + "is_breaking_change": False, + "body": "", + "footer": "", + } + message = conventional_commits.message(answers) + assert message == "fix(users): email pattern corrected" + + +def test_long_answer(config): + conventional_commits = ConventionalCommitsCz(config) + answers = { + "prefix": "fix", + "scope": "users", + "subject": "email pattern corrected", + "is_breaking_change": False, + "body": "complete content", + "footer": "closes #24", + } + message = conventional_commits.message(answers) + assert ( + message + == "fix(users): email pattern corrected\n\ncomplete content\n\ncloses #24" # noqa + ) + + +def test_breaking_change_in_footer(config): + conventional_commits = ConventionalCommitsCz(config) + answers = { + "prefix": "fix", + "scope": "users", + "subject": "email pattern corrected", + "is_breaking_change": True, + "body": "complete content", + "footer": "migrate by renaming user to users", + } + message = conventional_commits.message(answers) + print(message) + assert ( + message + == "fix(users): email pattern corrected\n\ncomplete content\n\nBREAKING CHANGE: migrate by renaming user to users" # noqa + ) + + +def test_example(config): + """just testing a string is returned. not the content""" + conventional_commits = ConventionalCommitsCz(config) + example = conventional_commits.example() + assert isinstance(example, str) + + +def test_schema(config): + """just testing a string is returned. not the content""" + conventional_commits = ConventionalCommitsCz(config) + schema = conventional_commits.schema() + assert isinstance(schema, str) + + +def test_info(config): + """just testing a string is returned. not the content""" + conventional_commits = ConventionalCommitsCz(config) + info = conventional_commits.info() + assert isinstance(info, str) + + +@pytest.mark.parametrize( + ("commit_message", "expected_message"), + [ + ( + "test(test_scope): this is test msg", + "this is test msg", + ), + ( + "test(test_scope)!: this is test msg", + "this is test msg", + ), + ( + "test!(test_scope): this is test msg", + "", + ), + ], +) +def test_process_commit(commit_message, expected_message, config): + conventional_commits = ConventionalCommitsCz(config) + message = conventional_commits.process_commit(commit_message) + assert message == expected_message diff --git a/tests/test_cz_customize.py b/tests/test_cz_customize.py new file mode 100644 index 0000000..210c8b6 --- /dev/null +++ b/tests/test_cz_customize.py @@ -0,0 +1,600 @@ +import pytest + +from commitizen.config import BaseConfig, JsonConfig, TomlConfig, YAMLConfig +from commitizen.cz.customize import CustomizeCommitsCz +from commitizen.exceptions import MissingCzCustomizeConfigError + +TOML_STR = r""" + [tool.commitizen.customize] + message_template = "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example = "feature: this feature enables customization through a config file" + schema = "<type>: <body>" + schema_pattern = "(feature|bug fix):(\\s.*)" + commit_parser = "^(?P<change_type>feature|bug fix):\\s(?P<message>.*)?" + changelog_pattern = "^(feature|bug fix)?(!)?" + change_type_map = {"feature" = "Feat", "bug fix" = "Fix"} + + bump_pattern = "^(break|new|fix|hotfix)" + bump_map = {"break" = "MAJOR", "new" = "MINOR", "fix" = "PATCH", "hotfix" = "PATCH"} + change_type_order = ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"] + info = "This is a customized cz." + + [[tool.commitizen.customize.questions]] + type = "list" + name = "change_type" + choices = [ + {value = "feature", name = "feature: A new feature."}, + {value = "bug fix", name = "bug fix: A bug fix."} + ] + message = "Select the type of change you are committing" + + [[tool.commitizen.customize.questions]] + type = "input" + name = "message" + message = "Body." + + [[tool.commitizen.customize.questions]] + type = "confirm" + name = "show_message" + message = "Do you want to add body message in commit?" +""" + +JSON_STR = r""" + { + "commitizen": { + "name": "cz_jira", + "version": "1.0.0", + "version_files": [ + "commitizen/__version__.py", + "pyproject.toml" + ], + "customize": { + "message_template": "{{change_type}}:{% if show_message %} {{message}}{% endif %}", + "example": "feature: this feature enables customization through a config file", + "schema": "<type>: <body>", + "schema_pattern": "(feature|bug fix):(\\s.*)", + "bump_pattern": "^(break|new|fix|hotfix)", + "bump_map": { + "break": "MAJOR", + "new": "MINOR", + "fix": "PATCH", + "hotfix": "PATCH" + }, + "commit_parser": "^(?P<change_type>feature|bug fix):\\s(?P<message>.*)?", + "changelog_pattern": "^(feature|bug fix)?(!)?", + "change_type_map": {"feature": "Feat", "bug fix": "Fix"}, + "change_type_order": ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"], + "info": "This is a customized cz.", + "questions": [ + { + "type": "list", + "name": "change_type", + "choices": [ + { + "value": "feature", + "name": "feature: A new feature." + }, + { + "value": "bug fix", + "name": "bug fix: A bug fix." + } + ], + "message": "Select the type of change you are committing" + }, + { + "type": "input", + "name": "message", + "message": "Body." + }, + { + "type": "confirm", + "name": "show_message", + "message": "Do you want to add body message in commit?" + } + ] + } + } + } +""" + +YAML_STR = """ +commitizen: + name: cz_jira + version: 1.0.0 + version_files: + - commitizen/__version__.py + - pyproject.toml + customize: + message_template: "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example: 'feature: this feature enables customization through a config file' + schema: "<type>: <body>" + schema_pattern: "(feature|bug fix):(\\s.*)" + bump_pattern: "^(break|new|fix|hotfix)" + bump_map: + break: MAJOR + new: MINOR + fix: PATCH + hotfix: PATCH + change_type_order: ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"] + info: This is a customized cz. + questions: + - type: list + name: change_type + choices: + - value: feature + name: 'feature: A new feature.' + - value: bug fix + name: 'bug fix: A bug fix.' + message: Select the type of change you are committing + - type: input + name: message + message: Body. + - type: confirm + name: show_message + message: Do you want to add body message in commit? +""" + +TOML_WITH_UNICODE = r""" + [tool.commitizen] + name = "cz_customize" + version = "1.0.0" + version_files = [ + "commitizen/__version__.py", + "pyproject.toml:version" + ] + [tool.commitizen.customize] + message_template = "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example = "โœจ feature: this feature enables customization through a config file" + schema = "<type>: <body>" + schema_pattern = "(โœจ feature|๐Ÿ› bug fix):(\\s.*)" + commit_parser = "^(?P<change_type>โœจ feature|๐Ÿ› bug fix):\\s(?P<message>.*)?" + changelog_pattern = "^(โœจ feature|๐Ÿ› bug fix)?(!)?" + change_type_map = {"โœจ feature" = "Feat", "๐Ÿ› bug fix" = "Fix"} + bump_pattern = "^(โœจ feat|๐Ÿ› bug fix)" + bump_map = {"break" = "MAJOR", "โœจ feat" = "MINOR", "๐Ÿ› bug fix" = "MINOR"} + change_type_order = ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"] + info = "This is a customized cz with emojis ๐ŸŽ‰!" + [[tool.commitizen.customize.questions]] + type = "list" + name = "change_type" + choices = [ + {value = "โœจ feature", name = "โœจ feature: A new feature."}, + {value = "๐Ÿ› bug fix", name = "๐Ÿ› bug fix: A bug fix."} + ] + message = "Select the type of change you are committing" + [[tool.commitizen.customize.questions]] + type = "input" + name = "message" + message = "Body." + [[tool.commitizen.customize.questions]] + type = "confirm" + name = "show_message" + message = "Do you want to add body message in commit?" +""" + +JSON_WITH_UNICODE = r""" + { + "commitizen": { + "name": "cz_customize", + "version": "1.0.0", + "version_files": [ + "commitizen/__version__.py", + "pyproject.toml:version" + ], + "customize": { + "message_template": "{{change_type}}:{% if show_message %} {{message}}{% endif %}", + "example": "โœจ feature: this feature enables customization through a config file", + "schema": "<type>: <body>", + "schema_pattern": "(โœจ feature|๐Ÿ› bug fix):(\\s.*)", + "bump_pattern": "^(โœจ feat|๐Ÿ› bug fix)", + "bump_map": { + "break": "MAJOR", + "โœจ feat": "MINOR", + "๐Ÿ› bug fix": "MINOR" + }, + "commit_parser": "^(?P<change_type>โœจ feature|๐Ÿ› bug fix):\\s(?P<message>.*)?", + "changelog_pattern": "^(โœจ feature|๐Ÿ› bug fix)?(!)?", + "change_type_map": {"โœจ feature": "Feat", "๐Ÿ› bug fix": "Fix"}, + "change_type_order": ["perf", "BREAKING CHANGE", "feat", "fix", "refactor"], + "info": "This is a customized cz with emojis ๐ŸŽ‰!", + "questions": [ + { + "type": "list", + "name": "change_type", + "choices": [ + { + "value": "โœจ feature", + "name": "โœจ feature: A new feature." + }, + { + "value": "๐Ÿ› bug fix", + "name": "๐Ÿ› bug fix: A bug fix." + } + ], + "message": "Select the type of change you are committing" + }, + { + "type": "input", + "name": "message", + "message": "Body." + }, + { + "type": "confirm", + "name": "show_message", + "message": "Do you want to add body message in commit?" + } + ] + } + } + } +""" + +TOML_STR_INFO_PATH = """ + [tool.commitizen.customize] + message_template = "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example = "feature: this feature enables customization through a config file" + schema = "<type>: <body>" + bump_pattern = "^(break|new|fix|hotfix)" + bump_map = {"break" = "MAJOR", "new" = "MINOR", "fix" = "PATCH", "hotfix" = "PATCH"} + info_path = "info.txt" +""" + +JSON_STR_INFO_PATH = r""" + { + "commitizen": { + "customize": { + "message_template": "{{change_type}}:{% if show_message %} {{message}}{% endif %}", + "example": "feature: this feature enables customization through a config file", + "schema": "<type>: <body>", + "bump_pattern": "^(break|new|fix|hotfix)", + "bump_map": { + "break": "MAJOR", + "new": "MINOR", + "fix": "PATCH", + "hotfix": "PATCH" + }, + "info_path": "info.txt" + } + } + } +""" + +YAML_STR_INFO_PATH = """ +commitizen: + customize: + message_template: "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example: 'feature: this feature enables customization through a config file' + schema: "<type>: <body>" + bump_pattern: "^(break|new|fix|hotfix)" + bump_map: + break: MAJOR + new: MINOR + fix: PATCH + hotfix: PATCH + info_path: info.txt +""" + +TOML_STR_WITHOUT_INFO = """ + [tool.commitizen.customize] + message_template = "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example = "feature: this feature enables customization through a config file" + schema = "<type>: <body>" + bump_pattern = "^(break|new|fix|hotfix)" + bump_map = {"break" = "MAJOR", "new" = "MINOR", "fix" = "PATCH", "hotfix" = "PATCH"} +""" + +JSON_STR_WITHOUT_PATH = r""" + { + "commitizen": { + "customize": { + "message_template": "{{change_type}}:{% if show_message %} {{message}}{% endif %}", + "example": "feature: this feature enables customization through a config file", + "schema": "<type>: <body>", + "bump_pattern": "^(break|new|fix|hotfix)", + "bump_map": { + "break": "MAJOR", + "new": "MINOR", + "fix": "PATCH", + "hotfix": "PATCH" + } + } + } + } +""" + +YAML_STR_WITHOUT_PATH = """ +commitizen: + customize: + message_template: "{{change_type}}:{% if show_message %} {{message}}{% endif %}" + example: 'feature: this feature enables customization through a config file' + schema: "<type>: <body>" + bump_pattern: "^(break|new|fix|hotfix)" + bump_map: + break: MAJOR + new: MINOR + fix: PATCH + hotfix: PATCH +""" + + +@pytest.fixture( + params=[ + TomlConfig(data=TOML_STR, path="not_exist.toml"), + JsonConfig(data=JSON_STR, path="not_exist.json"), + ] +) +def config(request): + """Parametrize the config fixture + + This fixture allow to test multiple config formats, + without add the builtin parametrize decorator + """ + return request.param + + +@pytest.fixture( + params=[ + TomlConfig(data=TOML_STR_INFO_PATH, path="not_exist.toml"), + JsonConfig(data=JSON_STR_INFO_PATH, path="not_exist.json"), + YAMLConfig(data=YAML_STR_INFO_PATH, path="not_exist.yaml"), + ] +) +def config_info(request): + return request.param + + +@pytest.fixture( + params=[ + TomlConfig(data=TOML_STR_WITHOUT_INFO, path="not_exist.toml"), + JsonConfig(data=JSON_STR_WITHOUT_PATH, path="not_exist.json"), + YAMLConfig(data=YAML_STR_WITHOUT_PATH, path="not_exist.yaml"), + ] +) +def config_without_info(request): + return request.param + + +@pytest.fixture( + params=[ + TomlConfig(data=TOML_WITH_UNICODE, path="not_exist.toml"), + JsonConfig(data=JSON_WITH_UNICODE, path="not_exist.json"), + ] +) +def config_with_unicode(request): + return request.param + + +def test_initialize_cz_customize_failed(): + with pytest.raises(MissingCzCustomizeConfigError) as excinfo: + config = BaseConfig() + _ = CustomizeCommitsCz(config) + + assert MissingCzCustomizeConfigError.message in str(excinfo.value) + + +def test_bump_pattern(config): + cz = CustomizeCommitsCz(config) + assert cz.bump_pattern == "^(break|new|fix|hotfix)" + + +def test_bump_pattern_unicode(config_with_unicode): + cz = CustomizeCommitsCz(config_with_unicode) + assert cz.bump_pattern == "^(โœจ feat|๐Ÿ› bug fix)" + + +def test_bump_map(config): + cz = CustomizeCommitsCz(config) + assert cz.bump_map == { + "break": "MAJOR", + "new": "MINOR", + "fix": "PATCH", + "hotfix": "PATCH", + } + + +def test_bump_map_unicode(config_with_unicode): + cz = CustomizeCommitsCz(config_with_unicode) + assert cz.bump_map == { + "break": "MAJOR", + "โœจ feat": "MINOR", + "๐Ÿ› bug fix": "MINOR", + } + + +def test_change_type_order(config): + cz = CustomizeCommitsCz(config) + assert cz.change_type_order == [ + "perf", + "BREAKING CHANGE", + "feat", + "fix", + "refactor", + ] + + +def test_change_type_order_unicode(config_with_unicode): + cz = CustomizeCommitsCz(config_with_unicode) + assert cz.change_type_order == [ + "perf", + "BREAKING CHANGE", + "feat", + "fix", + "refactor", + ] + + +def test_questions(config): + cz = CustomizeCommitsCz(config) + questions = cz.questions() + expected_questions = [ + { + "type": "list", + "name": "change_type", + "choices": [ + {"value": "feature", "name": "feature: A new feature."}, + {"value": "bug fix", "name": "bug fix: A bug fix."}, + ], + "message": "Select the type of change you are committing", + }, + {"type": "input", "name": "message", "message": "Body."}, + { + "type": "confirm", + "name": "show_message", + "message": "Do you want to add body message in commit?", + }, + ] + assert list(questions) == expected_questions + + +def test_questions_unicode(config_with_unicode): + cz = CustomizeCommitsCz(config_with_unicode) + questions = cz.questions() + expected_questions = [ + { + "type": "list", + "name": "change_type", + "choices": [ + {"value": "โœจ feature", "name": "โœจ feature: A new feature."}, + {"value": "๐Ÿ› bug fix", "name": "๐Ÿ› bug fix: A bug fix."}, + ], + "message": "Select the type of change you are committing", + }, + {"type": "input", "name": "message", "message": "Body."}, + { + "type": "confirm", + "name": "show_message", + "message": "Do you want to add body message in commit?", + }, + ] + assert list(questions) == expected_questions + + +def test_answer(config): + cz = CustomizeCommitsCz(config) + answers = { + "change_type": "feature", + "message": "this feature enaable customize through config file", + "show_message": True, + } + message = cz.message(answers) + assert message == "feature: this feature enaable customize through config file" + + cz = CustomizeCommitsCz(config) + answers = { + "change_type": "feature", + "message": "this feature enaable customize through config file", + "show_message": False, + } + message = cz.message(answers) + assert message == "feature:" + + +def test_answer_unicode(config_with_unicode): + cz = CustomizeCommitsCz(config_with_unicode) + answers = { + "change_type": "โœจ feature", + "message": "this feature enables customization through a config file", + "show_message": True, + } + message = cz.message(answers) + assert ( + message + == "โœจ feature: this feature enables customization through a config file" + ) + + cz = CustomizeCommitsCz(config_with_unicode) + answers = { + "change_type": "โœจ feature", + "message": "this feature enables customization through a config file", + "show_message": False, + } + message = cz.message(answers) + assert message == "โœจ feature:" + + +def test_example(config): + cz = CustomizeCommitsCz(config) + assert ( + "feature: this feature enables customization through a config file" + in cz.example() + ) + + +def test_example_unicode(config_with_unicode): + cz = CustomizeCommitsCz(config_with_unicode) + assert ( + "โœจ feature: this feature enables customization through a config file" + in cz.example() + ) + + +def test_schema(config): + cz = CustomizeCommitsCz(config) + assert "<type>: <body>" in cz.schema() + + +def test_schema_pattern(config): + cz = CustomizeCommitsCz(config) + assert r"(feature|bug fix):(\s.*)" in cz.schema_pattern() + + +def test_schema_pattern_unicode(config_with_unicode): + cz = CustomizeCommitsCz(config_with_unicode) + assert r"(โœจ feature|๐Ÿ› bug fix):(\s.*)" in cz.schema_pattern() + + +def test_info(config): + cz = CustomizeCommitsCz(config) + assert "This is a customized cz." in cz.info() + + +def test_info_unicode(config_with_unicode): + cz = CustomizeCommitsCz(config_with_unicode) + assert "This is a customized cz with emojis ๐ŸŽ‰!" in cz.info() + + +def test_info_with_info_path(tmpdir, config_info): + with tmpdir.as_cwd(): + tmpfile = tmpdir.join("info.txt") + tmpfile.write("Test info") + + cz = CustomizeCommitsCz(config_info) + assert "Test info" in cz.info() + + +def test_info_without_info(config_without_info): + cz = CustomizeCommitsCz(config_without_info) + assert cz.info() == "" + + +def test_commit_parser(config): + cz = CustomizeCommitsCz(config) + assert cz.commit_parser == "^(?P<change_type>feature|bug fix):\\s(?P<message>.*)?" + + +def test_commit_parser_unicode(config_with_unicode): + cz = CustomizeCommitsCz(config_with_unicode) + assert ( + cz.commit_parser + == "^(?P<change_type>โœจ feature|๐Ÿ› bug fix):\\s(?P<message>.*)?" + ) + + +def test_changelog_pattern(config): + cz = CustomizeCommitsCz(config) + assert cz.changelog_pattern == "^(feature|bug fix)?(!)?" + + +def test_changelog_pattern_unicode(config_with_unicode): + cz = CustomizeCommitsCz(config_with_unicode) + assert cz.changelog_pattern == "^(โœจ feature|๐Ÿ› bug fix)?(!)?" + + +def test_change_type_map(config): + cz = CustomizeCommitsCz(config) + assert cz.change_type_map == {"feature": "Feat", "bug fix": "Fix"} + + +def test_change_type_map_unicode(config_with_unicode): + cz = CustomizeCommitsCz(config_with_unicode) + assert cz.change_type_map == {"โœจ feature": "Feat", "๐Ÿ› bug fix": "Fix"} diff --git a/tests/test_cz_jira.py b/tests/test_cz_jira.py new file mode 100644 index 0000000..03055c1 --- /dev/null +++ b/tests/test_cz_jira.py @@ -0,0 +1,39 @@ +from commitizen.cz.jira import JiraSmartCz + + +def test_questions(config): + cz = JiraSmartCz(config) + questions = cz.questions() + assert isinstance(questions, list) + assert isinstance(questions[0], dict) + + +def test_answer(config): + cz = JiraSmartCz(config) + answers = { + "message": "new test", + "issues": "JRA-34", + "workflow": "", + "time": "", + "comment": "", + } + message = cz.message(answers) + assert message == "new test JRA-34" + + +def test_example(config): + cz = JiraSmartCz(config) + assert "JRA-34 #comment corrected indent issue\n" in cz.example() + + +def test_schema(config): + cz = JiraSmartCz(config) + assert "<ignored text>" in cz.schema() + + +def test_info(config): + cz = JiraSmartCz(config) + assert ( + "Smart Commits allow repository committers to perform " + "actions such as transitioning JIRA Software" + ) in cz.info() diff --git a/tests/test_cz_utils.py b/tests/test_cz_utils.py new file mode 100644 index 0000000..25c960c --- /dev/null +++ b/tests/test_cz_utils.py @@ -0,0 +1,26 @@ +import pytest +from pytest_mock import MockFixture + +from commitizen.cz import exceptions, utils + + +def test_required_validator(): + assert utils.required_validator("test") == "test" + + with pytest.raises(exceptions.AnswerRequiredError): + utils.required_validator("") + + +def test_multiple_line_breaker(): + message = "this is the first line | and this is the second line " + result = utils.multiple_line_breaker(message) + assert result == "this is the first line\nand this is the second line" + + result = utils.multiple_line_breaker(message, "is") + assert result == "th\n\nthe first line | and th\n\nthe second line" + + +def test_get_backup_file_path_no_project_root(mocker: MockFixture): + project_root_mock = mocker.patch("commitizen.git.find_git_project_root") + project_root_mock.return_value = None + assert utils.get_backup_file_path() diff --git a/tests/test_factory.py b/tests/test_factory.py new file mode 100644 index 0000000..390742f --- /dev/null +++ b/tests/test_factory.py @@ -0,0 +1,97 @@ +import sys +from textwrap import dedent + +if sys.version_info >= (3, 10): + from importlib import metadata +else: + import importlib_metadata as metadata + +import pytest + +from commitizen import BaseCommitizen, defaults, factory +from commitizen.config import BaseConfig +from commitizen.cz import discover_plugins +from commitizen.cz.conventional_commits import ConventionalCommitsCz +from commitizen.cz.customize import CustomizeCommitsCz +from commitizen.cz.jira import JiraSmartCz +from commitizen.exceptions import NoCommitizenFoundException + + +class Plugin: + pass + + +class OtherPlugin: + pass + + +def test_factory(): + config = BaseConfig() + config.settings.update({"name": defaults.DEFAULT_SETTINGS["name"]}) + r = factory.commiter_factory(config) + assert isinstance(r, BaseCommitizen) + + +def test_factory_fails(): + config = BaseConfig() + config.settings.update({"name": "Nothing"}) + with pytest.raises(NoCommitizenFoundException) as excinfo: + factory.commiter_factory(config) + + assert "The committer has not been found in the system." in str(excinfo) + + +def test_discover_plugins(tmp_path): + legacy_plugin_folder = tmp_path / "cz_legacy" + legacy_plugin_folder.mkdir() + init_file = legacy_plugin_folder / "__init__.py" + init_file.write_text( + dedent( + """\ + class Plugin: pass + + discover_this = Plugin + """ + ) + ) + + sys.path.append(tmp_path.as_posix()) + with pytest.warns(UserWarning) as record: + discovered_plugins = discover_plugins([tmp_path.as_posix()]) + sys.path.pop() + + assert ( + record[0].message.args[0] + == "Legacy plugin 'cz_legacy' has been ignored: please expose it the 'commitizen.plugin' entrypoint" + ) + assert "cz_legacy" not in discovered_plugins + + +def test_discover_external_plugin(mocker): + ep_plugin = metadata.EntryPoint( + "test", "tests.test_factory:Plugin", "commitizen.plugin" + ) + ep_other_plugin = metadata.EntryPoint( + "not-selected", "tests.test_factory::OtherPlugin", "commitizen.not_a_plugin" + ) + eps = [ep_plugin, ep_other_plugin] + + def mock_entrypoints(**kwargs): + group = kwargs.get("group") + return metadata.EntryPoints(ep for ep in eps if ep.group == group) + + mocker.patch.object(metadata, "entry_points", side_effect=mock_entrypoints) + + assert discover_plugins() == {"test": Plugin} + + +def test_discover_internal_plugins(): + expected = { + "cz_conventional_commits": ConventionalCommitsCz, + "cz_jira": JiraSmartCz, + "cz_customize": CustomizeCommitsCz, + } + + discovered = discover_plugins() + + assert set(expected.items()).issubset(set(discovered.items())) diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..8b2fc2b --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +import inspect +import os +import platform +import shutil + +import pytest +from pytest_mock import MockFixture + +from commitizen import cmd, exceptions, git +from tests.utils import ( + FakeCommand, + create_branch, + create_file_and_commit, + create_tag, + switch_branch, +) + + +def test_git_object_eq(): + git_commit = git.GitCommit( + rev="sha1-code", title="this is title", body="this is body" + ) + git_tag = git.GitTag(rev="sha1-code", name="0.0.1", date="2020-01-21") + + assert git_commit == git_tag + assert git_commit != "sha1-code" + + +def test_get_tags(mocker: MockFixture): + tag_str = ( + "v1.0.0---inner_delimiter---333---inner_delimiter---2020-01-20---inner_delimiter---\n" + "v0.5.0---inner_delimiter---222---inner_delimiter---2020-01-17---inner_delimiter---\n" + "v0.0.1---inner_delimiter---111---inner_delimiter---2020-01-17---inner_delimiter---\n" + ) + mocker.patch("commitizen.cmd.run", return_value=FakeCommand(out=tag_str)) + + git_tags = git.get_tags() + latest_git_tag = git_tags[0] + assert latest_git_tag.rev == "333" + assert latest_git_tag.name == "v1.0.0" + assert latest_git_tag.date == "2020-01-20" + + mocker.patch( + "commitizen.cmd.run", return_value=FakeCommand(out="", err="No tag available") + ) + assert git.get_tags() == [] + + +def test_get_reachable_tags(tmp_commitizen_project): + with tmp_commitizen_project.as_cwd(): + create_file_and_commit("Initial state") + create_tag("1.0.0") + # create develop + create_branch("develop") + switch_branch("develop") + + # add a feature to develop + create_file_and_commit("develop") + create_tag("1.1.0b0") + + # create staging + switch_branch("master") + create_file_and_commit("master") + create_tag("1.0.1") + + tags = git.get_tags(reachable_only=True) + tag_names = set([t.name for t in tags]) + # 1.1.0b0 is not present + assert tag_names == {"1.0.0", "1.0.1"} + + +@pytest.mark.parametrize("locale", ["en_US", "fr_FR"]) +def test_get_reachable_tags_with_commits( + tmp_commitizen_project, locale: str, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setenv("LANG", f"{locale}.UTF-8") + monkeypatch.setenv("LANGUAGE", f"{locale}.UTF-8") + monkeypatch.setenv("LC_ALL", f"{locale}.UTF-8") + with tmp_commitizen_project.as_cwd(): + tags = git.get_tags(reachable_only=True) + assert tags == [] + + +def test_get_tag_names(mocker: MockFixture): + tag_str = "v1.0.0\nv0.5.0\nv0.0.1\n" + mocker.patch("commitizen.cmd.run", return_value=FakeCommand(out=tag_str)) + + assert git.get_tag_names() == ["v1.0.0", "v0.5.0", "v0.0.1"] + + mocker.patch( + "commitizen.cmd.run", return_value=FakeCommand(out="", err="No tag available") + ) + assert git.get_tag_names() == [] + + +def test_git_message_with_empty_body(): + commit_title = "Some Title" + commit = git.GitCommit("test_rev", "Some Title", body="") + + assert commit.message == commit_title + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_get_log_as_str_list_empty(): + """ensure an exception or empty list in an empty project""" + try: + gitlog = git._get_log_as_str_list(start=None, end="HEAD", args="") + except exceptions.GitCommandError: + return + assert len(gitlog) == 0, "list should be empty if no assert" + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_get_commits(): + create_file_and_commit("feat(users): add username") + create_file_and_commit("fix: username exception") + commits = git.get_commits() + assert len(commits) == 2 + + +@pytest.mark.usefixtures("tmp_commitizen_project") +def test_get_commits_author_and_email(): + create_file_and_commit("fix: username exception") + commit = git.get_commits()[0] + + assert commit.author != "" + assert "@" in commit.author_email + + +def test_get_commits_without_email(mocker: MockFixture): + raw_commit = ( + "a515bb8f71c403f6f7d1c17b9d8ebf2ce3959395\n" + "95bbfc703eb99cb49ba0d6ffd8469911303dbe63 12d3b4bdaa996ea7067a07660bb5df4772297bdd\n" + "\n" + "user name\n" + "\n" + "----------commit-delimiter----------\n" + "12d3b4bdaa996ea7067a07660bb5df4772297bdd\n" + "de33bc5070de19600f2f00262b3c15efea762408\n" + "feat(users): add username\n" + "user name\n" + "\n" + "----------commit-delimiter----------\n" + ) + mocker.patch("commitizen.cmd.run", return_value=FakeCommand(out=raw_commit)) + + commits = git.get_commits() + + assert commits[0].author == "user name" + assert commits[1].author == "user name" + + assert commits[0].author_email == "" + assert commits[1].author_email == "" + + assert commits[0].title == "" + assert commits[1].title == "feat(users): add username" + + +def test_get_commits_without_breakline_in_each_commit(mocker: MockFixture): + raw_commit = ( + "ae9ba6fc5526cf478f52ef901418d85505109744\n" + "ff2f56ca844de72a9d59590831087bf5a97bac84\n" + "bump: version 2.13.0 โ†’ 2.14.0\n" + "GitHub Action\n" + "action@github.com\n" + "----------commit-delimiter----------\n" + "ff2f56ca844de72a9d59590831087bf5a97bac84\n" + "b4dc83284dc8c9729032a774a037df1d1f2397d5 20a54bf1b82cd7b573351db4d1e8814dd0be205d\n" + "Merge pull request #332 from cliles/feature/271-redux\n" + "User\n" + "user@email.com\n" + "Feature/271 redux----------commit-delimiter----------\n" + "20a54bf1b82cd7b573351db4d1e8814dd0be205d\n" + "658f38c3fe832cdab63ed4fb1f7b3a0969a583be\n" + "feat(#271): enable creation of annotated tags when bumping\n" + "User 2\n" + "user@email.edu\n" + "----------commit-delimiter----------\n" + ) + mocker.patch("commitizen.cmd.run", return_value=FakeCommand(out=raw_commit)) + + commits = git.get_commits() + + assert commits[0].author == "GitHub Action" + assert commits[1].author == "User" + assert commits[2].author == "User 2" + + assert commits[0].author_email == "action@github.com" + assert commits[1].author_email == "user@email.com" + assert commits[2].author_email == "user@email.edu" + + assert commits[0].title == "bump: version 2.13.0 โ†’ 2.14.0" + assert commits[1].title == "Merge pull request #332 from cliles/feature/271-redux" + assert ( + commits[2].title == "feat(#271): enable creation of annotated tags when bumping" + ) + + +def test_get_commits_with_and_without_parents(mocker: MockFixture): + raw_commit = ( + "4206e661bacf9643373255965f34bbdb382cb2b9\n" + "ae9ba6fc5526cf478f52ef901418d85505109744 bf8479e7aa1a5b9d2f491b79e3a4d4015519903e\n" + "Merge pull request from someone\n" + "Maintainer\n" + "maintainer@email.com\n" + "This is a much needed feature----------commit-delimiter----------\n" + "ae9ba6fc5526cf478f52ef901418d85505109744\n" + "ff2f56ca844de72a9d59590831087bf5a97bac84\n" + "Release 0.1.0\n" + "GitHub Action\n" + "action@github.com\n" + "----------commit-delimiter----------\n" + "ff2f56ca844de72a9d59590831087bf5a97bac84\n" + "\n" + "Initial commit\n" + "User\n" + "user@email.com\n" + "----------commit-delimiter----------\n" + ) + mocker.patch("commitizen.cmd.run", return_value=FakeCommand(out=raw_commit)) + + commits = git.get_commits() + + assert commits[0].author == "Maintainer" + assert commits[1].author == "GitHub Action" + assert commits[2].author == "User" + + assert commits[0].author_email == "maintainer@email.com" + assert commits[1].author_email == "action@github.com" + assert commits[2].author_email == "user@email.com" + + assert commits[0].title == "Merge pull request from someone" + assert commits[1].title == "Release 0.1.0" + assert commits[2].title == "Initial commit" + + assert commits[0].body == "This is a much needed feature" + assert commits[1].body == "" + assert commits[2].body == "" + + assert commits[0].parents == [ + "ae9ba6fc5526cf478f52ef901418d85505109744", + "bf8479e7aa1a5b9d2f491b79e3a4d4015519903e", + ] + assert commits[1].parents == ["ff2f56ca844de72a9d59590831087bf5a97bac84"] + assert commits[2].parents == [] + + +def test_get_commits_with_signature(): + config_file = ".git/config" + config_backup = ".git/config.bak" + shutil.copy(config_file, config_backup) + + try: + # temporarily turn on --show-signature + cmd.run("git config log.showsignature true") + + # retrieve a commit that we know has a signature + commit = git.get_commits( + start="bec20ebf433f2281c70f1eb4b0b6a1d0ed83e9b2", + end="9eae518235d051f145807ddf971ceb79ad49953a", + )[0] + + assert commit.title.startswith("fix") + finally: + # restore the repo's original config + shutil.move(config_backup, config_file) + + +def test_get_tag_names_has_correct_arrow_annotation(): + arrow_annotation = inspect.getfullargspec(git.get_tag_names).annotations["return"] + + assert arrow_annotation == "list[str | None]" + + +def test_get_latest_tag_name(tmp_commitizen_project): + with tmp_commitizen_project.as_cwd(): + tag_name = git.get_latest_tag_name() + assert tag_name is None + + create_file_and_commit("feat(test): test") + cmd.run("git tag 1.0") + tag_name = git.get_latest_tag_name() + assert tag_name == "1.0" + + +def test_is_staging_clean_when_adding_file(tmp_commitizen_project): + with tmp_commitizen_project.as_cwd(): + assert git.is_staging_clean() is True + + cmd.run("touch test_file") + + assert git.is_staging_clean() is True + + cmd.run("git add test_file") + + assert git.is_staging_clean() is False + + +def test_is_staging_clean_when_updating_file(tmp_commitizen_project): + with tmp_commitizen_project.as_cwd(): + assert git.is_staging_clean() is True + + cmd.run("touch test_file") + cmd.run("git add test_file") + if os.name == "nt": + cmd.run('git commit -m "add test_file"') + else: + cmd.run("git commit -m 'add test_file'") + cmd.run("echo 'test' > test_file") + + assert git.is_staging_clean() is True + + cmd.run("git add test_file") + + assert git.is_staging_clean() is False + + +def test_git_eol_style(tmp_commitizen_project): + with tmp_commitizen_project.as_cwd(): + assert git.get_eol_style() == git.EOLTypes.NATIVE + + cmd.run("git config core.eol lf") + assert git.get_eol_style() == git.EOLTypes.LF + + cmd.run("git config core.eol crlf") + assert git.get_eol_style() == git.EOLTypes.CRLF + + cmd.run("git config core.eol native") + assert git.get_eol_style() == git.EOLTypes.NATIVE + + +def test_eoltypes_get_eol_for_open(): + assert git.EOLTypes.get_eol_for_open(git.EOLTypes.NATIVE) == os.linesep + assert git.EOLTypes.get_eol_for_open(git.EOLTypes.LF) == "\n" + assert git.EOLTypes.get_eol_for_open(git.EOLTypes.CRLF) == "\r\n" + + +def test_get_core_editor(mocker): + mocker.patch.dict(os.environ, {"GIT_EDITOR": "nano"}) + assert git.get_core_editor() == "nano" + + mocker.patch.dict(os.environ, clear=True) + mocker.patch( + "commitizen.cmd.run", + return_value=cmd.Command( + out="vim", err="", stdout=b"", stderr=b"", return_code=0 + ), + ) + assert git.get_core_editor() == "vim" + + mocker.patch( + "commitizen.cmd.run", + return_value=cmd.Command(out="", err="", stdout=b"", stderr=b"", return_code=1), + ) + assert git.get_core_editor() is None + + +def test_create_tag_with_message(tmp_commitizen_project): + with tmp_commitizen_project.as_cwd(): + create_file_and_commit("feat(test): test") + tag_name = "1.0" + tag_message = "test message" + create_tag(tag_name, tag_message) + assert git.get_latest_tag_name() == tag_name + assert git.get_tag_message(tag_name) == ( + tag_message if platform.system() != "Windows" else f"'{tag_message}'" + ) + + +@pytest.mark.parametrize( + "file_path,expected_cmd", + [ + ( + "/tmp/temp file", + 'git commit --signoff -F "/tmp/temp file"', + ), + ( + "/tmp dir/temp file", + 'git commit --signoff -F "/tmp dir/temp file"', + ), + ( + "/tmp/tempfile", + 'git commit --signoff -F "/tmp/tempfile"', + ), + ], + ids=[ + "File contains spaces", + "Path contains spaces", + "Path does not contain spaces", + ], +) +def test_commit_with_spaces_in_path(mocker, file_path, expected_cmd): + mock_run = mocker.patch("commitizen.cmd.run", return_value=FakeCommand()) + mock_unlink = mocker.patch("os.unlink") + mock_temp_file = mocker.patch("commitizen.git.NamedTemporaryFile") + mock_temp_file.return_value.name = file_path + + git.commit("feat: new feature", "--signoff") + + mock_run.assert_called_once_with(expected_cmd) + mock_unlink.assert_called_once_with(file_path) diff --git a/tests/test_version_scheme_pep440.py b/tests/test_version_scheme_pep440.py new file mode 100644 index 0000000..6b1f621 --- /dev/null +++ b/tests/test_version_scheme_pep440.py @@ -0,0 +1,338 @@ +import itertools +import random + +import pytest + +from commitizen.version_schemes import Pep440, VersionProtocol + +simple_flow = [ + (("0.1.0", "PATCH", None, 0, None), "0.1.1"), + (("0.1.0", "PATCH", None, 0, 1), "0.1.1.dev1"), + (("0.1.1", "MINOR", None, 0, None), "0.2.0"), + (("0.2.0", "MINOR", None, 0, None), "0.3.0"), + (("0.2.0", "MINOR", None, 0, 1), "0.3.0.dev1"), + (("0.3.0", "PATCH", None, 0, None), "0.3.1"), + (("0.3.0", "PATCH", "alpha", 0, None), "0.3.1a0"), + (("0.3.1a0", None, "alpha", 0, None), "0.3.1a1"), + (("0.3.0", "PATCH", "alpha", 1, None), "0.3.1a1"), + (("0.3.1a0", None, "alpha", 1, None), "0.3.1a1"), + (("0.3.1a0", None, None, 0, None), "0.3.1"), + (("0.3.1", "PATCH", None, 0, None), "0.3.2"), + (("0.4.2", "MAJOR", "alpha", 0, None), "1.0.0a0"), + (("1.0.0a0", None, "alpha", 0, None), "1.0.0a1"), + (("1.0.0a1", None, "alpha", 0, None), "1.0.0a2"), + (("1.0.0a1", None, "alpha", 0, 1), "1.0.0a2.dev1"), + (("1.0.0a2.dev0", None, "alpha", 0, 1), "1.0.0a3.dev1"), + (("1.0.0a2.dev0", None, "alpha", 0, 0), "1.0.0a3.dev0"), + (("1.0.0a1", None, "beta", 0, None), "1.0.0b0"), + (("1.0.0b0", None, "beta", 0, None), "1.0.0b1"), + (("1.0.0b1", None, "rc", 0, None), "1.0.0rc0"), + (("1.0.0rc0", None, "rc", 0, None), "1.0.0rc1"), + (("1.0.0rc0", None, "rc", 0, 1), "1.0.0rc1.dev1"), + (("1.0.0rc0", "PATCH", None, 0, None), "1.0.0"), + (("1.0.0a3.dev0", None, "beta", 0, None), "1.0.0b0"), + (("1.0.0", "PATCH", None, 0, None), "1.0.1"), + (("1.0.1", "PATCH", None, 0, None), "1.0.2"), + (("1.0.2", "MINOR", None, 0, None), "1.1.0"), + (("1.1.0", "MINOR", None, 0, None), "1.2.0"), + (("1.2.0", "PATCH", None, 0, None), "1.2.1"), + (("1.2.1", "MAJOR", None, 0, None), "2.0.0"), +] + +local_versions = [ + (("4.5.0+0.1.0", "PATCH", None, 0, None), "4.5.0+0.1.1"), + (("4.5.0+0.1.1", "MINOR", None, 0, None), "4.5.0+0.2.0"), + (("4.5.0+0.2.0", "MAJOR", None, 0, None), "4.5.0+1.0.0"), +] + +# never bump backwards on pre-releases +linear_prerelease_cases = [ + (("0.1.1b1", None, "alpha", 0, None), "0.1.1b2"), + (("0.1.1rc0", None, "alpha", 0, None), "0.1.1rc1"), + (("0.1.1rc0", None, "beta", 0, None), "0.1.1rc1"), +] + +weird_cases = [ + (("1.1", "PATCH", None, 0, None), "1.1.1"), + (("1", "MINOR", None, 0, None), "1.1.0"), + (("1", "MAJOR", None, 0, None), "2.0.0"), + (("1a0", None, "alpha", 0, None), "1.0.0a1"), + (("1a0", None, "alpha", 1, None), "1.0.0a1"), + (("1", None, "beta", 0, None), "1.0.0b0"), + (("1", None, "beta", 1, None), "1.0.0b1"), + (("1beta", None, "beta", 0, None), "1.0.0b1"), + (("1.0.0alpha1", None, "alpha", 0, None), "1.0.0a2"), + (("1", None, "rc", 0, None), "1.0.0rc0"), + (("1.0.0rc1+e20d7b57f3eb", "PATCH", None, 0, None), "1.0.0"), +] + +# test driven development +tdd_cases = [ + (("0.1.1", "PATCH", None, 0, None), "0.1.2"), + (("0.1.1", "MINOR", None, 0, None), "0.2.0"), + (("2.1.1", "MAJOR", None, 0, None), "3.0.0"), + (("0.9.0", "PATCH", "alpha", 0, None), "0.9.1a0"), + (("0.9.0", "MINOR", "alpha", 0, None), "0.10.0a0"), + (("0.9.0", "MAJOR", "alpha", 0, None), "1.0.0a0"), + (("0.9.0", "MAJOR", "alpha", 1, None), "1.0.0a1"), + (("1.0.0a2", None, "beta", 0, None), "1.0.0b0"), + (("1.0.0a2", None, "beta", 1, None), "1.0.0b1"), + (("1.0.0beta1", None, "rc", 0, None), "1.0.0rc0"), + (("1.0.0rc1", None, "rc", 0, None), "1.0.0rc2"), +] + +# additional pre-release tests run through various release scenarios +prerelease_cases = [ + # + (("3.3.3", "PATCH", "alpha", 0, None), "3.3.4a0"), + (("3.3.4a0", "PATCH", "alpha", 0, None), "3.3.4a1"), + (("3.3.4a1", "MINOR", "alpha", 0, None), "3.4.0a0"), + (("3.4.0a0", "PATCH", "alpha", 0, None), "3.4.0a1"), + (("3.4.0a1", "MINOR", "alpha", 0, None), "3.4.0a2"), + (("3.4.0a2", "MAJOR", "alpha", 0, None), "4.0.0a0"), + (("4.0.0a0", "PATCH", "alpha", 0, None), "4.0.0a1"), + (("4.0.0a1", "MINOR", "alpha", 0, None), "4.0.0a2"), + (("4.0.0a2", "MAJOR", "alpha", 0, None), "4.0.0a3"), + # + (("1.0.0", "PATCH", "alpha", 0, None), "1.0.1a0"), + (("1.0.1a0", "PATCH", "alpha", 0, None), "1.0.1a1"), + (("1.0.1a1", "MINOR", "alpha", 0, None), "1.1.0a0"), + (("1.1.0a0", "PATCH", "alpha", 0, None), "1.1.0a1"), + (("1.1.0a1", "MINOR", "alpha", 0, None), "1.1.0a2"), + (("1.1.0a2", "MAJOR", "alpha", 0, None), "2.0.0a0"), + # + (("1.0.0", "MINOR", "alpha", 0, None), "1.1.0a0"), + (("1.1.0a0", "PATCH", "alpha", 0, None), "1.1.0a1"), + (("1.1.0a1", "MINOR", "alpha", 0, None), "1.1.0a2"), + (("1.1.0a2", "PATCH", "alpha", 0, None), "1.1.0a3"), + (("1.1.0a3", "MAJOR", "alpha", 0, None), "2.0.0a0"), + # + (("1.0.0", "MAJOR", "alpha", 0, None), "2.0.0a0"), + (("2.0.0a0", "MINOR", "alpha", 0, None), "2.0.0a1"), + (("2.0.0a1", "PATCH", "alpha", 0, None), "2.0.0a2"), + (("2.0.0a2", "MAJOR", "alpha", 0, None), "2.0.0a3"), + (("2.0.0a3", "MINOR", "alpha", 0, None), "2.0.0a4"), + (("2.0.0a4", "PATCH", "alpha", 0, None), "2.0.0a5"), + (("2.0.0a5", "MAJOR", "alpha", 0, None), "2.0.0a6"), + # + (("2.0.0b0", "MINOR", "alpha", 0, None), "2.0.0b1"), + (("2.0.0b0", "PATCH", "alpha", 0, None), "2.0.0b1"), + # + (("1.0.1a0", "PATCH", None, 0, None), "1.0.1"), + (("1.0.1a0", "MINOR", None, 0, None), "1.1.0"), + (("1.0.1a0", "MAJOR", None, 0, None), "2.0.0"), + # + (("1.1.0a0", "PATCH", None, 0, None), "1.1.0"), + (("1.1.0a0", "MINOR", None, 0, None), "1.1.0"), + (("1.1.0a0", "MAJOR", None, 0, None), "2.0.0"), + # + (("2.0.0a0", "MINOR", None, 0, None), "2.0.0"), + (("2.0.0a0", "MAJOR", None, 0, None), "2.0.0"), + (("2.0.0a0", "PATCH", None, 0, None), "2.0.0"), + # + (("3.0.0a1", None, None, 0, None), "3.0.0"), + (("3.0.0b1", None, None, 0, None), "3.0.0"), + (("3.0.0rc1", None, None, 0, None), "3.0.0"), + # + (("3.1.4", None, "alpha", 0, None), "3.1.4a0"), + (("3.1.4", None, "beta", 0, None), "3.1.4b0"), + (("3.1.4", None, "rc", 0, None), "3.1.4rc0"), + # + (("3.1.4", None, "alpha", 0, None), "3.1.4a0"), + (("3.1.4a0", "PATCH", "alpha", 0, None), "3.1.4a1"), # UNEXPECTED! + (("3.1.4a0", "MINOR", "alpha", 0, None), "3.2.0a0"), + (("3.1.4a0", "MAJOR", "alpha", 0, None), "4.0.0a0"), +] + +excact_cases = [ + (("1.0.0", "PATCH", None, 0, None), "1.0.1"), + (("1.0.0", "MINOR", None, 0, None), "1.1.0"), + # with exact_increment=False: "1.0.0b0" + (("1.0.0a1", "PATCH", "beta", 0, None), "1.0.1b0"), + # with exact_increment=False: "1.0.0b1" + (("1.0.0b0", "PATCH", "beta", 0, None), "1.0.1b0"), + # with exact_increment=False: "1.0.0rc0" + (("1.0.0b1", "PATCH", "rc", 0, None), "1.0.1rc0"), + # with exact_increment=False: "1.0.0-rc1" + (("1.0.0rc0", "PATCH", "rc", 0, None), "1.0.1rc0"), + # with exact_increment=False: "1.0.0rc1-dev1" + (("1.0.0rc0", "PATCH", "rc", 0, 1), "1.0.1rc0.dev1"), + # with exact_increment=False: "1.0.0b0" + (("1.0.0a1", "MINOR", "beta", 0, None), "1.1.0b0"), + # with exact_increment=False: "1.0.0b1" + (("1.0.0b0", "MINOR", "beta", 0, None), "1.1.0b0"), + # with exact_increment=False: "1.0.0b1" + (("1.0.0b0", "MINOR", "alpha", 0, None), "1.1.0a0"), + # with exact_increment=False: "1.0.0rc0" + (("1.0.0b1", "MINOR", "rc", 0, None), "1.1.0rc0"), + # with exact_increment=False: "1.0.0rc1" + (("1.0.0rc0", "MINOR", "rc", 0, None), "1.1.0rc0"), + # with exact_increment=False: "1.0.0rc1-dev1" + (("1.0.0rc0", "MINOR", "rc", 0, 1), "1.1.0rc0.dev1"), + # with exact_increment=False: "2.0.0" + (("2.0.0b0", "MAJOR", None, 0, None), "3.0.0"), + # with exact_increment=False: "2.0.0" + (("2.0.0b0", "MINOR", None, 0, None), "2.1.0"), + # with exact_increment=False: "2.0.0" + (("2.0.0b0", "PATCH", None, 0, None), "2.0.1"), + # same with exact_increment=False + (("2.0.0b0", "MAJOR", "alpha", 0, None), "3.0.0a0"), + # with exact_increment=False: "2.0.0b1" + (("2.0.0b0", "MINOR", "alpha", 0, None), "2.1.0a0"), + # with exact_increment=False: "2.0.0b1" + (("2.0.0b0", "PATCH", "alpha", 0, None), "2.0.1a0"), +] + + +@pytest.mark.parametrize( + "test_input,expected", + itertools.chain( + tdd_cases, + weird_cases, + simple_flow, + linear_prerelease_cases, + prerelease_cases, + ), +) +def test_bump_pep440_version(test_input, expected): + current_version = test_input[0] + increment = test_input[1] + prerelease = test_input[2] + prerelease_offset = test_input[3] + devrelease = test_input[4] + assert ( + str( + Pep440(current_version).bump( + increment=increment, + prerelease=prerelease, + prerelease_offset=prerelease_offset, + devrelease=devrelease, + ) + ) + == expected + ) + + +@pytest.mark.parametrize("test_input, expected", excact_cases) +def test_bump_pep440_version_force(test_input, expected): + current_version = test_input[0] + increment = test_input[1] + prerelease = test_input[2] + prerelease_offset = test_input[3] + devrelease = test_input[4] + assert ( + str( + Pep440(current_version).bump( + increment=increment, + prerelease=prerelease, + prerelease_offset=prerelease_offset, + devrelease=devrelease, + exact_increment=True, + ) + ) + == expected + ) + + +@pytest.mark.parametrize("test_input,expected", local_versions) +def test_bump_pep440_version_local(test_input, expected): + current_version = test_input[0] + increment = test_input[1] + prerelease = test_input[2] + prerelease_offset = test_input[3] + devrelease = test_input[4] + is_local_version = True + assert ( + str( + Pep440(current_version).bump( + increment=increment, + prerelease=prerelease, + prerelease_offset=prerelease_offset, + devrelease=devrelease, + is_local_version=is_local_version, + ) + ) + == expected + ) + + +def test_pep440_scheme_property(): + version = Pep440("0.0.1") + assert version.scheme is Pep440 + + +def test_pep440_implement_version_protocol(): + assert isinstance(Pep440("0.0.1"), VersionProtocol) + + +def test_pep440_sortable(): + test_input = [x[0][0] for x in simple_flow] + test_input.extend([x[1] for x in simple_flow]) + # randomize + random_input = [Pep440(x) for x in random.sample(test_input, len(test_input))] + assert len(random_input) == len(test_input) + sorted_result = [str(x) for x in sorted(random_input)] + assert sorted_result == [ + "0.1.0", + "0.1.0", + "0.1.1.dev1", + "0.1.1", + "0.1.1", + "0.2.0", + "0.2.0", + "0.2.0", + "0.3.0.dev1", + "0.3.0", + "0.3.0", + "0.3.0", + "0.3.0", + "0.3.1a0", + "0.3.1a0", + "0.3.1a0", + "0.3.1a0", + "0.3.1a1", + "0.3.1a1", + "0.3.1a1", + "0.3.1", + "0.3.1", + "0.3.1", + "0.3.2", + "0.4.2", + "1.0.0a0", + "1.0.0a0", + "1.0.0a1", + "1.0.0a1", + "1.0.0a1", + "1.0.0a1", + "1.0.0a2.dev0", + "1.0.0a2.dev0", + "1.0.0a2.dev1", + "1.0.0a2", + "1.0.0a3.dev0", + "1.0.0a3.dev0", + "1.0.0a3.dev1", + "1.0.0b0", + "1.0.0b0", + "1.0.0b0", + "1.0.0b1", + "1.0.0b1", + "1.0.0rc0", + "1.0.0rc0", + "1.0.0rc0", + "1.0.0rc0", + "1.0.0rc1.dev1", + "1.0.0rc1", + "1.0.0", + "1.0.0", + "1.0.1", + "1.0.1", + "1.0.2", + "1.0.2", + "1.1.0", + "1.1.0", + "1.2.0", + "1.2.0", + "1.2.1", + "1.2.1", + "2.0.0", + ] diff --git a/tests/test_version_scheme_semver.py b/tests/test_version_scheme_semver.py new file mode 100644 index 0000000..71d5e58 --- /dev/null +++ b/tests/test_version_scheme_semver.py @@ -0,0 +1,269 @@ +import itertools +import random + +import pytest + +from commitizen.version_schemes import SemVer, VersionProtocol + +simple_flow = [ + (("0.1.0", "PATCH", None, 0, None), "0.1.1"), + (("0.1.0", "PATCH", None, 0, 1), "0.1.1-dev1"), + (("0.1.1", "MINOR", None, 0, None), "0.2.0"), + (("0.2.0", "MINOR", None, 0, None), "0.3.0"), + (("0.2.0", "MINOR", None, 0, 1), "0.3.0-dev1"), + (("0.3.0", "PATCH", None, 0, None), "0.3.1"), + (("0.3.0", "PATCH", "alpha", 0, None), "0.3.1-a0"), + (("0.3.1a0", None, "alpha", 0, None), "0.3.1-a1"), + (("0.3.0", "PATCH", "alpha", 1, None), "0.3.1-a1"), + (("0.3.1a0", None, "alpha", 1, None), "0.3.1-a1"), + (("0.3.1a0", None, None, 0, None), "0.3.1"), + (("0.3.1", "PATCH", None, 0, None), "0.3.2"), + (("0.4.2", "MAJOR", "alpha", 0, None), "1.0.0-a0"), + (("1.0.0a0", None, "alpha", 0, None), "1.0.0-a1"), + (("1.0.0a1", None, "alpha", 0, None), "1.0.0-a2"), + (("1.0.0a1", None, "alpha", 0, 1), "1.0.0-a2-dev1"), + (("1.0.0a2.dev0", None, "alpha", 0, 1), "1.0.0-a3-dev1"), + (("1.0.0a2.dev0", None, "alpha", 0, 0), "1.0.0-a3-dev0"), + (("1.0.0a1", None, "beta", 0, None), "1.0.0-b0"), + (("1.0.0b0", None, "beta", 0, None), "1.0.0-b1"), + (("1.0.0b1", None, "rc", 0, None), "1.0.0-rc0"), + (("1.0.0rc0", None, "rc", 0, None), "1.0.0-rc1"), + (("1.0.0rc0", None, "rc", 0, 1), "1.0.0-rc1-dev1"), + (("1.0.0rc0", "PATCH", None, 0, None), "1.0.0"), + (("1.0.0a3.dev0", None, "beta", 0, None), "1.0.0-b0"), + (("1.0.0", "PATCH", None, 0, None), "1.0.1"), + (("1.0.1", "PATCH", None, 0, None), "1.0.2"), + (("1.0.2", "MINOR", None, 0, None), "1.1.0"), + (("1.1.0", "MINOR", None, 0, None), "1.2.0"), + (("1.2.0", "PATCH", None, 0, None), "1.2.1"), + (("1.2.1", "MAJOR", None, 0, None), "2.0.0"), +] + +local_versions = [ + (("4.5.0+0.1.0", "PATCH", None, 0, None), "4.5.0+0.1.1"), + (("4.5.0+0.1.1", "MINOR", None, 0, None), "4.5.0+0.2.0"), + (("4.5.0+0.2.0", "MAJOR", None, 0, None), "4.5.0+1.0.0"), +] + +# never bump backwards on pre-releases +linear_prerelease_cases = [ + (("0.1.1b1", None, "alpha", 0, None), "0.1.1-b2"), + (("0.1.1rc0", None, "alpha", 0, None), "0.1.1-rc1"), + (("0.1.1rc0", None, "beta", 0, None), "0.1.1-rc1"), +] + +weird_cases = [ + (("1.1", "PATCH", None, 0, None), "1.1.1"), + (("1", "MINOR", None, 0, None), "1.1.0"), + (("1", "MAJOR", None, 0, None), "2.0.0"), + (("1a0", None, "alpha", 0, None), "1.0.0-a1"), + (("1a0", None, "alpha", 1, None), "1.0.0-a1"), + (("1", None, "beta", 0, None), "1.0.0-b0"), + (("1", None, "beta", 1, None), "1.0.0-b1"), + (("1beta", None, "beta", 0, None), "1.0.0-b1"), + (("1.0.0alpha1", None, "alpha", 0, None), "1.0.0-a2"), + (("1", None, "rc", 0, None), "1.0.0-rc0"), + (("1.0.0rc1+e20d7b57f3eb", "PATCH", None, 0, None), "1.0.0"), +] + +# test driven development +tdd_cases = [ + (("0.1.1", "PATCH", None, 0, None), "0.1.2"), + (("0.1.1", "MINOR", None, 0, None), "0.2.0"), + (("2.1.1", "MAJOR", None, 0, None), "3.0.0"), + (("0.9.0", "PATCH", "alpha", 0, None), "0.9.1-a0"), + (("0.9.0", "MINOR", "alpha", 0, None), "0.10.0-a0"), + (("0.9.0", "MAJOR", "alpha", 0, None), "1.0.0-a0"), + (("0.9.0", "MAJOR", "alpha", 1, None), "1.0.0-a1"), + (("1.0.0a2", None, "beta", 0, None), "1.0.0-b0"), + (("1.0.0a2", None, "beta", 1, None), "1.0.0-b1"), + (("1.0.0beta1", None, "rc", 0, None), "1.0.0-rc0"), + (("1.0.0rc1", None, "rc", 0, None), "1.0.0-rc2"), + (("1.0.0-a0", None, "rc", 0, None), "1.0.0-rc0"), + (("1.0.0-alpha1", None, "alpha", 0, None), "1.0.0-a2"), +] + +excact_cases = [ + (("1.0.0", "PATCH", None, 0, None), "1.0.1"), + (("1.0.0", "MINOR", None, 0, None), "1.1.0"), + # with exact_increment=False: "1.0.0-b0" + (("1.0.0a1", "PATCH", "beta", 0, None), "1.0.1-b0"), + # with exact_increment=False: "1.0.0-b1" + (("1.0.0b0", "PATCH", "beta", 0, None), "1.0.1-b0"), + # with exact_increment=False: "1.0.0-rc0" + (("1.0.0b1", "PATCH", "rc", 0, None), "1.0.1-rc0"), + # with exact_increment=False: "1.0.0-rc1" + (("1.0.0rc0", "PATCH", "rc", 0, None), "1.0.1-rc0"), + # with exact_increment=False: "1.0.0-rc1-dev1" + (("1.0.0rc0", "PATCH", "rc", 0, 1), "1.0.1-rc0-dev1"), + # with exact_increment=False: "1.0.0-b0" + (("1.0.0a1", "MINOR", "beta", 0, None), "1.1.0-b0"), + # with exact_increment=False: "1.0.0-b1" + (("1.0.0b0", "MINOR", "beta", 0, None), "1.1.0-b0"), + # with exact_increment=False: "1.0.0-rc0" + (("1.0.0b1", "MINOR", "rc", 0, None), "1.1.0-rc0"), + # with exact_increment=False: "1.0.0-rc1" + (("1.0.0rc0", "MINOR", "rc", 0, None), "1.1.0-rc0"), + # with exact_increment=False: "1.0.0-rc1-dev1" + (("1.0.0rc0", "MINOR", "rc", 0, 1), "1.1.0-rc0-dev1"), + # with exact_increment=False: "2.0.0" + (("2.0.0b0", "MAJOR", None, 0, None), "3.0.0"), + # with exact_increment=False: "2.0.0" + (("2.0.0b0", "MINOR", None, 0, None), "2.1.0"), + # with exact_increment=False: "2.0.0" + (("2.0.0b0", "PATCH", None, 0, None), "2.0.1"), + # same with exact_increment=False + (("2.0.0b0", "MAJOR", "alpha", 0, None), "3.0.0-a0"), + # with exact_increment=False: "2.0.0b1" + (("2.0.0b0", "MINOR", "alpha", 0, None), "2.1.0-a0"), + # with exact_increment=False: "2.0.0b1" + (("2.0.0b0", "PATCH", "alpha", 0, None), "2.0.1-a0"), +] + + +@pytest.mark.parametrize( + "test_input, expected", + itertools.chain(tdd_cases, weird_cases, simple_flow, linear_prerelease_cases), +) +def test_bump_semver_version(test_input, expected): + current_version = test_input[0] + increment = test_input[1] + prerelease = test_input[2] + prerelease_offset = test_input[3] + devrelease = test_input[4] + assert ( + str( + SemVer(current_version).bump( + increment=increment, + prerelease=prerelease, + prerelease_offset=prerelease_offset, + devrelease=devrelease, + ) + ) + == expected + ) + + +@pytest.mark.parametrize("test_input, expected", excact_cases) +def test_bump_semver_version_force(test_input, expected): + current_version = test_input[0] + increment = test_input[1] + prerelease = test_input[2] + prerelease_offset = test_input[3] + devrelease = test_input[4] + assert ( + str( + SemVer(current_version).bump( + increment=increment, + prerelease=prerelease, + prerelease_offset=prerelease_offset, + devrelease=devrelease, + exact_increment=True, + ) + ) + == expected + ) + + +@pytest.mark.parametrize("test_input,expected", local_versions) +def test_bump_semver_version_local(test_input, expected): + current_version = test_input[0] + increment = test_input[1] + prerelease = test_input[2] + prerelease_offset = test_input[3] + devrelease = test_input[4] + is_local_version = True + assert ( + str( + SemVer(current_version).bump( + increment=increment, + prerelease=prerelease, + prerelease_offset=prerelease_offset, + devrelease=devrelease, + is_local_version=is_local_version, + ) + ) + == expected + ) + + +def test_semver_scheme_property(): + version = SemVer("0.0.1") + assert version.scheme is SemVer + + +def test_semver_implement_version_protocol(): + assert isinstance(SemVer("0.0.1"), VersionProtocol) + + +def test_semver_sortable(): + test_input = [x[0][0] for x in simple_flow] + test_input.extend([x[1] for x in simple_flow]) + # randomize + random_input = [SemVer(x) for x in random.sample(test_input, len(test_input))] + assert len(random_input) == len(test_input) + sorted_result = [str(x) for x in sorted(random_input)] + assert sorted_result == [ + "0.1.0", + "0.1.0", + "0.1.1-dev1", + "0.1.1", + "0.1.1", + "0.2.0", + "0.2.0", + "0.2.0", + "0.3.0-dev1", + "0.3.0", + "0.3.0", + "0.3.0", + "0.3.0", + "0.3.1-a0", + "0.3.1-a0", + "0.3.1-a0", + "0.3.1-a0", + "0.3.1-a1", + "0.3.1-a1", + "0.3.1-a1", + "0.3.1", + "0.3.1", + "0.3.1", + "0.3.2", + "0.4.2", + "1.0.0-a0", + "1.0.0-a0", + "1.0.0-a1", + "1.0.0-a1", + "1.0.0-a1", + "1.0.0-a1", + "1.0.0-a2-dev0", + "1.0.0-a2-dev0", + "1.0.0-a2-dev1", + "1.0.0-a2", + "1.0.0-a3-dev0", + "1.0.0-a3-dev0", + "1.0.0-a3-dev1", + "1.0.0-b0", + "1.0.0-b0", + "1.0.0-b0", + "1.0.0-b1", + "1.0.0-b1", + "1.0.0-rc0", + "1.0.0-rc0", + "1.0.0-rc0", + "1.0.0-rc0", + "1.0.0-rc1-dev1", + "1.0.0-rc1", + "1.0.0", + "1.0.0", + "1.0.1", + "1.0.1", + "1.0.2", + "1.0.2", + "1.1.0", + "1.1.0", + "1.2.0", + "1.2.0", + "1.2.1", + "1.2.1", + "2.0.0", + ] diff --git a/tests/test_version_scheme_semver2.py b/tests/test_version_scheme_semver2.py new file mode 100644 index 0000000..d18a058 --- /dev/null +++ b/tests/test_version_scheme_semver2.py @@ -0,0 +1,211 @@ +import itertools +import random + +import pytest + +from commitizen.version_schemes import SemVer2, VersionProtocol + +simple_flow = [ + (("0.1.0", "PATCH", None, 0, None), "0.1.1"), + (("0.1.0", "PATCH", None, 0, 1), "0.1.1-dev.1"), + (("0.1.1", "MINOR", None, 0, None), "0.2.0"), + (("0.2.0", "MINOR", None, 0, None), "0.3.0"), + (("0.2.0", "MINOR", None, 0, 1), "0.3.0-dev.1"), + (("0.3.0", "PATCH", None, 0, None), "0.3.1"), + (("0.3.0", "PATCH", "alpha", 0, None), "0.3.1-alpha.0"), + (("0.3.1-alpha.0", None, "alpha", 0, None), "0.3.1-alpha.1"), + (("0.3.0", "PATCH", "alpha", 1, None), "0.3.1-alpha.1"), + (("0.3.1-alpha.0", None, "alpha", 1, None), "0.3.1-alpha.1"), + (("0.3.1-alpha.0", None, None, 0, None), "0.3.1"), + (("0.3.1", "PATCH", None, 0, None), "0.3.2"), + (("0.4.2", "MAJOR", "alpha", 0, None), "1.0.0-alpha.0"), + (("1.0.0-alpha.0", None, "alpha", 0, None), "1.0.0-alpha.1"), + (("1.0.0-alpha.1", None, "alpha", 0, None), "1.0.0-alpha.2"), + (("1.0.0-alpha.1", None, "alpha", 0, 1), "1.0.0-alpha.2.dev.1"), + (("1.0.0-alpha.2.dev.0", None, "alpha", 0, 1), "1.0.0-alpha.3.dev.1"), + (("1.0.0-alpha.2.dev.0", None, "alpha", 0, 0), "1.0.0-alpha.3.dev.0"), + (("1.0.0-alpha.1", None, "beta", 0, None), "1.0.0-beta.0"), + (("1.0.0-beta.0", None, "beta", 0, None), "1.0.0-beta.1"), + (("1.0.0-beta.1", None, "rc", 0, None), "1.0.0-rc.0"), + (("1.0.0-rc.0", None, "rc", 0, None), "1.0.0-rc.1"), + (("1.0.0-rc.0", None, "rc", 0, 1), "1.0.0-rc.1.dev.1"), + (("1.0.0-rc.0", "PATCH", None, 0, None), "1.0.0"), + (("1.0.0-alpha.3.dev.0", None, "beta", 0, None), "1.0.0-beta.0"), + (("1.0.0", "PATCH", None, 0, None), "1.0.1"), + (("1.0.1", "PATCH", None, 0, None), "1.0.2"), + (("1.0.2", "MINOR", None, 0, None), "1.1.0"), + (("1.1.0", "MINOR", None, 0, None), "1.2.0"), + (("1.2.0", "PATCH", None, 0, None), "1.2.1"), + (("1.2.1", "MAJOR", None, 0, None), "2.0.0"), +] + +local_versions = [ + (("4.5.0+0.1.0", "PATCH", None, 0, None), "4.5.0+0.1.1"), + (("4.5.0+0.1.1", "MINOR", None, 0, None), "4.5.0+0.2.0"), + (("4.5.0+0.2.0", "MAJOR", None, 0, None), "4.5.0+1.0.0"), +] + +# never bump backwards on pre-releases +linear_prerelease_cases = [ + (("0.1.1-beta.1", None, "alpha", 0, None), "0.1.1-beta.2"), + (("0.1.1-rc.0", None, "alpha", 0, None), "0.1.1-rc.1"), + (("0.1.1-rc.0", None, "beta", 0, None), "0.1.1-rc.1"), +] + +weird_cases = [ + (("1.1", "PATCH", None, 0, None), "1.1.1"), + (("1", "MINOR", None, 0, None), "1.1.0"), + (("1", "MAJOR", None, 0, None), "2.0.0"), + (("1-alpha.0", None, "alpha", 0, None), "1.0.0-alpha.1"), + (("1-alpha.0", None, "alpha", 1, None), "1.0.0-alpha.1"), + (("1", None, "beta", 0, None), "1.0.0-beta.0"), + (("1", None, "beta", 1, None), "1.0.0-beta.1"), + (("1-beta", None, "beta", 0, None), "1.0.0-beta.1"), + (("1.0.0-alpha.1", None, "alpha", 0, None), "1.0.0-alpha.2"), + (("1", None, "rc", 0, None), "1.0.0-rc.0"), + (("1.0.0-rc.1+e20d7b57f3eb", "PATCH", None, 0, None), "1.0.0"), +] + +# test driven development +tdd_cases = [ + (("0.1.1", "PATCH", None, 0, None), "0.1.2"), + (("0.1.1", "MINOR", None, 0, None), "0.2.0"), + (("2.1.1", "MAJOR", None, 0, None), "3.0.0"), + (("0.9.0", "PATCH", "alpha", 0, None), "0.9.1-alpha.0"), + (("0.9.0", "MINOR", "alpha", 0, None), "0.10.0-alpha.0"), + (("0.9.0", "MAJOR", "alpha", 0, None), "1.0.0-alpha.0"), + (("0.9.0", "MAJOR", "alpha", 1, None), "1.0.0-alpha.1"), + (("1.0.0-alpha.2", None, "beta", 0, None), "1.0.0-beta.0"), + (("1.0.0-alpha.2", None, "beta", 1, None), "1.0.0-beta.1"), + (("1.0.0-beta.1", None, "rc", 0, None), "1.0.0-rc.0"), + (("1.0.0-rc.1", None, "rc", 0, None), "1.0.0-rc.2"), + (("1.0.0-alpha.0", None, "rc", 0, None), "1.0.0-rc.0"), + (("1.0.0-alpha.1", None, "alpha", 0, None), "1.0.0-alpha.2"), +] + + +@pytest.mark.parametrize( + "test_input, expected", + itertools.chain(tdd_cases, weird_cases, simple_flow, linear_prerelease_cases), +) +def test_bump_semver_version(test_input, expected): + current_version = test_input[0] + increment = test_input[1] + prerelease = test_input[2] + prerelease_offset = test_input[3] + devrelease = test_input[4] + assert ( + str( + SemVer2(current_version).bump( + increment=increment, + prerelease=prerelease, + prerelease_offset=prerelease_offset, + devrelease=devrelease, + ) + ) + == expected + ) + + +@pytest.mark.parametrize("test_input,expected", local_versions) +def test_bump_semver_version_local(test_input, expected): + current_version = test_input[0] + increment = test_input[1] + prerelease = test_input[2] + prerelease_offset = test_input[3] + devrelease = test_input[4] + is_local_version = True + assert ( + str( + SemVer2(current_version).bump( + increment=increment, + prerelease=prerelease, + prerelease_offset=prerelease_offset, + devrelease=devrelease, + is_local_version=is_local_version, + ) + ) + == expected + ) + + +def test_semver_scheme_property(): + version = SemVer2("0.0.1") + assert version.scheme is SemVer2 + + +def test_semver_implement_version_protocol(): + assert isinstance(SemVer2("0.0.1"), VersionProtocol) + + +def test_semver_sortable(): + test_input = [x[0][0] for x in simple_flow] + test_input.extend([x[1] for x in simple_flow]) + # randomize + random_input = [SemVer2(x) for x in random.sample(test_input, len(test_input))] + assert len(random_input) == len(test_input) + sorted_result = [str(x) for x in sorted(random_input)] + assert sorted_result == [ + "0.1.0", + "0.1.0", + "0.1.1-dev.1", + "0.1.1", + "0.1.1", + "0.2.0", + "0.2.0", + "0.2.0", + "0.3.0-dev.1", + "0.3.0", + "0.3.0", + "0.3.0", + "0.3.0", + "0.3.1-alpha.0", + "0.3.1-alpha.0", + "0.3.1-alpha.0", + "0.3.1-alpha.0", + "0.3.1-alpha.1", + "0.3.1-alpha.1", + "0.3.1-alpha.1", + "0.3.1", + "0.3.1", + "0.3.1", + "0.3.2", + "0.4.2", + "1.0.0-alpha.0", + "1.0.0-alpha.0", + "1.0.0-alpha.1", + "1.0.0-alpha.1", + "1.0.0-alpha.1", + "1.0.0-alpha.1", + "1.0.0-alpha.2.dev.0", + "1.0.0-alpha.2.dev.0", + "1.0.0-alpha.2.dev.1", + "1.0.0-alpha.2", + "1.0.0-alpha.3.dev.0", + "1.0.0-alpha.3.dev.0", + "1.0.0-alpha.3.dev.1", + "1.0.0-beta.0", + "1.0.0-beta.0", + "1.0.0-beta.0", + "1.0.0-beta.1", + "1.0.0-beta.1", + "1.0.0-rc.0", + "1.0.0-rc.0", + "1.0.0-rc.0", + "1.0.0-rc.0", + "1.0.0-rc.1.dev.1", + "1.0.0-rc.1", + "1.0.0", + "1.0.0", + "1.0.1", + "1.0.1", + "1.0.2", + "1.0.2", + "1.1.0", + "1.1.0", + "1.2.0", + "1.2.0", + "1.2.1", + "1.2.1", + "2.0.0", + ] diff --git a/tests/test_version_schemes.py b/tests/test_version_schemes.py new file mode 100644 index 0000000..8e2dae9 --- /dev/null +++ b/tests/test_version_schemes.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import sys + +if sys.version_info >= (3, 10): + from importlib import metadata +else: + import importlib_metadata as metadata + +import pytest +from pytest_mock import MockerFixture + +from commitizen.config.base_config import BaseConfig +from commitizen.exceptions import VersionSchemeUnknown +from commitizen.version_schemes import Pep440, SemVer, get_version_scheme + + +def test_default_version_scheme_is_pep440(config: BaseConfig): + scheme = get_version_scheme(config.settings) + assert scheme is Pep440 + + +def test_version_scheme_from_config(config: BaseConfig): + config.settings["version_scheme"] = "semver" + scheme = get_version_scheme(config.settings) + assert scheme is SemVer + + +def test_version_scheme_from_name(config: BaseConfig): + config.settings["version_scheme"] = "pep440" + scheme = get_version_scheme(config.settings, "semver") + assert scheme is SemVer + + +def test_raise_for_unknown_version_scheme(config: BaseConfig): + with pytest.raises(VersionSchemeUnknown): + get_version_scheme(config.settings, "unknown") + + +def test_version_scheme_from_deprecated_config(config: BaseConfig): + config.settings["version_type"] = "semver" + with pytest.warns(DeprecationWarning): + scheme = get_version_scheme(config.settings) + assert scheme is SemVer + + +def test_version_scheme_from_config_priority(config: BaseConfig): + config.settings["version_scheme"] = "pep440" + config.settings["version_type"] = "semver" + with pytest.warns(DeprecationWarning): + scheme = get_version_scheme(config.settings) + assert scheme is Pep440 + + +def test_warn_if_version_protocol_not_implemented( + config: BaseConfig, mocker: MockerFixture +): + class NotVersionProtocol: + pass + + ep = mocker.Mock() + ep.load.return_value = NotVersionProtocol + mocker.patch.object(metadata, "entry_points", return_value=(ep,)) + + with pytest.warns(match="VersionProtocol"): + get_version_scheme(config.settings, "any") diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..5e26b2d --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import sys +import time +import uuid +from pathlib import Path + +import pytest +from deprecated import deprecated + +from commitizen import cmd, exceptions, git + +skip_below_py_3_10 = pytest.mark.skipif( + sys.version_info < (3, 10), + reason="The output message of argparse is different between Python 3.10 and lower than Python 3.10", +) + +skip_below_py_3_13 = pytest.mark.skipif( + sys.version_info < (3, 13), + reason="The output message of argparse is different between Python 3.13 and lower than Python 3.13", +) + + +class FakeCommand: + def __init__(self, out=None, err=None, return_code=0): + self.out = out + self.err = err + self.return_code = return_code + + +def create_file_and_commit( + message: str, filename: str | None = None, committer_date: str | None = None +): + if not filename: + filename = str(uuid.uuid4()) + + Path(filename).touch() + c = cmd.run("git add .") + if c.return_code != 0: + raise exceptions.CommitError(c.err) + c = git.commit(message, committer_date=committer_date) + if c.return_code != 0: + raise exceptions.CommitError(c.err) + + +def create_branch(name: str): + c = cmd.run(f"git branch {name}") + if c.return_code != 0: + raise exceptions.GitCommandError(c.err) + + +def switch_branch(branch: str): + c = cmd.run(f"git switch {branch}") + if c.return_code != 0: + raise exceptions.GitCommandError(c.err) + + +def merge_branch(branch: str): + c = cmd.run(f"git merge {branch}") + if c.return_code != 0: + raise exceptions.GitCommandError(c.err) + + +def get_current_branch() -> str: + c = cmd.run("git rev-parse --abbrev-ref HEAD") + if c.return_code != 0: + raise exceptions.GitCommandError(c.err) + return c.out + + +def create_tag(tag: str, message: str | None = None) -> None: + c = git.tag(tag, annotated=(message is not None), msg=message) + if c.return_code != 0: + raise exceptions.CommitError(c.err) + + +@deprecated( + reason="\n\ +Prefer using `create_file_and_commit(filename, committer_date={your_date})` to influence the order of tags.\n\ +This is because lightweight tags (like the ones created here) use the commit's creatordate which we can specify \ +with the GIT_COMMITTER_DATE flag, instead of waiting entire seconds during tests." +) +def wait_for_tag(): + """Deprecated -- use `create_file_and_commit(filename, committer_date={your_date})` to order tags instead + + Wait for tag. + + The resolution of timestamps is 1 second, so we need to wait + to create another tag unfortunately. + + This means: + If we create 2 tags under the same second, they might be returned in the wrong order + + See https://stackoverflow.com/questions/28237043/what-is-the-resolution-of-gits-commit-date-or-author-date-timestamps + """ + time.sleep(1.1)