From 5ab0dfe15a7b9b7c28a78296086b632f7d09de77 Mon Sep 17 00:00:00 2001
From: "Axel H." <noirbizarre@gmail.com>
Date: Sat, 8 Feb 2025 00:54:22 +0100
Subject: [PATCH 1/2] ci(poe): use `poethepoet` as script runner for dev and ci
 and use poetry dependencies groups in ci

Fix #724
---
 .github/pull_request_template.md    |  2 +-
 .github/workflows/docspublish.yml   | 12 ++++---
 .github/workflows/pythonpackage.yml |  6 ++--
 .github/workflows/pythonpublish.yml | 10 +++---
 .pre-commit-config.yaml             | 11 +++---
 docs/contributing.md                |  8 ++---
 pyproject.toml                      | 54 +++++++++++++++++++++++++++++
 scripts/format                      | 10 ------
 scripts/publish                     |  2 --
 scripts/test                        | 10 ------
 10 files changed, 77 insertions(+), 48 deletions(-)
 delete mode 100755 scripts/format
 delete mode 100755 scripts/publish
 delete mode 100755 scripts/test

diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 14bee6b43..0064604fb 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -10,7 +10,7 @@ Please fill in the following content to let us know better about this change.
 ## Checklist
 
 - [ ] Add test cases to all the changes you introduce
-- [ ] Run `./scripts/format` and `./scripts/test` locally to ensure this change passes linter check and test
+- [ ] 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
 
diff --git a/.github/workflows/docspublish.yml b/.github/workflows/docspublish.yml
index f318b8685..a871d3c37 100644
--- a/.github/workflows/docspublish.yml
+++ b/.github/workflows/docspublish.yml
@@ -19,12 +19,12 @@ jobs:
           python-version: "3.x"
       - name: Install dependencies
         run: |
-          python -m pip install -U pip poetry
+          python -m pip install -U pip poetry poethepoet
           poetry --version
-          poetry install
+          poetry install --only main,script
       - name: Update CLI screenshots
         run: |
-          poetry run python scripts/gen_cli_help_screenshots.py
+          poetry doc:screenshots
       - name: Commit and push updated CLI screenshots
         run: |
           git config --global user.name "github-actions[bot]"
@@ -55,12 +55,14 @@ jobs:
           python-version: "3.x"
       - name: Install dependencies
         run: |
-          python -m pip install -U mkdocs mkdocs-material
+          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: |
-          python -m mkdocs build
+          poetry doc:build
       - name: Generate Sponsors 💖
         uses: JamesIves/github-sponsors-readme-action@v1
         with:
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 8df5e54c0..f2363745c 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -19,14 +19,14 @@ jobs:
           python-version: ${{ matrix.python-version }}
       - name: Install dependencies
         run: |
-          python -m pip install -U pip poetry
+          python -m pip install -U pip poetry poethepoet
           poetry --version
-          poetry install
+          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"
-          SKIP=no-commit-to-branch,commitizen-branch poetry run pre-commit run --all-files --hook-stage pre-push
+          poetry ci
         shell: bash
       - name: Upload coverage to Codecov
         if: runner.os == 'Linux'
diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml
index e3b3aa6f3..dc522bc0f 100644
--- a/.github/workflows/pythonpublish.yml
+++ b/.github/workflows/pythonpublish.yml
@@ -19,12 +19,10 @@ jobs:
           python-version: "3.x"
       - name: Install dependencies
         run: |
-          python -m pip install -U pip poetry mkdocs mkdocs-material
+          python -m pip install -U pip poetry
           poetry --version
-          poetry install
       - name: Publish
         env:
-          PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }}
-          PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
-        run: |
-          ./scripts/publish
+          POETRY_HTTP_BASIC_PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }}
+          POETRY_HTTP_BASIC_PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
+        run: poetry publish --build
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 470d1f162..5db6862ae 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -5,7 +5,6 @@ default_install_hook_types:
 
 default_stages:
   - pre-commit
-  - pre-push
 
 repos:
   - repo: meta
@@ -55,21 +54,19 @@ repos:
       - id: commitizen-branch
         stages:
           - post-commit
-          - pre-push
 
   - repo: local
     hooks:
       - id: format
-        name: format
+        name: Format
         language: system
         pass_filenames: false
-        entry: ./scripts/format
+        entry: poetry format
         types: [ python ]
 
       - id: linter and test
-        name: linter and test
+        name: Linters
         language: system
         pass_filenames: false
-        stages: [ pre-push ]
-        entry: ./scripts/test
+        entry: poetry lint
         types: [ python ]
diff --git a/docs/contributing.md b/docs/contributing.md
index 439e3a19f..0da1707da 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -16,16 +16,16 @@ If you're a first-time contributor, you can check the issues with [good first is
 1. Fork [the repository](https://github.com/commitizen-tools/commitizen).
 2. Clone the repository from your GitHub.
 3. Setup development environment through [poetry](https://python-poetry.org/) (`poetry install`).
-4. Setup [pre-commit](https://pre-commit.com/) hook (`poetry run pre-commit install`)
+4. Setup [pre-commit](https://pre-commit.com/) hook (`poetry setup-pre-commit`)
 5. Check out a new branch and add your modification.
 6. Add test cases for all your changes.
    (We use [CodeCov](https://codecov.io/) to ensure our test coverage does not drop.)
 7. Use [commitizen](https://github.com/commitizen-tools/commitizen) to do git commit. We follow [conventional commits](https://www.conventionalcommits.org/).
-8. Run `./scripts/format` and `./scripts/test` to ensure you follow the coding style and the tests pass.
-9. Optionally, update the `./docs/README.md` or `docs/images/cli_help` (through running `scripts/gen_cli_help_screenshots.py`).
+8. Run `poetry all` to ensure you follow the coding style and the tests pass.
+9. Optionally, update the `./docs/README.md` or `docs/images/cli_help` (through running `poetry doc:screenshots`).
 9. **Do not** update the `CHANGELOG.md`, it will be automatically created after merging to `master`.
 10. **Do not** update the versions in the project, they will be automatically updated.
-10. If your changes are about documentation. Run `poetry run mkdocs serve` to serve documentation locally and check whether there is any warning or error.
+10. If your changes are about documentation. Run `poetry doc` to serve documentation locally and check whether there is any warning or error.
 11. Send a [pull request](https://github.com/commitizen-tools/commitizen/pulls) 🙏
 
 ## Use of GitHub Labels
diff --git a/pyproject.toml b/pyproject.toml
index 47d83785c..56131bef5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -99,6 +99,9 @@ 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"
 
@@ -161,6 +164,9 @@ omit = [
 
 [tool.pytest.ini_options]
 addopts = "--strict-markers"
+testpaths = [
+    "tests/",
+]
 
 [tool.ruff]
 line-length = 88
@@ -202,3 +208,51 @@ ignore_missing_imports = true
 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/"},
+]
+
+test.help = "Run the test suite"
+test.cmd = "pytest -n 3 --dist=loadfile"
+
+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",
+]
+
+"doc:screenshots".help = "Render documentation screeenshots"
+"doc:screenshots".script = "scripts.gen_cli_help_screenshots:gen_cli_help_screenshots"
+
+"doc:build".help = "Build the documentation"
+"doc:build".cmd = "mkdocs build"
+
+doc.help = "Live documentation server"
+doc.cmd = "mkdocs serve"
+
+ci.help = "Run all tasks in CI"
+ci.sequence = [
+    {cmd="pre-commit run --all-files"},
+    "cover",
+]
+ci.env = {SKIP = "no-commit-to-branch"}
+
+setup-pre-commit.help = "Install pre-commit hooks"
+setup-pre-commit.cmd = "pre-commit install"
diff --git a/scripts/format b/scripts/format
deleted file mode 100755
index 0ffe29ba4..000000000
--- a/scripts/format
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/usr/bin/env sh
-set -e
-
-export PREFIX="poetry run python -m "
-
-set -x
-
-# This is needed for running import sorting
-${PREFIX}ruff check --fix commitizen tests
-${PREFIX}ruff format commitizen tests
diff --git a/scripts/publish b/scripts/publish
deleted file mode 100755
index 4d31f1188..000000000
--- a/scripts/publish
+++ /dev/null
@@ -1,2 +0,0 @@
-# Publish to pypi
-poetry publish --build -u $PYPI_USERNAME -p $PYPI_PASSWORD
diff --git a/scripts/test b/scripts/test
deleted file mode 100755
index 894228b41..000000000
--- a/scripts/test
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/usr/bin/env sh
-set -e
-
-export PREFIX='poetry run python -m '
-export REGEX='^(?![.]|venv).*'
-
-${PREFIX}pytest -n 3 --dist=loadfile --cov-report term-missing --cov-report=xml:coverage.xml --cov=commitizen tests/
-${PREFIX}ruff check commitizen/ tests/ --fix
-${PREFIX}mypy commitizen/ tests/
-${PREFIX}commitizen -nr 3 check --rev-range origin/master..

From 6e542f0e7dbf8f2a1084d04358f16fba2ca5a039 Mon Sep 17 00:00:00 2001
From: "Axel H." <noirbizarre@gmail.com>
Date: Sat, 8 Feb 2025 02:09:32 +0100
Subject: [PATCH 2/2] ci(tox): add `test:all` command to run the test suite on
 all support Python versions using `tox`

---
 poetry.lock    | 130 +++++++++++++++++++++++++++++++++++++++++++------
 pyproject.toml |  15 ++++++
 2 files changed, 131 insertions(+), 14 deletions(-)

diff --git a/poetry.lock b/poetry.lock
index 723dceddd..073e1bc24 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -49,6 +49,18 @@ files = [
 [package.extras]
 dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"]
 
+[[package]]
+name = "cachetools"
+version = "5.5.1"
+description = "Extensible memoizing collections and decorators"
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+    {file = "cachetools-5.5.1-py3-none-any.whl", hash = "sha256:b76651fdc3b24ead3c648bbdeeb940c1b04d365b38b4af66788f9ec4a81d42bb"},
+    {file = "cachetools-5.5.1.tar.gz", hash = "sha256:70f238fbba50383ef62e55c6aff6d9673175fe59f7c6782c7a0b9e38f4a9df95"},
+]
+
 [[package]]
 name = "certifi"
 version = "2024.8.30"
@@ -73,6 +85,18 @@ files = [
     {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
 ]
 
+[[package]]
+name = "chardet"
+version = "5.2.0"
+description = "Universal encoding detector for Python 3"
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+    {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"},
+    {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"},
+]
+
 [[package]]
 name = "charset-normalizer"
 version = "3.4.1"
@@ -328,7 +352,7 @@ version = "0.3.9"
 description = "Distribution utilities"
 optional = false
 python-versions = "*"
-groups = ["linters"]
+groups = ["dev", "linters"]
 files = [
     {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"},
     {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"},
@@ -386,7 +410,7 @@ version = "3.16.1"
 description = "A platform independent file lock."
 optional = false
 python-versions = ">=3.8"
-groups = ["linters"]
+groups = ["dev", "linters"]
 files = [
     {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"},
     {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"},
@@ -909,7 +933,7 @@ version = "24.2"
 description = "Core utilities for Python packages"
 optional = false
 python-versions = ">=3.8"
-groups = ["main", "documentation", "test"]
+groups = ["main", "dev", "documentation", "test"]
 files = [
     {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
     {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
@@ -981,7 +1005,7 @@ version = "4.3.6"
 description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
 optional = false
 python-versions = ">=3.8"
-groups = ["documentation", "linters"]
+groups = ["dev", "documentation", "linters"]
 files = [
     {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"},
     {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"},
@@ -998,7 +1022,7 @@ version = "1.5.0"
 description = "plugin and hook calling mechanisms for python"
 optional = false
 python-versions = ">=3.8"
-groups = ["test"]
+groups = ["dev", "test"]
 files = [
     {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
     {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
@@ -1104,6 +1128,26 @@ pyyaml = "*"
 [package.extras]
 extra = ["pygments (>=2.12)"]
 
+[[package]]
+name = "pyproject-api"
+version = "1.9.0"
+description = "API to interact with the python pyproject.toml based projects"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+    {file = "pyproject_api-1.9.0-py3-none-any.whl", hash = "sha256:326df9d68dea22d9d98b5243c46e3ca3161b07a1b9b18e213d1e24fd0e605766"},
+    {file = "pyproject_api-1.9.0.tar.gz", hash = "sha256:7e8a9854b2dfb49454fae421cb86af43efbb2b2454e5646ffb7623540321ae6e"},
+]
+
+[package.dependencies]
+packaging = ">=24.2"
+tomli = {version = ">=2.2.1", markers = "python_version < \"3.11\""}
+
+[package.extras]
+docs = ["furo (>=2024.8.6)", "sphinx-autodoc-typehints (>=3)"]
+testing = ["covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "setuptools (>=75.8)"]
+
 [[package]]
 name = "pytest"
 version = "8.3.4"
@@ -1570,16 +1614,46 @@ tests = ["pytest", "pytest-cov"]
 
 [[package]]
 name = "tomli"
-version = "2.1.0"
+version = "2.2.1"
 description = "A lil' TOML parser"
 optional = false
 python-versions = ">=3.8"
-groups = ["linters", "test"]
-files = [
-    {file = "tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391"},
-    {file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"},
-]
-markers = {linters = "python_version < \"3.11\"", test = "python_full_version <= \"3.11.0a6\""}
+groups = ["dev", "linters", "test"]
+files = [
+    {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"},
+    {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"},
+    {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"},
+    {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"},
+    {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"},
+    {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"},
+    {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"},
+    {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"},
+    {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"},
+    {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"},
+    {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"},
+    {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"},
+    {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"},
+    {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"},
+    {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"},
+    {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"},
+    {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"},
+    {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"},
+    {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"},
+    {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"},
+    {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"},
+    {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"},
+    {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"},
+    {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"},
+    {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"},
+    {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"},
+    {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"},
+    {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"},
+    {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"},
+    {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"},
+    {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"},
+    {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"},
+]
+markers = {dev = "python_version < \"3.11\"", linters = "python_version < \"3.11\"", test = "python_full_version <= \"3.11.0a6\""}
 
 [[package]]
 name = "tomlkit"
@@ -1593,6 +1667,34 @@ files = [
     {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"},
 ]
 
+[[package]]
+name = "tox"
+version = "4.24.1"
+description = "tox is a generic virtualenv management and test command line tool"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+    {file = "tox-4.24.1-py3-none-any.whl", hash = "sha256:57ba7df7d199002c6df8c2db9e6484f3de6ca8f42013c083ea2d4d1e5c6bdc75"},
+    {file = "tox-4.24.1.tar.gz", hash = "sha256:083a720adbc6166fff0b7d1df9d154f9d00bfccb9403b8abf6bc0ee435d6a62e"},
+]
+
+[package.dependencies]
+cachetools = ">=5.5"
+chardet = ">=5.2"
+colorama = ">=0.4.6"
+filelock = ">=3.16.1"
+packaging = ">=24.2"
+platformdirs = ">=4.3.6"
+pluggy = ">=1.5"
+pyproject-api = ">=1.8"
+tomli = {version = ">=2.1", markers = "python_version < \"3.11\""}
+typing-extensions = {version = ">=4.12.2", markers = "python_version < \"3.11\""}
+virtualenv = ">=20.27.1"
+
+[package.extras]
+test = ["devpi-process (>=1.0.2)", "pytest (>=8.3.3)", "pytest-mock (>=3.14)"]
+
 [[package]]
 name = "traitlets"
 version = "5.14.3"
@@ -1694,7 +1796,7 @@ version = "20.27.1"
 description = "Virtual Python Environment builder"
 optional = false
 python-versions = ">=3.8"
-groups = ["linters"]
+groups = ["dev", "linters"]
 files = [
     {file = "virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4"},
     {file = "virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba"},
@@ -1863,4 +1965,4 @@ type = ["pytest-mypy"]
 [metadata]
 lock-version = "2.1"
 python-versions = ">=3.9,<4.0"
-content-hash = "83c82b26a9bff591edf995c9c251e52dc23d9a4024562e1218a783ddf151fc20"
+content-hash = "b0f8544806163bc0dddc039eb313f9d82119b845b3a19dedc381e9c88e8f4466"
diff --git a/pyproject.toml b/pyproject.toml
index 56131bef5..11a3837da 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -104,6 +104,7 @@ packages = [{ include = "commitizen" }, { include = "commitizen/py.typed" }]
 
 [tool.poetry.group.dev.dependencies]
 ipython = "^8.0"
+tox = ">4"
 
 [tool.poetry.group.test.dependencies]
 pytest = ">=7.2,<9.0"
@@ -168,6 +169,17 @@ 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
 
@@ -228,6 +240,9 @@ lint.sequence = [
 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"