Compare commits
2 commits
424668c7d4
...
caa80e8849
Author | SHA1 | Date | |
---|---|---|---|
caa80e8849 | |||
7791e7adfd |
236 changed files with 25045 additions and 0 deletions
3
.codacy.yaml
Normal file
3
.codacy.yaml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
exclude_paths:
|
||||||
|
- 'tests/**'
|
||||||
|
- 'docs/**'
|
7
.github/.codecov.yml
vendored
Normal file
7
.github/.codecov.yml
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
coverage:
|
||||||
|
status:
|
||||||
|
project:
|
||||||
|
default:
|
||||||
|
# minimum of 97% (real 96%)
|
||||||
|
target: 97%
|
||||||
|
threshold: 1%
|
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
* @woile @Lee-W @noirbizarre
|
4
.github/FUNDING.yml
vendored
Normal file
4
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
open_collective: commitizen-tools
|
||||||
|
github: commitizen-tools
|
61
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
61
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
|
@ -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
|
6
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
6
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
@ -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
|
29
.github/ISSUE_TEMPLATE/documentation.yml
vendored
Normal file
29
.github/ISSUE_TEMPLATE/documentation.yml
vendored
Normal file
|
@ -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
|
31
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
31
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
name: 🚀 Feature request
|
||||||
|
description: Suggest an idea for this project
|
||||||
|
title: "<One feature request per issue>"
|
||||||
|
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.
|
24
.github/dependabot.yml
vendored
Normal file
24
.github/dependabot.yml
vendored
Normal file
|
@ -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"
|
3
.github/labeler.yml
vendored
Normal file
3
.github/labeler.yml
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
'pr-status: wait-for-review':
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file: '**'
|
29
.github/pull_request_template.md
vendored
Normal file
29
.github/pull_request_template.md
vendored
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<!--
|
||||||
|
Thanks for sending a pull request!
|
||||||
|
Please fill in the following content to let us know better about this change.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
<!-- Describe what the change is -->
|
||||||
|
|
||||||
|
|
||||||
|
## 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
|
||||||
|
<!-- A clear and concise description of what you expected to happen -->
|
||||||
|
|
||||||
|
|
||||||
|
## Steps to Test This Pull Request
|
||||||
|
<!-- Steps to reproduce the behavior:
|
||||||
|
1. ...
|
||||||
|
2. ...
|
||||||
|
3. ... -->
|
||||||
|
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
<!-- Add any other RELATED ISSUE, context or screenshots about the pull request here. -->
|
29
.github/workflows/bumpversion.yml
vendored
Normal file
29
.github/workflows/bumpversion.yml
vendored
Normal file
|
@ -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
|
78
.github/workflows/docspublish.yml
vendored
Normal file
78
.github/workflows/docspublish.yml
vendored
Normal file
|
@ -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"
|
32
.github/workflows/homebrewpublish.yml
vendored
Normal file
32
.github/workflows/homebrewpublish.yml
vendored
Normal file
|
@ -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
|
23
.github/workflows/label_issues.yml
vendored
Normal file
23
.github/workflows/label_issues.yml
vendored
Normal file
|
@ -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']
|
||||||
|
})
|
19
.github/workflows/label_pr.yml
vendored
Normal file
19
.github/workflows/label_pr.yml
vendored
Normal file
|
@ -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
|
38
.github/workflows/pythonpackage.yml
vendored
Normal file
38
.github/workflows/pythonpackage.yml
vendored
Normal file
|
@ -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
|
28
.github/workflows/pythonpublish.yml
vendored
Normal file
28
.github/workflows/pythonpublish.yml
vendored
Normal file
|
@ -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
|
115
.gitignore
vendored
Normal file
115
.gitignore
vendored
Normal file
|
@ -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
|
72
.pre-commit-config.yaml
Normal file
72
.pre-commit-config.yaml
Normal file
|
@ -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 ]
|
27
.pre-commit-hooks.yaml
Normal file
27
.pre-commit-hooks.yaml
Normal file
|
@ -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"
|
1986
CHANGELOG.md
Normal file
1986
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load diff
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -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.
|
4
MANIFEST.in
Normal file
4
MANIFEST.in
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
include .bumpversion.cfg
|
||||||
|
include LICENSE
|
||||||
|
include commitizen/cz/*.txt
|
||||||
|
global-exclude *.py[cod] __pycache__ *.so *.dylib
|
29
commitizen/__init__.py
Normal file
29
commitizen/__init__.py
Normal file
|
@ -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"]
|
4
commitizen/__main__.py
Normal file
4
commitizen/__main__.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from commitizen.cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
1
commitizen/__version__.py
Normal file
1
commitizen/__version__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
__version__ = "4.6.0"
|
153
commitizen/bump.py
Normal file
153
commitizen/bump.py
Normal file
|
@ -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)
|
353
commitizen/changelog.py
Normal file
353
commitizen/changelog.py
Normal file
|
@ -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
|
93
commitizen/changelog_formats/__init__.py
Normal file
93
commitizen/changelog_formats/__init__.py
Normal file
|
@ -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
|
28
commitizen/changelog_formats/asciidoc.py
Normal file
28
commitizen/changelog_formats/asciidoc.py
Normal file
|
@ -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<level>=+) (?P<title>.*)$")
|
||||||
|
|
||||||
|
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"))
|
86
commitizen/changelog_formats/base.py
Normal file
86
commitizen/changelog_formats/base.py
Normal file
|
@ -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"
|
||||||
|
)
|
29
commitizen/changelog_formats/markdown.py
Normal file
29
commitizen/changelog_formats/markdown.py
Normal file
|
@ -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"))
|
92
commitizen/changelog_formats/restructuredtext.py
Normal file
92
commitizen/changelog_formats/restructuredtext.py
Normal file
|
@ -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:])
|
||||||
|
)
|
26
commitizen/changelog_formats/textile.py
Normal file
26
commitizen/changelog_formats/textile.py
Normal file
|
@ -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"))
|
660
commitizen/cli.py
Normal file
660
commitizen/cli.py
Normal file
|
@ -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()
|
50
commitizen/cmd.py
Normal file
50
commitizen/cmd.py
Normal file
|
@ -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,
|
||||||
|
)
|
23
commitizen/commands/__init__.py
Normal file
23
commitizen/commands/__init__.py
Normal file
|
@ -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",
|
||||||
|
)
|
439
commitizen/commands/bump.py
Normal file
439
commitizen/commands/bump.py
Normal file
|
@ -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)
|
232
commitizen/commands/changelog.py
Normal file
232
commitizen/commands/changelog.py
Normal file
|
@ -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)
|
155
commitizen/commands/check.py
Normal file
155
commitizen/commands/check.py
Normal file
|
@ -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))
|
170
commitizen/commands/commit.py
Normal file
170
commitizen/commands/commit.py
Normal file
|
@ -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!")
|
13
commitizen/commands/example.py
Normal file
13
commitizen/commands/example.py
Normal file
|
@ -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())
|
13
commitizen/commands/info.py
Normal file
13
commitizen/commands/info.py
Normal file
|
@ -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())
|
374
commitizen/commands/init.py
Normal file
374
commitizen/commands/init.py
Normal file
|
@ -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)
|
13
commitizen/commands/list_cz.py
Normal file
13
commitizen/commands/list_cz.py
Normal file
|
@ -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()))
|
13
commitizen/commands/schema.py
Normal file
13
commitizen/commands/schema.py
Normal file
|
@ -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())
|
39
commitizen/commands/version.py
Normal file
39
commitizen/commands/version.py
Normal file
|
@ -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__}")
|
58
commitizen/config/__init__.py
Normal file
58
commitizen/config/__init__.py
Normal file
|
@ -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
|
37
commitizen/config/base_config.py
Normal file
37
commitizen/config/base_config.py
Normal file
|
@ -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()
|
56
commitizen/config/json_config.py
Normal file
56
commitizen/config/json_config.py
Normal file
|
@ -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
|
63
commitizen/config/toml_config.py
Normal file
63
commitizen/config/toml_config.py
Normal file
|
@ -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
|
57
commitizen/config/yaml_config.py
Normal file
57
commitizen/config/yaml_config.py
Normal file
|
@ -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
|
44
commitizen/cz/__init__.py
Normal file
44
commitizen/cz/__init__.py
Normal file
|
@ -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()
|
108
commitizen/cz/base.py
Normal file
108
commitizen/cz/base.py
Normal file
|
@ -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]
|
1
commitizen/cz/conventional_commits/__init__.py
Normal file
1
commitizen/cz/conventional_commits/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from .conventional_commits import ConventionalCommitsCz # noqa: F401
|
212
commitizen/cz/conventional_commits/conventional_commits.py
Normal file
212
commitizen/cz/conventional_commits/conventional_commits.py
Normal file
|
@ -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()
|
|
@ -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]
|
1
commitizen/cz/customize/__init__.py
Normal file
1
commitizen/cz/customize/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from .customize import CustomizeCommitsCz # noqa
|
94
commitizen/cz/customize/customize.py
Normal file
94
commitizen/cz/customize/customize.py
Normal file
|
@ -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 ""
|
0
commitizen/cz/customize/customize_info.txt
Normal file
0
commitizen/cz/customize/customize_info.txt
Normal file
4
commitizen/cz/exceptions.py
Normal file
4
commitizen/cz/exceptions.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
class CzException(Exception): ...
|
||||||
|
|
||||||
|
|
||||||
|
class AnswerRequiredError(CzException): ...
|
3
commitizen/cz/jira/__init__.py
Normal file
3
commitizen/cz/jira/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
from .jira import JiraSmartCz
|
||||||
|
|
||||||
|
__all__ = ["JiraSmartCz"]
|
81
commitizen/cz/jira/jira.py
Normal file
81
commitizen/cz/jira/jira.py
Normal file
|
@ -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
|
84
commitizen/cz/jira/jira_info.txt
Normal file
84
commitizen/cz/jira/jira_info.txt
Normal file
|
@ -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
|
32
commitizen/cz/utils.py
Normal file
32
commitizen/cz/utils.py
Normal file
|
@ -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")
|
156
commitizen/defaults.py
Normal file
156
commitizen/defaults.py
Normal file
|
@ -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()},
|
||||||
|
}
|
213
commitizen/exceptions.py
Normal file
213
commitizen/exceptions.py
Normal file
|
@ -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."
|
19
commitizen/factory.py
Normal file
19
commitizen/factory.py
Normal file
|
@ -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
|
312
commitizen/git.py
Normal file
312
commitizen/git.py
Normal file
|
@ -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")
|
36
commitizen/hooks.py
Normal file
36
commitizen/hooks.py
Normal file
|
@ -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
|
42
commitizen/out.py
Normal file
42
commitizen/out.py
Normal file
|
@ -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)
|
51
commitizen/providers/__init__.py
Normal file
51
commitizen/providers/__init__.py
Normal file
|
@ -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))
|
91
commitizen/providers/base_provider.py
Normal file
91
commitizen/providers/base_provider.py
Normal file
|
@ -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
|
30
commitizen/providers/cargo_provider.py
Normal file
30
commitizen/providers/cargo_provider.py
Normal file
|
@ -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
|
15
commitizen/providers/commitizen_provider.py
Normal file
15
commitizen/providers/commitizen_provider.py
Normal file
|
@ -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)
|
12
commitizen/providers/composer_provider.py
Normal file
12
commitizen/providers/composer_provider.py
Normal file
|
@ -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
|
82
commitizen/providers/npm_provider.py
Normal file
82
commitizen/providers/npm_provider.py
Normal file
|
@ -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
|
11
commitizen/providers/pep621_provider.py
Normal file
11
commitizen/providers/pep621_provider.py
Normal file
|
@ -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"
|
19
commitizen/providers/poetry_provider.py
Normal file
19
commitizen/providers/poetry_provider.py
Normal file
|
@ -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
|
28
commitizen/providers/scm_provider.py
Normal file
28
commitizen/providers/scm_provider.py
Normal file
|
@ -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
|
41
commitizen/providers/uv_provider.py
Normal file
41
commitizen/providers/uv_provider.py
Normal file
|
@ -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))
|
0
commitizen/py.typed
Normal file
0
commitizen/py.typed
Normal file
267
commitizen/tags.py
Normal file
267
commitizen/tags.py
Normal file
|
@ -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"],
|
||||||
|
)
|
19
commitizen/templates/CHANGELOG.adoc.j2
Normal file
19
commitizen/templates/CHANGELOG.adoc.j2
Normal file
|
@ -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 %}
|
19
commitizen/templates/CHANGELOG.md.j2
Normal file
19
commitizen/templates/CHANGELOG.md.j2
Normal file
|
@ -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 %}
|
23
commitizen/templates/CHANGELOG.rst.j2
Normal file
23
commitizen/templates/CHANGELOG.rst.j2
Normal file
|
@ -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 %}
|
19
commitizen/templates/CHANGELOG.textile.j2
Normal file
19
commitizen/templates/CHANGELOG.textile.j2
Normal file
|
@ -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 %}
|
439
commitizen/version_schemes.py
Normal file
439
commitizen/version_schemes.py
Normal file
|
@ -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
|
5
debian/changelog
vendored
Normal file
5
debian/changelog
vendored
Normal file
|
@ -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
|
47
debian/control
vendored
Normal file
47
debian/control
vendored
Normal file
|
@ -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.
|
32
debian/copyright
vendored
Normal file
32
debian/copyright
vendored
Normal file
|
@ -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.
|
12
debian/rules
vendored
Executable file
12
debian/rules
vendored
Executable file
|
@ -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
|
1
debian/source/format
vendored
Normal file
1
debian/source/format
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
3.0 (quilt)
|
3
debian/watch
vendored
Normal file
3
debian/watch
vendored
Normal file
|
@ -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
|
21
hooks/post-commit.py
Executable file
21
hooks/post-commit.py
Executable file
|
@ -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)
|
61
hooks/prepare-commit-msg.py
Executable file
61
hooks/prepare-commit-msg.py
Executable file
|
@ -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]))
|
78
mkdocs.yml
Normal file
78
mkdocs.yml
Normal file
|
@ -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
|
1968
poetry.lock
generated
Normal file
1968
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
277
pyproject.toml
Normal file
277
pyproject.toml
Normal file
|
@ -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"
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue