├── tbump ├── py.typed ├── __main__.py ├── __init__.py ├── action.py ├── error.py ├── git.py ├── hooks.py ├── executor.py ├── init.py ├── git_bumper.py ├── file_bumper.py ├── cli.py └── config.py ├── tests ├── __init__.py ├── pyproject │ ├── README.rst │ ├── foo │ │ └── __init__.py │ └── pyproject.toml ├── project │ ├── VERSION │ ├── pub.js │ ├── version_info.py │ ├── glob-two.v │ ├── yarn.lock │ ├── glob-one.c │ ├── package.json │ ├── after.py │ ├── before.py │ └── tbump.toml ├── test_api.py ├── test_init.py ├── test_git_bumper.py ├── test_hooks.py ├── conftest.py ├── test_file_bumper.py ├── test_config.py └── test_cli.py ├── setup.cfg ├── .coveragerc ├── .gitignore ├── .copier-answers.yml ├── .github └── workflows │ ├── linters.yml │ └── tests.yml ├── mypy.ini ├── tbump.toml ├── tasks.py ├── pyproject.toml ├── LICENSE ├── README.rst ├── Changelog.rst └── poetry.lock /tbump/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/pyproject/README.rst: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/project/VERSION: -------------------------------------------------------------------------------- 1 | 1.2.41-alpha-1 2 | -------------------------------------------------------------------------------- /tests/pyproject/foo/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.0" 2 | -------------------------------------------------------------------------------- /tbump/__main__.py: -------------------------------------------------------------------------------- 1 | from tbump.cli import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /tests/project/pub.js: -------------------------------------------------------------------------------- 1 | export default PUBLIC_VERSION = '1.2.41'; 2 | -------------------------------------------------------------------------------- /tests/project/version_info.py: -------------------------------------------------------------------------------- 1 | version_info = (1, 2, 41, "alpha", 1) 2 | -------------------------------------------------------------------------------- /tbump/__init__.py: -------------------------------------------------------------------------------- 1 | from tbump.file_bumper import bump_files # noqa: F401 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | build 4 | .venv 5 | max-line-length = 100 6 | -------------------------------------------------------------------------------- /tests/project/glob-two.v: -------------------------------------------------------------------------------- 1 | module test(); 2 | logic [128:0] version_two = "1.2.41-alpha-1"; 3 | endmodule 4 | -------------------------------------------------------------------------------- /tests/project/yarn.lock: -------------------------------------------------------------------------------- 1 | [dependencies] 2 | hello = "1.2.41-alpha-1" 3 | some-dep = "1.2.0-alpha-6" 4 | other-dep = "1.2.41-alpha1" 5 | -------------------------------------------------------------------------------- /tests/project/glob-one.c: -------------------------------------------------------------------------------- 1 | static const char* version_one = "1.2.41-alpha-1"; 2 | 3 | int main() { 4 | puts(version); 5 | return 0; 6 | } -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = tbump 3 | 4 | omit = 5 | tbump/test/* 6 | setup.py 7 | */pytest-* 8 | .venv/* 9 | 10 | branch = True 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .envrc 2 | .venv 3 | .coverage 4 | htmlcov 5 | *.egg-info 6 | 7 | build/ 8 | dist/ 9 | 10 | __pycache__ 11 | .mypy_cache/ 12 | 13 | .vscode 14 | .idea 15 | -------------------------------------------------------------------------------- /tests/project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hello", 3 | "version": "1.2.41-alpha-1", 4 | "dependencies": { 5 | "some-dep": "^1.2.0-alpha-6", 6 | "other-dep": "1.2.41-alpha-1" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tbump/action.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | 4 | class Action(metaclass=abc.ABCMeta): 5 | @abc.abstractmethod 6 | def print_self(self) -> None: 7 | pass 8 | 9 | @abc.abstractmethod 10 | def do(self) -> None: 11 | pass 12 | -------------------------------------------------------------------------------- /tbump/error.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | 3 | 4 | class Error(Exception): 5 | def print_error(self) -> None: 6 | pass 7 | 8 | def __str__(self) -> str: 9 | pp = pprint.PrettyPrinter(indent=4) 10 | return pp.pformat(vars(self)) 11 | -------------------------------------------------------------------------------- /tests/project/after.py: -------------------------------------------------------------------------------- 1 | """ Fake hook used for testing. 2 | Just write a file named after-hook.stamp when called, 3 | so that test code can check if the hook ran 4 | """ 5 | 6 | from pathlib import Path 7 | 8 | 9 | def main() -> None: 10 | Path("after-hook.stamp").write_text("") 11 | 12 | 13 | if __name__ == "__main__": 14 | main() 15 | -------------------------------------------------------------------------------- /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 2 | _commit: v1.1.3-8-g2c7106c 3 | _src_path: git+https://git.sr.ht/~dmerej/copier-python 4 | author_email: dimitri@dmerej.info 5 | author_name: Dimitri Merejkowsky 6 | package_import_name: tbump 7 | project_description: Bump software releases 8 | project_name: tbump 9 | project_version: 6.9.0 10 | 11 | -------------------------------------------------------------------------------- /tests/project/before.py: -------------------------------------------------------------------------------- 1 | """ Fake hook used for testing. 2 | Just write a file named before-hook.stamp when called, 3 | so that test code can check if the hook ran 4 | """ 5 | 6 | import sys 7 | from pathlib import Path 8 | 9 | 10 | def main() -> None: 11 | current, new = sys.argv[1:] 12 | Path("before-hook.stamp").write_text(current + " -> " + new) 13 | 14 | 15 | if __name__ == "__main__": 16 | main() 17 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any 3 | 4 | from tbump import bump_files 5 | from tests.conftest import file_contains 6 | 7 | 8 | def test_bump_files_defaults_to_working_dir(test_repo: Path, monkeypatch: Any) -> None: 9 | monkeypatch.chdir(test_repo) 10 | bump_files("1.2.42") 11 | 12 | assert file_contains(test_repo / "package.json", '"version": "1.2.42"') 13 | 14 | 15 | def test_bump_files_with_repo_path(test_repo: Path) -> None: 16 | bump_files("1.2.42", test_repo) 17 | 18 | assert file_contains(test_repo / "package.json", '"version": "1.2.42"') 19 | -------------------------------------------------------------------------------- /.github/workflows/linters.yml: -------------------------------------------------------------------------------- 1 | name: linters 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: v* 7 | pull_request: 8 | 9 | jobs: 10 | run_linters: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | 16 | - uses: actions/checkout@v1 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v4.6.0 20 | with: 21 | python-version: "3.10" 22 | 23 | - name: Prepare project for development 24 | run: | 25 | python -m pip install poetry 26 | python -m poetry install 27 | 28 | - name: Run linters 29 | run: | 30 | python -m poetry run invoke lint 31 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | files = tbump/**/*.py 3 | 4 | allow_incomplete_defs = false 5 | allow_subclassing_any = false 6 | allow_untyped_calls = false 7 | allow_untyped_decorators = false 8 | allow_untyped_defs = false 9 | check_untyped_defs = true 10 | enable_error_code = ignore-without-code 11 | ignore_missing_imports = false 12 | no_implicit_optional = true 13 | pretty = true 14 | show_error_codes = true 15 | warn_redundant_casts = true 16 | warn_return_any = true 17 | warn_unused_configs = true 18 | warn_unused_ignores = true 19 | 20 | [mypy-docopt] 21 | ignore_missing_imports = true 22 | 23 | [mypy-pytest] 24 | ignore_missing_imports = true 25 | 26 | [mypy-schema] 27 | ignore_missing_imports = true 28 | 29 | [mypy-tomlkit.*] 30 | ignore_missing_imports = true 31 | 32 | -------------------------------------------------------------------------------- /tbump.toml: -------------------------------------------------------------------------------- 1 | github_url = "https://github.com/dmerejkowsky/tbump" 2 | 3 | 4 | [version] 5 | current = "6.11.0" 6 | regex = ''' 7 | (?P\d+) 8 | \. 9 | (?P\d+) 10 | \. 11 | (?P\d+) 12 | ''' 13 | 14 | 15 | [git] 16 | message_template = "Bump to {new_version}" 17 | tag_template = "v{new_version}" 18 | 19 | 20 | [[file]] 21 | src = "pyproject.toml" 22 | search = 'version = "{current_version}"' 23 | 24 | 25 | [[file]] 26 | src = "tbump/cli.py" 27 | 28 | [[before_commit]] 29 | name = "Run CI" 30 | cmd = "poetry run invoke lint && poetry run pytest" 31 | 32 | [[before_commit]] 33 | name = "Check Changelog" 34 | cmd = "grep -q {new_version} Changelog.rst" 35 | 36 | [[after_push]] 37 | name = "Publish to pypi" 38 | cmd = "poetry publish --build" 39 | -------------------------------------------------------------------------------- /tests/pyproject/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "foo" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Dimitri Merejkowsky "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.9" 9 | 10 | [tool.poetry.dev-dependencies] 11 | pytest = "^5.2" 12 | 13 | [build-system] 14 | requires = ["poetry-core>=1.0.0"] 15 | build-backend = "poetry.core.masonry.api" 16 | 17 | [tool.tbump.version] 18 | current = "0.1.0" 19 | 20 | regex = ''' 21 | (?P\d+) 22 | \. 23 | (?P\d+) 24 | \. 25 | (?P\d+) 26 | ''' 27 | 28 | [tool.tbump.git] 29 | message_template = "Bump to {new_version}" 30 | tag_template = "v{new_version}" 31 | 32 | [[tool.tbump.file]] 33 | src = "pyproject.toml" 34 | search = 'version = "{current_version}"' 35 | 36 | [[tool.tbump.file]] 37 | src = "foo/__init__.py" 38 | search = '__version__ = "{current_version}"' 39 | -------------------------------------------------------------------------------- /tests/project/tbump.toml: -------------------------------------------------------------------------------- 1 | [version] 2 | current = "1.2.41-alpha-1" 3 | regex = ''' 4 | (?P\d+) 5 | \. 6 | (?P\d+) 7 | \. 8 | (?P\d+) 9 | ( 10 | - 11 | (?Palpha|beta) 12 | - 13 | (?P\d+) 14 | )? 15 | ( 16 | \+ 17 | (?P[a-z0-9\.]+) 18 | )? 19 | ''' 20 | 21 | [git] 22 | message_template = "Bump to {new_version}" 23 | tag_template = "v{new_version}" 24 | 25 | [[file]] 26 | src = "package.json" 27 | search = '"version": "{current_version}"' 28 | 29 | [[file]] 30 | src = "VERSION" 31 | 32 | [[file]] 33 | src = "pub.js" 34 | version_template = "{major}.{minor}.{patch}" 35 | 36 | [[file]] 37 | src = "glob*.?" 38 | search = 'version_[a-z]+ = "{current_version}"' 39 | 40 | [[file]] 41 | src = "version_info.py" 42 | version_template = '({major}, {minor}, {patch}, "{channel}", {release})' 43 | search = "version_info = {current_version}" 44 | 45 | [[field]] 46 | name = "channel" 47 | default = "" 48 | 49 | [[field]] 50 | name = "release" 51 | default = 0 52 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | tags: v* 7 | pull_request: 8 | 9 | jobs: 10 | run_tests: 11 | 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | 19 | steps: 20 | 21 | - uses: actions/checkout@v1 22 | 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v4.6.0 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Prepare project for development 29 | run: | 30 | python -m pip install poetry 31 | python -m poetry install 32 | 33 | - name: Run tests 34 | run: | 35 | # tests run git commands, and they need 36 | # a proper git identity for that 37 | git config --global user.email "test@tbump-tests.com" 38 | git config --global user.name "Tasty Test" 39 | python -m poetry run pytest --cov . --cov-report term 40 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | from invoke import call, task 3 | 4 | SOURCES = "tbump" 5 | 6 | 7 | @task 8 | def black(c, check=False): 9 | print("Running black") 10 | cmd = f"black {SOURCES}" 11 | if check: 12 | cmd += " --check" 13 | c.run(cmd) 14 | 15 | 16 | @task 17 | def isort(c, check=False): 18 | print("Running isort") 19 | cmd = f"isort {SOURCES}" 20 | if check: 21 | cmd += " --check" 22 | c.run(cmd) 23 | 24 | 25 | @task 26 | def flake8(c): 27 | print("Running flake8") 28 | c.run(f"flake8 {SOURCES}") 29 | 30 | 31 | @task 32 | def mypy(c, machine_readable=False): 33 | print("Running mypy") 34 | cmd = "mypy" 35 | if machine_readable: 36 | cmd += " --no-pretty" 37 | else: 38 | cmd += " --color-output --pretty" 39 | c.run(cmd) 40 | 41 | 42 | @task 43 | def test(c): 44 | print("Running pytest") 45 | c.run("pytest", pty=True) 46 | 47 | 48 | @task( 49 | pre=[ 50 | call(black, check=True), 51 | call(isort, check=True), 52 | call(flake8), 53 | call(mypy), 54 | ] 55 | ) 56 | def lint(c): 57 | pass 58 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | 4 | [tool.poetry] 5 | name = "tbump" 6 | version = "6.11.0" 7 | description = "Bump software releases" 8 | readme = "README.rst" 9 | authors = ["Dimitri Merejkowsky "] 10 | license = "BSD-3-Clause" 11 | repository = "https://github.com/dmerejkowsky/tbump" 12 | 13 | [tool.poetry.urls] 14 | Changelog = "https://github.com/dmerejkowsky/tbump/blob/main/Changelog.rst" 15 | Issues = "https://github.com/dmerejkowsky/tbump/issues" 16 | 17 | [tool.poetry.dependencies] 18 | # Note: keep this in sync with .github/workflows/tests.yml 19 | python = "^3.9" 20 | 21 | cli-ui = ">=0.10.3" 22 | docopt-ng = "^0.9" 23 | packaging = "^24.0" 24 | schema = "^0.7.1" 25 | tomlkit = "^0.11" 26 | 27 | [tool.poetry.group.dev.dependencies] 28 | # Task runner 29 | invoke = "^2.2" 30 | 31 | # Tests 32 | pytest = "^8.3" 33 | pytest-cov = "^6.0" 34 | pytest-mock = "^3.14" 35 | 36 | # Linters 37 | black = "^24.3" 38 | flake8 = "7.1" 39 | flake8-bugbear = "^24.12" 40 | flake8-comprehensions = "^3.16" 41 | isort = "^5.13" 42 | mypy = "1.13" 43 | pep8-naming = "^0.14" 44 | 45 | [tool.poetry.scripts] 46 | tbump = "tbump.cli:main" 47 | 48 | [build-system] 49 | requires = ["poetry-core>=1.0.0"] 50 | build-backend = "poetry.core.masonry.api" 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Kontrol 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | from pathlib import Path 3 | 4 | import pytest 5 | import tomlkit 6 | 7 | from tbump.cli import run as run_tbump 8 | from tbump.init import TbumpTomlAlreadyExists 9 | 10 | 11 | def test_creates_tbump_toml_config(test_repo: Path) -> None: 12 | tbump_path = test_repo / "tbump.toml" 13 | tbump_path.unlink() 14 | current_version = "1.2.41-alpha1" 15 | 16 | run_tbump(["-C", str(test_repo), "init", current_version]) 17 | 18 | assert tbump_path.exists() 19 | config = tomlkit.loads(tbump_path.read_text()) 20 | assert config["version"]["current"] == "1.2.41-alpha1" # type: ignore[index] 21 | 22 | 23 | def test_append_to_pyproject(test_repo: Path) -> None: 24 | cfg_path = test_repo / "pyproject.toml" 25 | isort_config = textwrap.dedent( 26 | """ 27 | [tool.isort] 28 | profile = "black" 29 | """ 30 | ) 31 | cfg_path.write_text(isort_config) 32 | current_version = "1.2.41-alpha1" 33 | 34 | run_tbump(["-C", str(test_repo), "init", "--pyproject", current_version]) 35 | 36 | assert cfg_path.exists() 37 | config = tomlkit.loads(cfg_path.read_text()) 38 | assert config["tool"]["tbump"]["version"]["current"] == "1.2.41-alpha1" # type: ignore[index] 39 | assert config["tool"]["isort"]["profile"] == "black" # type: ignore[index] 40 | 41 | 42 | def test_abort_if_tbump_toml_exists( 43 | test_repo: Path, 44 | ) -> None: 45 | with pytest.raises(TbumpTomlAlreadyExists): 46 | run_tbump(["-C", str(test_repo), "init", "1.2.41-alpha1"]) 47 | 48 | 49 | def test_use_specified_path( 50 | test_repo: Path, 51 | ) -> None: 52 | # fmt: off 53 | run_tbump( 54 | [ 55 | "-C", str(test_repo), 56 | "--config", str(test_repo / "other.toml"), 57 | "init", 58 | "1.2.41-alpha1", 59 | ] 60 | ) 61 | # fmt: on 62 | assert (test_repo / "other.toml").exists() 63 | -------------------------------------------------------------------------------- /tests/test_git_bumper.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | import pytest 6 | 7 | from tbump.config import get_config_file 8 | from tbump.git import run_git, run_git_captured 9 | from tbump.git_bumper import ( 10 | GitBumper, 11 | GitBumperOptions, 12 | NotOnAnyBranch, 13 | NoTrackedBranch, 14 | ) 15 | 16 | 17 | @pytest.fixture 18 | def test_git_bumper(test_repo: Path, tag_message: Optional[str]) -> GitBumper: 19 | bump_options = GitBumperOptions(test_repo, tag_message) 20 | 21 | config_file = get_config_file(test_repo) 22 | config = config_file.get_config() 23 | git_bumper = GitBumper( 24 | bump_options, operations=["commit", "tag", "push_commit", "push_tag"] 25 | ) 26 | git_bumper.set_config(config) 27 | return git_bumper 28 | 29 | 30 | def test_git_bumper_happy_path( 31 | test_repo: Path, test_git_bumper: GitBumper, tag_message: Optional[str] 32 | ) -> None: 33 | new_version = "1.2.42" 34 | test_git_bumper.check_dirty() 35 | test_git_bumper.check_branch_state(new_version) 36 | # Make sure git add does not fail: 37 | # we could use file_bumper here instead 38 | (test_repo / "VERSION").write_text(new_version) 39 | commands = test_git_bumper.get_commands(new_version) 40 | for command in commands: 41 | command.run() 42 | _, out = run_git_captured(test_repo, "log", "--oneline") 43 | assert "Bump to %s" % new_version in out 44 | 45 | _, tag_out = run_git_captured(test_repo, "tag", "-n1") 46 | 47 | actual_tag = test_git_bumper.tag_template.format(new_version=new_version) 48 | if tag_message: 49 | pattern = r"{}\s+{}".format(actual_tag, tag_message) 50 | else: 51 | pattern = r"{}\s+{}".format(actual_tag, actual_tag) 52 | 53 | search = re.search(pattern, tag_out) 54 | assert search is not None 55 | 56 | 57 | def test_git_bumper_no_tracking_ref( 58 | test_repo: Path, test_git_bumper: GitBumper 59 | ) -> None: 60 | run_git(test_repo, "checkout", "-b", "devel") 61 | 62 | with pytest.raises(NoTrackedBranch): 63 | test_git_bumper.check_dirty() 64 | test_git_bumper.check_branch_state("1.2.42") 65 | 66 | 67 | def test_not_on_any_branch(test_repo: Path, test_git_bumper: GitBumper) -> None: 68 | run_git(test_repo, "commit", "--message", "test", "--allow-empty") 69 | run_git(test_repo, "checkout", "HEAD~1") 70 | 71 | with pytest.raises(NotOnAnyBranch): 72 | test_git_bumper.check_dirty() 73 | test_git_bumper.check_branch_state("1.2.42") 74 | -------------------------------------------------------------------------------- /tbump/git.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from pathlib import Path 3 | from typing import List, Optional, Tuple 4 | 5 | import cli_ui as ui 6 | 7 | from tbump.error import Error 8 | 9 | _GIT_COMMANDS = [] 10 | _RECORD = False 11 | 12 | 13 | class GitError(Error): 14 | pass 15 | 16 | 17 | class GitCommandError(GitError): 18 | def __init__( 19 | self, cmd: List[str], working_path: Path, output: Optional[str] = None 20 | ): 21 | super().__init__() 22 | self.cmd = cmd 23 | self.output = output 24 | self.working_path = working_path 25 | 26 | def print_error(self) -> None: 27 | cmd_str = " ".join(self.cmd) 28 | ui.error("Command", "`%s`" % cmd_str, "failed") 29 | 30 | 31 | def print_git_command(cmd: List[str]) -> None: 32 | ui.info(ui.darkgray, "$", ui.reset, "git", *cmd) 33 | 34 | 35 | def run_git(working_path: Path, *cmd: str, verbose: bool = False) -> None: 36 | """Run git `cmd` in given `working_path` 37 | 38 | Displays the command ran if `verbose` is True 39 | 40 | Raise GitCommandError if return code is non-zero. 41 | """ 42 | cmd_list = list(cmd) 43 | if _RECORD: 44 | _GIT_COMMANDS.append(cmd_list) 45 | if verbose: 46 | print_git_command(cmd_list) 47 | git_cmd = list(cmd) 48 | git_cmd.insert(0, "git") 49 | 50 | returncode = subprocess.call(git_cmd, cwd=working_path) 51 | if returncode != 0: 52 | raise GitCommandError(cmd=git_cmd, working_path=working_path) 53 | 54 | 55 | def run_git_captured( 56 | working_path: Path, *cmd: str, check: bool = True 57 | ) -> Tuple[int, str]: 58 | """Run git `cmd` in given `working_path`, capturing the output 59 | 60 | Return a tuple (returncode, output). 61 | 62 | Raise GitCommandError if return code is non-zero and check is True 63 | """ 64 | git_cmd = list(cmd) 65 | git_cmd.insert(0, "git") 66 | options = {} 67 | options["stdout"] = subprocess.PIPE 68 | options["stderr"] = subprocess.STDOUT 69 | 70 | ui.debug(ui.lightgray, working_path, "$", ui.reset, *git_cmd) 71 | process = subprocess.Popen(git_cmd, cwd=working_path, **options) # type: ignore[call-overload] 72 | output, _ = process.communicate() 73 | output = output.decode("utf-8") 74 | if output.endswith("\n"): 75 | output = output.strip("\n") 76 | returncode = process.returncode 77 | ui.debug(ui.lightgray, "[%i]" % returncode, ui.reset, output) 78 | if check and returncode != 0: 79 | raise GitCommandError(working_path=working_path, cmd=git_cmd, output=output) 80 | return returncode, output 81 | -------------------------------------------------------------------------------- /tbump/hooks.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from pathlib import Path 3 | from typing import List, Optional 4 | 5 | import cli_ui as ui 6 | 7 | from tbump.action import Action 8 | from tbump.error import Error 9 | 10 | 11 | class Hook(Action): 12 | def __init__(self, name: str, cmd: str): 13 | super().__init__() 14 | self.working_path: Optional[Path] = None 15 | self.name = name 16 | self.cmd = cmd 17 | 18 | def print_self(self) -> None: 19 | ui.info(ui.darkgray, "$", ui.reset, self.cmd) 20 | 21 | def do(self) -> None: 22 | self.run() 23 | 24 | def run(self) -> None: 25 | rc = subprocess.call(self.cmd, shell=True, cwd=self.working_path) 26 | if rc != 0: 27 | raise HookError(name=self.name, cmd=self.cmd, rc=rc) 28 | 29 | 30 | class BeforeCommitHook(Hook): 31 | pass 32 | 33 | 34 | class AfterPushHook(Hook): 35 | pass 36 | 37 | 38 | HOOKS_CLASSES = { 39 | "after_push": AfterPushHook, 40 | "before_commit": BeforeCommitHook, 41 | "before_push": BeforeCommitHook, # retro-compat name 42 | "hook": BeforeCommitHook, # retro-compat name 43 | } 44 | 45 | 46 | class HookError(Error): 47 | def __init__(self, *, name: str, cmd: str, rc: int): 48 | super().__init__() 49 | self.cmd = cmd 50 | self.rc = rc 51 | self.name = name 52 | 53 | def print_error(self) -> None: 54 | ui.error(ui.reset, "`%s`" % self.cmd, "exited with return code", self.rc) 55 | 56 | 57 | class HooksRunner: 58 | def __init__(self, working_path: Path, current_version: str, operations: List[str]): 59 | self.hooks: List[Hook] = [] 60 | self.working_path = working_path 61 | self.current_version = current_version 62 | self.operations = operations 63 | 64 | def add_hook(self, hook: Hook) -> None: 65 | hook.working_path = self.working_path 66 | self.hooks.append(hook) 67 | 68 | def get_before_hooks(self, new_version: str) -> List[Hook]: 69 | return self._get_hooks_for_new_version_by_type(new_version, "before_commit") 70 | 71 | def get_after_hooks(self, new_version: str) -> List[Hook]: 72 | if "push_tag" in self.operations or "push_commit" in self.operations: 73 | return self._get_hooks_for_new_version_by_type(new_version, "after_push") 74 | else: 75 | return [] 76 | 77 | def _get_hooks_for_new_version_by_type( 78 | self, new_version: str, type_: str 79 | ) -> List[Hook]: 80 | cls = HOOKS_CLASSES[type_] 81 | matching_hooks = [hook for hook in self.hooks if isinstance(hook, cls)] 82 | res = [] 83 | for hook in matching_hooks: 84 | hook.cmd = hook.cmd.format( 85 | current_version=self.current_version, new_version=new_version 86 | ) 87 | res.append(hook) 88 | return res 89 | -------------------------------------------------------------------------------- /tests/test_hooks.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | import pytest 5 | import tomlkit 6 | 7 | from tbump.cli import run as run_tbump 8 | from tbump.git import run_git 9 | from tbump.hooks import HookError 10 | 11 | 12 | def add_hook(test_repo: Path, name: str, cmd: str, after_push: bool = False) -> None: 13 | """Patch the configuration file so that we can also test hooks.""" 14 | cfg_path = test_repo / "tbump.toml" 15 | parsed = tomlkit.loads(cfg_path.read_text()) 16 | if after_push: 17 | key = "after_push" 18 | else: 19 | key = "before_commit" 20 | if key not in parsed: 21 | parsed[key] = tomlkit.aot() 22 | hook_config = tomlkit.table() 23 | hook_config.add("cmd", cmd) 24 | hook_config.add("name", name) 25 | parsed[key].append(hook_config) # type: ignore[arg-type, call-arg, union-attr] 26 | cfg_path.write_text(tomlkit.dumps(parsed)) 27 | run_git(test_repo, "add", ".") 28 | run_git(test_repo, "commit", "--message", "update hooks") 29 | 30 | 31 | def add_before_hook(test_repo: Path) -> None: 32 | """Patch config to add a working `before_commit` hook 33 | that runs tbump/test/data/before.py 34 | 35 | """ 36 | add_hook( 37 | test_repo, 38 | "fake yarn", 39 | sys.executable + " before.py {current_version} {new_version}", 40 | ) 41 | 42 | 43 | def add_after_hook(test_repo: Path) -> None: 44 | """Patch config to add a working `after_push` hook 45 | that runs tbump/test/data/after.py 46 | 47 | """ 48 | add_hook(test_repo, "after hook", sys.executable + " after.py", after_push=True) 49 | 50 | 51 | def add_crashing_hook(test_repo: Path) -> None: 52 | """Patch config to add a `before_commit` hook 53 | that runs a command that fails 54 | """ 55 | add_hook(test_repo, "crashing hook", sys.executable + " nosuchfile.py") 56 | 57 | 58 | def test_working_hook(test_repo: Path) -> None: 59 | """ 60 | Check that the configured hook runs and properly uses 61 | current and new version 62 | """ 63 | add_before_hook(test_repo) 64 | run_tbump(["-C", str(test_repo), "1.2.41-alpha-2", "--non-interactive"]) 65 | hook_stamp = test_repo / "before-hook.stamp" 66 | assert hook_stamp.read_text() == "1.2.41-alpha-1 -> 1.2.41-alpha-2" 67 | 68 | 69 | def test_hook_fails(test_repo: Path) -> None: 70 | """ 71 | Check that the proper exception is raised 72 | if the hooks exits with non-zero return code 73 | """ 74 | add_before_hook(test_repo) 75 | add_crashing_hook(test_repo) 76 | with pytest.raises(HookError): 77 | run_tbump(["-C", str(test_repo), "1.2.41-alpha-2", "--non-interactive"]) 78 | 79 | 80 | def test_hooks_after_push(test_repo: Path) -> None: 81 | """ 82 | Check that both `before_commit` and `after_push` 83 | hooks run when tbump is configured with both 84 | """ 85 | add_before_hook(test_repo) 86 | add_after_hook(test_repo) 87 | run_tbump(["-C", str(test_repo), "1.2.41-alpha-2", "--non-interactive"]) 88 | assert (test_repo / "before-hook.stamp").exists() 89 | assert (test_repo / "after-hook.stamp").exists() 90 | -------------------------------------------------------------------------------- /tbump/executor.py: -------------------------------------------------------------------------------- 1 | from typing import List, Sequence 2 | 3 | import cli_ui as ui 4 | 5 | from tbump.action import Action 6 | from tbump.config import ConfigFileUpdater 7 | from tbump.file_bumper import FileBumper 8 | from tbump.git_bumper import GitBumper 9 | from tbump.hooks import HooksRunner 10 | 11 | 12 | class ActionGroup: 13 | def __init__( 14 | self, 15 | dry_run_desc: str, 16 | desc: str, 17 | actions: Sequence[Action], 18 | *, 19 | should_enumerate: bool = False, 20 | ): 21 | self.should_enumerate = should_enumerate 22 | self.desc = desc 23 | self.dry_run_desc = dry_run_desc 24 | self.actions = actions 25 | 26 | def print_group(self, dry_run: bool = False) -> None: 27 | if not self.actions: 28 | return 29 | if dry_run and self.dry_run_desc: 30 | ui.info_2(self.dry_run_desc) 31 | if not dry_run and self.desc: 32 | ui.info_2(self.desc) 33 | for i, action in enumerate(self.actions): 34 | if self.should_enumerate: 35 | ui.info_count(i, len(self.actions), end="") 36 | action.print_self() 37 | 38 | def execute(self) -> None: 39 | for action in self.actions: 40 | action.do() 41 | 42 | 43 | class Executor: 44 | def __init__( 45 | self, 46 | new_version: str, 47 | file_bumper: FileBumper, 48 | config_file: ConfigFileUpdater, 49 | ): 50 | self.new_version = new_version 51 | self.work: List[ActionGroup] = [] 52 | 53 | update_config_group = ActionGroup( 54 | f"Would update current version in {config_file.relative_path}", 55 | "Updating current version", 56 | [config_file], 57 | should_enumerate=False, 58 | ) 59 | self.work.append(update_config_group) 60 | 61 | patches = ActionGroup( 62 | "Would patch these files", 63 | "Patching files", 64 | file_bumper.get_patches(new_version), 65 | ) 66 | self.work.append(patches) 67 | 68 | def add_git_and_hook_actions( 69 | self, new_version: str, git_bumper: GitBumper, hooks_runner: HooksRunner 70 | ) -> None: 71 | before_hooks = ActionGroup( 72 | "Would run these hooks before commit", 73 | "Running hooks before commit", 74 | hooks_runner.get_before_hooks(new_version), 75 | should_enumerate=True, 76 | ) 77 | self.work.append(before_hooks) 78 | 79 | git_commands = ActionGroup( 80 | "Would run these git commands", 81 | "Performing git operations", 82 | git_bumper.get_commands(new_version), 83 | ) 84 | self.work.append(git_commands) 85 | 86 | after_hooks = ActionGroup( 87 | "Would run these hooks after push", 88 | "Running hooks after push", 89 | hooks_runner.get_after_hooks(new_version), 90 | should_enumerate=True, 91 | ) 92 | self.work.append(after_hooks) 93 | 94 | def print_self(self, *, dry_run: bool = False) -> None: 95 | for action_group in self.work: 96 | action_group.print_group(dry_run=dry_run) 97 | 98 | def run(self) -> None: 99 | for action_group in self.work: 100 | action_group.print_group(dry_run=False) 101 | action_group.execute() 102 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from copy import copy 4 | from pathlib import Path 5 | from typing import Any, Iterator, List 6 | 7 | import pytest 8 | 9 | import tbump.git 10 | from tbump.git import run_git 11 | 12 | 13 | class GitRecorder: 14 | """Helper class to record git commands that are run""" 15 | 16 | def __init__(self) -> None: 17 | tbump.git._GIT_COMMANDS = [] 18 | 19 | def start(self) -> None: 20 | """Start recording messages""" 21 | tbump.git._RECORD = True 22 | 23 | def stop(self) -> None: 24 | """Stop recording messages""" 25 | tbump.git._RECORD = False 26 | 27 | def reset(self) -> None: 28 | """Reset the list""" 29 | tbump.git._GIT_COMMANDS = [] 30 | 31 | def commands(self) -> List[List[str]]: 32 | """Find a message in the list of recorded message 33 | 34 | :param pattern: regular expression pattern to use 35 | when looking for recorded message 36 | """ 37 | return copy(tbump.git._GIT_COMMANDS) 38 | 39 | 40 | @pytest.fixture 41 | def git_recorder() -> GitRecorder: 42 | res = GitRecorder() 43 | res.start() 44 | return res 45 | 46 | 47 | @pytest.fixture() 48 | def tmp_path(tmpdir: Any) -> Path: 49 | return Path(tmpdir) 50 | 51 | 52 | @pytest.fixture 53 | def test_project() -> Path: 54 | this_dir = Path(__file__).absolute().parent 55 | return this_dir / "project" 56 | 57 | 58 | @pytest.fixture 59 | def test_pyproject() -> Path: 60 | this_dir = Path(__file__).absolute().parent 61 | return this_dir / "pyproject" 62 | 63 | 64 | @pytest.fixture(autouse=True, scope="session") 65 | def restore_cwd() -> Iterator[None]: 66 | old_cwd = os.getcwd() 67 | yield 68 | os.chdir(old_cwd) 69 | 70 | 71 | def file_contains(path: Path, text: str) -> bool: 72 | for line in path.read_text().splitlines(): 73 | if text in line: 74 | return True 75 | return False 76 | 77 | 78 | def setup_repo(tmp_path: Path, test_project: Path) -> Path: 79 | src_path = tmp_path / "src" 80 | shutil.copytree(test_project, src_path) 81 | run_git(src_path, "init", "--initial-branch", "master") 82 | run_git(src_path, "add", ".") 83 | run_git(src_path, "commit", "--message", "initial commit") 84 | run_git( 85 | src_path, "tag", "--annotate", "--message", "v1.2.41-alpha-1", "v1.2.41-alpha-1" 86 | ) 87 | return src_path 88 | 89 | 90 | def setup_remote(tmp_path: Path) -> Path: 91 | git_path = tmp_path / "git" 92 | git_path.mkdir() 93 | remote_path = git_path / "repo.git" 94 | remote_path.mkdir() 95 | run_git(remote_path, "init", "--bare", "--initial-branch", "master") 96 | 97 | src_path = tmp_path / "src" 98 | run_git(src_path, "remote", "add", "origin", str(remote_path)) 99 | run_git(src_path, "push", "-u", "origin", "master") 100 | return src_path 101 | 102 | 103 | @pytest.fixture 104 | def test_repo(tmp_path: Path, test_project: Path) -> Path: 105 | res = setup_repo(tmp_path, test_project) 106 | setup_remote(tmp_path) 107 | return res 108 | 109 | 110 | @pytest.fixture 111 | def test_pyproject_repo(tmp_path: Path, test_pyproject: Path) -> Path: 112 | res = setup_repo(tmp_path, test_pyproject) 113 | setup_remote(tmp_path) 114 | return res 115 | 116 | 117 | @pytest.fixture(params=[None, "test tag message"]) 118 | def tag_message(request: Any) -> Any: 119 | return request.param 120 | -------------------------------------------------------------------------------- /tbump/init.py: -------------------------------------------------------------------------------- 1 | import textwrap 2 | from pathlib import Path 3 | from typing import List, Optional 4 | 5 | import cli_ui as ui 6 | 7 | from tbump.error import Error 8 | from tbump.git import run_git_captured 9 | 10 | 11 | class TbumpTomlAlreadyExists(Error): 12 | def __init__(self, cfg_path: Path): 13 | super().__init__() 14 | self.cfg_path = cfg_path 15 | 16 | def print_error(self) -> None: 17 | ui.error(self.cfg_path, "already exists") 18 | 19 | 20 | def find_files(working_path: Path, current_version: str) -> List[str]: 21 | ui.info_2("Looking for files matching", ui.bold, current_version) 22 | cmd = ["grep", "--fixed-strings", "--files-with-matches", current_version] 23 | _, out = run_git_captured(working_path, *cmd, check=True) 24 | res: List[str] = [] 25 | ui.info("Found following matching files") 26 | for file in out.splitlines(): 27 | ui.info(" * ", file) 28 | res.append(file) 29 | return res 30 | 31 | 32 | def init( 33 | working_path: Path, 34 | *, 35 | current_version: str, 36 | use_pyproject: bool = False, 37 | specified_config_path: Optional[Path] = None, 38 | ) -> None: 39 | """Interactively creates a new tbump.toml""" 40 | if use_pyproject: 41 | text = "[tool.tbump]\n" 42 | key_prefix = "tool.tbump." 43 | cfg_path = working_path / "pyproject.toml" 44 | else: 45 | text = "" 46 | key_prefix = "" 47 | if specified_config_path: 48 | cfg_path = specified_config_path 49 | else: 50 | cfg_path = working_path / "tbump.toml" 51 | if cfg_path.exists(): 52 | raise TbumpTomlAlreadyExists(cfg_path) 53 | ui.info_1("Generating tbump config file") 54 | text += textwrap.dedent( 55 | """\ 56 | # Uncomment this if your project is hosted on GitHub: 57 | # github_url = "https://github.com///" 58 | 59 | [@key_prefix@version] 60 | current = "@current_version@" 61 | 62 | # Example of a semver regexp. 63 | # Make sure this matches current_version before 64 | # using tbump 65 | regex = ''' 66 | (?P\\d+) 67 | \\. 68 | (?P\\d+) 69 | \\. 70 | (?P\\d+) 71 | ''' 72 | 73 | [@key_prefix@git] 74 | message_template = "Bump to {new_version}" 75 | tag_template = "v{new_version}" 76 | 77 | # For each file to patch, add a [[@key_prefix@file]] config 78 | # section containing the path of the file, relative to the 79 | # tbump.toml location. 80 | [[@key_prefix@file]] 81 | src = "..." 82 | 83 | # You can specify a list of commands to 84 | # run after the files have been patched 85 | # and before the git commit is made 86 | 87 | # [[@key_prefix@before_commit]] 88 | # name = "check changelog" 89 | # cmd = "grep -q {new_version} Changelog.rst" 90 | 91 | # Or run some commands after the git tag and the branch 92 | # have been pushed: 93 | # [[@key_prefix@after_push]] 94 | # name = "publish" 95 | # cmd = "./publish.sh" 96 | """ 97 | ) 98 | 99 | text = text.replace("@current_version@", current_version) 100 | text = text.replace("@key_prefix@", key_prefix) 101 | with cfg_path.open("a") as f: 102 | f.write(text) 103 | ui.info_2(ui.check, "Generated", cfg_path) 104 | -------------------------------------------------------------------------------- /tests/test_file_bumper.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from tbump.config import get_config_file 6 | from tbump.file_bumper import BadSubstitution, CurrentVersionNotFound, FileBumper, Patch 7 | from tests.conftest import file_contains 8 | 9 | 10 | def test_file_bumper_simple(test_repo: Path) -> None: 11 | bumper = _bumper_for(test_repo) 12 | assert bumper.working_path == test_repo 13 | 14 | patches = bumper.get_patches(new_version="1.2.41-alpha-2") 15 | for patch in patches: 16 | patch.apply() 17 | 18 | assert file_contains(test_repo / "package.json", '"version": "1.2.41-alpha-2"') 19 | assert file_contains(test_repo / "package.json", '"other-dep": "1.2.41-alpha-1"') 20 | assert file_contains(test_repo / "pub.js", "PUBLIC_VERSION = '1.2.41'") 21 | assert file_contains(test_repo / "glob-one.c", 'version_one = "1.2.41-alpha-2"') 22 | assert file_contains(test_repo / "glob-two.v", 'version_two = "1.2.41-alpha-2"') 23 | 24 | 25 | def test_patcher_preserve_endings(tmp_path: Path) -> None: 26 | foo_txt = tmp_path / "foo.txt" 27 | old_contents = b"line 1\r\nv=42\r\nline3\r\n" 28 | foo_txt.write_bytes(old_contents) 29 | patch = Patch(tmp_path, "foo.txt", 1, "v=42", "v=43") 30 | patch.apply() 31 | actual_contents = foo_txt.read_bytes() 32 | expected_contents = old_contents.replace(b"42", b"43") 33 | assert actual_contents == expected_contents 34 | 35 | 36 | def test_file_bumper_preserve_endings(test_repo: Path) -> None: 37 | bumper = _bumper_for(test_repo) 38 | package_json = test_repo / "package.json" 39 | 40 | # Make sure package.json contain CRLF line endings 41 | lines = package_json.read_text().splitlines(keepends=False) 42 | package_json.write_bytes(b"\r\n".join([x.encode() for x in lines])) 43 | 44 | patches = bumper.get_patches(new_version="1.2.41-alpha-2") 45 | for patch in patches: 46 | patch.apply() 47 | 48 | actual = package_json.read_bytes() 49 | assert b'version": "1.2.41-alpha-2",\r\n' in actual 50 | 51 | 52 | def test_looking_for_empty_groups(tmp_path: Path) -> None: 53 | tbump_path = tmp_path / "tbump.toml" 54 | tbump_path.write_text( 55 | r""" 56 | [version] 57 | current = "1.2" 58 | regex = ''' 59 | (?P\d+) 60 | \. 61 | (?P\d+) 62 | ( 63 | \. 64 | (?P\d+) 65 | )? 66 | ''' 67 | 68 | [git] 69 | message_template = "Bump to {new_version}" 70 | tag_template = "v{new_version}" 71 | 72 | [[file]] 73 | src = "foo" 74 | version_template = "{major}.{minor}.{patch}" 75 | 76 | """ 77 | ) 78 | foo_path = tmp_path / "foo" 79 | foo_path.write_text( 80 | """ 81 | version = "1.2" 82 | """ 83 | ) 84 | bumper = _bumper_for(tmp_path) 85 | with pytest.raises(BadSubstitution) as e: 86 | bumper.get_patches(new_version="1.3.1") 87 | assert e.value.src == "foo" 88 | assert e.value.groups == {"major": "1", "minor": "2", "patch": None} 89 | 90 | 91 | def test_current_version_not_found(tmp_path: Path) -> None: 92 | tbump_path = tmp_path / "tbump.toml" 93 | tbump_path.write_text( 94 | r""" 95 | [version] 96 | current = "1.2.3" 97 | regex = ".*" 98 | 99 | [git] 100 | message_template = "Bump to {new_version}" 101 | tag_template = "v{new_version}" 102 | 103 | [[file]] 104 | src = "version.txt" 105 | """ 106 | ) 107 | version_txt_path = tmp_path / "version.txt" 108 | version_txt_path.write_text("nope") 109 | bumper = _bumper_for(tmp_path) 110 | 111 | with pytest.raises(CurrentVersionNotFound) as e: 112 | bumper.get_patches(new_version="1.3.1") 113 | assert e.value.src == "version.txt" 114 | 115 | 116 | def test_replacing_with_empty_groups(tmp_path: Path) -> None: 117 | tbump_path = tmp_path / "tbump.toml" 118 | tbump_path.write_text( 119 | r""" 120 | [version] 121 | current = "1.2.3" 122 | regex = ''' 123 | (?P\d+) 124 | \. 125 | (?P\d+) 126 | ( 127 | \. 128 | (?P\d+) 129 | )? 130 | ''' 131 | 132 | [git] 133 | message_template = "Bump to {new_version}" 134 | tag_template = "v{new_version}" 135 | 136 | [[file]] 137 | src = "foo" 138 | version_template = "{major}.{minor}.{patch}" 139 | 140 | """ 141 | ) 142 | foo_path = tmp_path / "foo" 143 | foo_path.write_text( 144 | """ 145 | version = "1.2.3" 146 | """ 147 | ) 148 | 149 | bumper = _bumper_for(tmp_path) 150 | with pytest.raises(BadSubstitution) as e: 151 | bumper.get_patches(new_version="1.3") 152 | assert e.value.groups == {"major": "1", "minor": "3", "patch": None} 153 | 154 | 155 | def test_changing_same_file_twice(tmp_path: Path) -> None: 156 | tbump_path = tmp_path / "tbump.toml" 157 | tbump_path.write_text( 158 | r""" 159 | [version] 160 | current = "1.2.3" 161 | regex = ''' 162 | (?P\d+) 163 | \. 164 | (?P\d+) 165 | ( 166 | \. 167 | (?P\d+) 168 | )? 169 | ''' 170 | 171 | [git] 172 | message_template = "Bump to {new_version}" 173 | tag_template = "v{new_version}" 174 | 175 | [[file]] 176 | src = "foo.c" 177 | version_template = "{major}.{minor}" 178 | search = "PUBLIC_VERSION" 179 | 180 | [[file]] 181 | src = "foo.c" 182 | search = "FULL_VERSION" 183 | 184 | """ 185 | ) 186 | 187 | foo_c = tmp_path / "foo.c" 188 | foo_c.write_text( 189 | """ 190 | #define FULL_VERSION "1.2.3" 191 | #define PUBLIC_VERSION "1.2" 192 | """ 193 | ) 194 | bumper = _bumper_for(tmp_path) 195 | patches = bumper.get_patches(new_version="1.3.0") 196 | for patch in patches: 197 | patch.do() 198 | 199 | assert file_contains(tmp_path / foo_c, '#define FULL_VERSION "1.3.0"') 200 | assert file_contains(tmp_path / foo_c, '#define PUBLIC_VERSION "1.3"') 201 | 202 | 203 | def _bumper_for(working_path: Path) -> FileBumper: 204 | config_file = get_config_file(working_path) 205 | return FileBumper(working_path, config_file.get_config()) 206 | -------------------------------------------------------------------------------- /tbump/git_bumper.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from pathlib import Path 3 | from typing import List, Optional, Tuple 4 | 5 | import cli_ui as ui 6 | 7 | from tbump.action import Action 8 | from tbump.config import Config 9 | from tbump.git import GitError, print_git_command, run_git, run_git_captured 10 | 11 | 12 | class DirtyRepository(GitError): 13 | def __init__(self, *, git_status_output: str): 14 | super().__init__() 15 | self.git_status_output = git_status_output 16 | 17 | def print_error(self) -> None: 18 | ui.error("Repository is dirty") 19 | ui.info(self.git_status_output) 20 | 21 | 22 | class NotOnAnyBranch(GitError): 23 | def print_error(self) -> None: 24 | ui.error("Not on any branch") 25 | 26 | 27 | class NoTrackedBranch(GitError): 28 | def __init__(self, *, branch: str): 29 | super().__init__() 30 | self.branch = branch 31 | 32 | def print_error(self) -> None: 33 | ui.error( 34 | "Current branch (%s)" % self.branch, "does not track anything. Cannot push." 35 | ) 36 | 37 | 38 | class RefAlreadyExists(GitError): 39 | def __init__(self, *, ref: str): 40 | super().__init__() 41 | self.ref = ref 42 | 43 | def print_error(self) -> None: 44 | ui.error("git ref", self.ref, "already exists") 45 | 46 | 47 | class Command(Action): 48 | def __init__(self, repo_path: Path, cmd: List[str]): 49 | super().__init__() 50 | self.repo_path = repo_path 51 | self.cmd = list(cmd) 52 | self.verbose = True 53 | 54 | def print_self(self) -> None: 55 | print_git_command(self.cmd) 56 | 57 | def do(self) -> None: 58 | self.run() 59 | 60 | def run(self) -> None: 61 | return run_git(self.repo_path, *self.cmd, verbose=False) 62 | 63 | 64 | @dataclass 65 | class GitBumperOptions: 66 | working_path: Path 67 | tag_message: Optional[str] = None 68 | no_atomic: bool = False 69 | 70 | 71 | class GitBumper: 72 | def __init__(self, options: GitBumperOptions, operations: List[str]): 73 | self.repo_path = options.working_path 74 | self.atomic_push = True 75 | self.sign = False 76 | self.tag_message = options.tag_message 77 | self.tag_template = "" 78 | self.message_template = "" 79 | self.remote_name = "" 80 | self.remote_branch = "" 81 | self.operations = operations 82 | self.commands: List[Command] = [] 83 | 84 | def get_tag_name(self, new_version: str) -> str: 85 | return self.tag_template.format(new_version=new_version) 86 | 87 | def set_config(self, config: Config) -> None: 88 | self.tag_template = config.git_tag_template 89 | self.message_template = config.git_message_template 90 | # atomic_push is True by default, and must be explicitly 91 | # disabled in the configuration: 92 | if not config.atomic_push: 93 | self.atomic_push = False 94 | 95 | if config.sign: 96 | self.sign = True 97 | 98 | def run_git(self, *args: str, verbose: bool = False) -> None: 99 | return run_git(self.repo_path, *args, verbose=verbose) 100 | 101 | def run_git_captured(self, *args: str, check: bool = True) -> Tuple[int, str]: 102 | return run_git_captured(self.repo_path, *args, check=check) 103 | 104 | def check_dirty(self) -> None: 105 | if "commit" not in self.operations: 106 | return 107 | _, out = self.run_git_captured("status", "--porcelain") 108 | dirty = False 109 | for line in out.splitlines(): 110 | # Ignore untracked files 111 | if not line.startswith("??"): 112 | dirty = True 113 | if dirty: 114 | raise DirtyRepository(git_status_output=out) 115 | 116 | def get_current_branch(self) -> str: 117 | cmd = ("rev-parse", "--abbrev-ref", "HEAD") 118 | _, out = self.run_git_captured(*cmd) 119 | if out == "HEAD": 120 | raise NotOnAnyBranch() 121 | return out 122 | 123 | def get_tracking_ref(self) -> str: 124 | branch_name = self.get_current_branch() 125 | rc, out = self.run_git_captured( 126 | "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", check=False 127 | ) 128 | if rc != 0: 129 | raise NoTrackedBranch(branch=branch_name) 130 | return out 131 | 132 | def check_ref_does_not_exists(self, tag_name: str) -> None: 133 | rc, _ = self.run_git_captured("rev-parse", tag_name, check=False) 134 | if rc == 0: 135 | raise RefAlreadyExists(ref=tag_name) 136 | 137 | def check_branch_state(self, new_version: str) -> None: 138 | if "commit" not in self.operations: 139 | return 140 | if "tag" in self.operations: 141 | tag_name = self.get_tag_name(new_version) 142 | self.check_ref_does_not_exists(tag_name) 143 | 144 | if "push_commit" in self.operations: 145 | self.get_current_branch() 146 | tracking_ref = self.get_tracking_ref() 147 | self.remote_name, self.remote_branch = tracking_ref.split("/", maxsplit=1) 148 | 149 | def add_command(self, commands: List[Command], *cmd: str) -> None: 150 | command = Command(self.repo_path, list(cmd)) 151 | commands.append(command) 152 | 153 | def get_commands(self, new_version: str) -> List[Command]: 154 | res: List[Command] = [] 155 | if "commit" not in self.operations: 156 | return res 157 | self.add_command(res, "add", "--update") 158 | commit_message = self.message_template.format(new_version=new_version) 159 | self.add_command(res, "commit", "--message", commit_message) 160 | tag_name = self.get_tag_name(new_version) 161 | if "tag" in self.operations: 162 | if self.tag_message: 163 | tag_message = self.tag_message 164 | else: 165 | tag_message = tag_name 166 | 167 | if not self.sign: 168 | self.add_command( 169 | res, "tag", "--annotate", "--message", tag_message, tag_name 170 | ) 171 | else: 172 | self.add_command( 173 | res, 174 | "tag", 175 | "--sign", 176 | "--annotate", 177 | "--message", 178 | tag_message, 179 | tag_name, 180 | ) 181 | if "push_commit" in self.operations and "push_tag" in self.operations: 182 | if self.atomic_push: 183 | self.add_command( 184 | res, 185 | "push", 186 | "--atomic", 187 | self.remote_name, 188 | self.remote_branch, 189 | tag_name, 190 | ) 191 | else: 192 | # Need to do the op separately, otherwise tag will get pushed 193 | # even if branch fails 194 | self.add_command(res, "push", self.remote_name, self.remote_branch) 195 | self.add_command(res, "push", self.remote_name, tag_name) 196 | elif "push_commit" in self.operations: 197 | self.add_command(res, "push", self.remote_name, self.remote_branch) 198 | elif "push_tag" in self.operations: 199 | self.add_command(res, "push", self.remote_name, tag_name) 200 | # else do nothing 201 | return res 202 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | import re 2 | import textwrap 3 | from pathlib import Path 4 | 5 | import pytest 6 | import schema 7 | import tomlkit 8 | 9 | from tbump.config import ( 10 | Config, 11 | Field, 12 | File, 13 | InvalidConfig, 14 | TbumpTomlUpdater, 15 | from_parsed_config, 16 | get_config_file, 17 | validate_basic_schema, 18 | validate_config, 19 | ) 20 | from tbump.hooks import HOOKS_CLASSES, BeforeCommitHook 21 | 22 | 23 | def test_happy_parse(test_project: Path) -> None: 24 | config_file = get_config_file(test_project) 25 | config = config_file.get_config() 26 | foo_json = File(src="package.json", search='"version": "{current_version}"') 27 | version_txt = File(src="VERSION") 28 | pub_js = File(src="pub.js", version_template="{major}.{minor}.{patch}") 29 | glob = File(src="glob*.?", search='version_[a-z]+ = "{current_version}"') 30 | version_info = File( 31 | src="version_info.py", 32 | search="version_info = {current_version}", 33 | version_template='({major}, {minor}, {patch}, "{channel}", {release})', 34 | ) 35 | 36 | channel = Field( 37 | name="channel", 38 | default="", 39 | ) 40 | release = Field( 41 | name="release", 42 | default=0, 43 | ) 44 | 45 | expected_pattern = r""" (?P\d+) 46 | \. 47 | (?P\d+) 48 | \. 49 | (?P\d+) 50 | ( 51 | - 52 | (?Palpha|beta) 53 | - 54 | (?P\d+) 55 | )? 56 | ( 57 | \+ 58 | (?P[a-z0-9\.]+) 59 | )? 60 | """ 61 | 62 | assert config.version_regex.pattern == expected_pattern 63 | 64 | assert config.files == [foo_json, version_txt, pub_js, glob, version_info] 65 | assert config.fields == [channel, release] 66 | 67 | assert config.current_version == "1.2.41-alpha-1" 68 | 69 | 70 | def test_uses_pyproject_if_tbump_toml_is_missing( 71 | test_project: Path, tmp_path: Path 72 | ) -> None: 73 | 74 | expected_file = get_config_file(test_project) 75 | parsed_config = expected_file.get_parsed() 76 | tools_config = tomlkit.table() 77 | tools_config.add("tbump", parsed_config) 78 | 79 | pyproject_config = tomlkit.document() 80 | pyproject_config["tool"] = tools_config 81 | to_write = tomlkit.dumps(pyproject_config) 82 | 83 | pyproject_toml = tmp_path / "pyproject.toml" 84 | pyproject_toml.write_text(to_write) 85 | 86 | actual_file = get_config_file(tmp_path) 87 | assert actual_file.get_config() == expected_file.get_config() 88 | 89 | 90 | def test_complain_if_pyproject_does_not_contain_tbump_config(tmp_path: Path) -> None: 91 | pyproject_toml = tmp_path / "pyproject.toml" 92 | to_write = textwrap.dedent( 93 | r""" 94 | [tool.isort] 95 | profile = "black" 96 | """ 97 | ) 98 | pyproject_toml.write_text(to_write) 99 | 100 | with pytest.raises(InvalidConfig): 101 | get_config_file(tmp_path) 102 | 103 | 104 | def test_use_specified_path(tmp_path: Path, test_project: Path) -> None: 105 | other_path = tmp_path / "other.toml" 106 | test_toml = test_project / "tbump.toml" 107 | other_path.write_text(test_toml.read_text()) 108 | expected_file = get_config_file(tmp_path, specified_config_path=other_path) 109 | assert isinstance(expected_file, TbumpTomlUpdater) 110 | 111 | 112 | def test_raise_when_specified_path_does_not_exists(tmp_path: Path) -> None: 113 | with pytest.raises(InvalidConfig): 114 | get_config_file(tmp_path, specified_config_path=tmp_path / "no-such.toml") 115 | 116 | 117 | def test_validate_schema_in_pyproject_toml(tmp_path: Path) -> None: 118 | pyproject_toml = tmp_path / "pyproject.toml" 119 | to_write = textwrap.dedent( 120 | r""" 121 | [[tool.tbump.file]] 122 | search = '"version": "{current_version}"' 123 | src = "package.json" 124 | 125 | [tool.tbump.git] 126 | message_template = "Bump to {new_version}" 127 | tag_template = "v{new_version}" 128 | 129 | [tool.tbump.version] 130 | # Note: missing 'current' 131 | regex = ''' 132 | (?P\d+) 133 | \. 134 | (?P\d+) 135 | \. 136 | (?P\d+) 137 | ''' 138 | """ 139 | ) 140 | pyproject_toml.write_text(to_write) 141 | 142 | with pytest.raises(InvalidConfig) as e: 143 | get_config_file(tmp_path) 144 | assert "'current'" in str(e.value.parse_error) 145 | 146 | 147 | def assert_validation_error(config: Config, expected_message: str) -> None: 148 | try: 149 | validate_config(config) 150 | pytest.fail("should have raised schema error") 151 | except schema.SchemaError as error: 152 | assert expected_message in error.args[0] 153 | 154 | 155 | @pytest.fixture 156 | def test_config(test_project: Path) -> Config: 157 | config_file = get_config_file(test_project) 158 | return config_file.get_config() 159 | 160 | 161 | def test_invalid_commit_message(test_config: Config) -> None: 162 | test_config.git_message_template = "invalid message" 163 | assert_validation_error( 164 | test_config, "git.message_template should contain the string {new_version}" 165 | ) 166 | 167 | 168 | def test_invalid_hook_cmd(test_config: Config) -> None: 169 | invalid_cmd = "grep -q {version} Changelog.rst" 170 | invalid_hook = BeforeCommitHook(name="check changelog", cmd=invalid_cmd) 171 | test_config.hooks.append(invalid_hook) 172 | assert_validation_error( 173 | test_config, 174 | "hook cmd: 'grep -q {version} Changelog.rst' uses unknown placeholder: 'version'", 175 | ) 176 | 177 | 178 | def test_current_version_does_not_match_expected_regex(test_config: Config) -> None: 179 | test_config.version_regex = re.compile(r"(\d+)\.(\d+)\.(\d+)") 180 | test_config.current_version = "1.42a1" 181 | assert_validation_error( 182 | test_config, "Current version: 1.42a1 does not match version regex" 183 | ) 184 | 185 | 186 | def test_invalid_regex() -> None: 187 | contents = textwrap.dedent( 188 | r""" 189 | [version] 190 | current = '1.42a1' 191 | regex = '(unbalanced' 192 | 193 | [git] 194 | message_template = "Bump to {new_version}" 195 | tag_template = "v{new_version}" 196 | 197 | [[file]] 198 | src = "VERSION" 199 | """ 200 | ) 201 | data = tomlkit.loads(contents) 202 | with pytest.raises(schema.SchemaError) as e: 203 | validate_basic_schema(data.value) 204 | print(e) 205 | 206 | 207 | def test_invalid_custom_template(test_config: Config) -> None: 208 | first_file = test_config.files[0] 209 | first_file.src = "pub.js" 210 | first_file.version_template = "{major}.{minor}.{no_such_group}" 211 | assert_validation_error( 212 | test_config, 213 | "version template for 'pub.js' contains unknown group: 'no_such_group'", 214 | ) 215 | 216 | 217 | def test_parse_hooks() -> None: 218 | contents = textwrap.dedent( 219 | r""" 220 | [version] 221 | current = "1.2.3" 222 | regex = '(?P\d+)\.(?P\d+)\.(?P\d+)' 223 | 224 | [git] 225 | message_template = "Bump to {new_version}" 226 | tag_template = "v{new_version}" 227 | 228 | [[file]] 229 | src = "pub.js" 230 | 231 | [[before_commit]] 232 | name = "Check changelog" 233 | cmd = "grep -q {new_version} Changelog.md" 234 | 235 | [[after_push]] 236 | name = "After push" 237 | cmd = "cargo publish" 238 | """ 239 | ) 240 | parsed = tomlkit.loads(contents) 241 | config = from_parsed_config(parsed.value) 242 | first_hook = config.hooks[0] 243 | assert first_hook.name == "Check changelog" 244 | assert first_hook.cmd == "grep -q {new_version} Changelog.md" 245 | expected_class = HOOKS_CLASSES["before_commit"] 246 | assert isinstance(first_hook, expected_class) 247 | 248 | second_hook = config.hooks[1] 249 | expected_class = HOOKS_CLASSES["after_push"] 250 | assert isinstance(second_hook, expected_class) 251 | 252 | 253 | def test_retro_compat_hooks() -> None: 254 | contents = textwrap.dedent( 255 | r""" 256 | [version] 257 | current = "1.2.3" 258 | regex = '(?P\d+)\.(?P\d+)\.(?P\d+)' 259 | 260 | [git] 261 | message_template = "Bump to {new_version}" 262 | tag_template = "v{new_version}" 263 | 264 | [[file]] 265 | src = "pub.js" 266 | 267 | [[hook]] 268 | name = "very old name" 269 | cmd = "old command" 270 | 271 | [[before_push]] 272 | name = "deprecated name" 273 | cmd = "deprecated command" 274 | """ 275 | ) 276 | parsed = tomlkit.parse(contents) 277 | config = from_parsed_config(parsed.value) 278 | first_hook = config.hooks[0] 279 | assert isinstance(first_hook, BeforeCommitHook) 280 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/tbump.svg 2 | :target: https://pypi.org/project/tbump/ 3 | 4 | .. image:: https://img.shields.io/github/license/dmerejkowsky/tbump.svg 5 | :target: https://github.com/dmerejkowsky/tbump/blob/main/LICENSE 6 | 7 | .. image:: https://github.com/dmerejkowsky/tbump/workflows/tests/badge.svg 8 | :target: https://github.com/dmerejkowsky/tbump/actions 9 | 10 | .. image:: https://github.com/dmerejkowsky/tbump/workflows/linters/badge.svg 11 | :target: https://github.com/dmerejkowsky/tbump/actions 12 | 13 | .. image:: https://img.shields.io/badge/code%20style-black-black.svg 14 | :target: https://github.com/psf/black 15 | 16 | .. image:: https://img.shields.io/badge/mypy-checked-blue.svg 17 | :target: https://mypy-lang.org 18 | 19 | 20 | tbump: bump software releases 21 | ============================= 22 | 23 | tbump helps you bump the version of your project easily. 24 | 25 | Note 26 | ---- 27 | 28 | This project was originally hosted on the `TankerHQ 29 | `_ organization, which was my employer from 2016 30 | to 2021. They kindly agreed to give me back ownership of this project. Thanks! 31 | 32 | Installation 33 | ------------ 34 | 35 | The recommended way to install ``tbump`` is to use `pipx `_ 36 | 37 | * Make sure to have Python **3.8** or later installed. 38 | * Install ``pipx`` 39 | * Run ``pipx install tbump``. 40 | 41 | ``tbump`` is also available on ``pypi`` and can be installed with ``pip`` if you know what you are doing. 42 | 43 | Screenshot 44 | ----------- 45 | 46 | Here's what a typical usage of ``tbump`` looks like: 47 | 48 | .. code-block:: console 49 | 50 | $ tbump 5.0.5 51 | :: Bumping from 5.0.4 to 5.0.5 52 | => Would patch these files 53 | - setup.py:14 version="5.0.4", 54 | + setup.py:14 version="5.0.5", 55 | - tbump.toml:2 current = "5.0.4" 56 | + tbump.toml:2 current = "5.0.5" 57 | => Would run these hooks before commit 58 | * (1/2) $ ./test.sh 59 | * (2/2) $ grep -q -F 5.0.5 Changelog.rst 60 | => Would run these git commands 61 | * git add --update 62 | * git commit --message Bump to 5.0.5 63 | * git tag --annotate --message v5.0.5 v5.0.5 64 | * git push origin master 65 | * git push origin v5.0.5 66 | => Would run these hooks after push 67 | * (1/1) $ ./publish.sh 68 | :: Looking good? (y/N) 69 | y 70 | => Patching files 71 | ... 72 | => Running hooks before commit 73 | ... 74 | => Making bump commit and push matching tags 75 | ... 76 | => Running hooks after push 77 | ... 78 | Done ✓ 79 | 80 | 81 | 82 | Usage 83 | ------ 84 | 85 | First, run ``tbump init ``, where ``current_version`` 86 | is the current version of your program. This will create a 87 | ``tbump.toml`` file looking like this: 88 | 89 | .. code-block:: ini 90 | 91 | [version] 92 | current = "1.2.41" 93 | regex = ''' 94 | (?P\d+) 95 | \. 96 | (?P\d+) 97 | \. 98 | (?P\d+) 99 | ''' 100 | 101 | [git] 102 | message_template = "Bump to {new_version}" 103 | tag_template = "v{new_version}" 104 | 105 | [[file]] 106 | src = "setup.py" 107 | 108 | 109 | .. note:: 110 | 111 | * The file uses `toml syntax `_. 112 | * Strings should be templated using curly brackets, to be used with 113 | Python's built-in ``.format()`` method. 114 | * Paths may contain unix-style `globs 115 | `_, e.g. ``src = 116 | "a/**/script.?s"`` matches both ``a/b/script.js`` and 117 | ``a/b/c/script.ts``. 118 | * The version regular expression will be used in `verbose mode 119 | `_ and can 120 | contain named groups (see below). 121 | * tbump will also look for a ``[tool.tbump]`` section in the 122 | `pyproject.toml` file if its exists. You can use ``tbump init`` with 123 | the ``--pyproject`` option to append the configuration in this file 124 | instead of creating a new file. 125 | 126 | 127 | Then run: 128 | 129 | .. code-block:: console 130 | 131 | $ tbump 1.2.42 132 | 133 | ``tbump`` will: 134 | 135 | * Replace the string ``1.2.41`` by ``1.2.42`` in every file listed in the 136 | configuration 137 | 138 | * Make a commit based on the ``message_template``. 139 | 140 | * Make an **annotated** tag based on the ``tag_template`` 141 | 142 | * Push the current branch and the tag. 143 | 144 | Note that by default, ``tbump`` will display all the changes and stop to ask if they are correct before performing any action, allowing you to abort and re-try the bump if something is not right. 145 | You can use ``--non-interactive`` to disable this behavior. 146 | 147 | If you only want to bump the files without performing any git actions or running the hook commands, use the ``--only-patch`` option. 148 | 149 | The current version of the project can be found using the command: 150 | 151 | .. code-block:: console 152 | 153 | $ tbump current-version 154 | 155 | Advanced configuration 156 | ---------------------- 157 | 158 | Command-line options 159 | ++++++++++++++++++++ 160 | 161 | See: 162 | 163 | .. code-block:: console 164 | 165 | tbump --help 166 | 167 | 168 | Restricting the lines that are replaced 169 | +++++++++++++++++++++++++++++++++++++++ 170 | 171 | 172 | Sometimes you want to make sure only the line matching a given pattern is replaced. For instance, with the following ``package.json``: 173 | 174 | .. code-block:: js 175 | 176 | /* in package.json */ 177 | { 178 | "name": "foo", 179 | "version": "0.42", 180 | "dependencies": { 181 | "some-dep": "0.42", 182 | "other-dep": "1.3", 183 | } 184 | } 185 | 186 | you'll want to make sure that when you bump from ``0.42`` to ``0.43``, that the line containing ``some-dep`` does not change. 187 | 188 | In this case, you can set a ``search`` option in the ``file`` section: 189 | 190 | .. code-block:: ini 191 | 192 | # In tbump.toml 193 | 194 | [[file]] 195 | src = "package.json" 196 | search = '"version": "{current_version}"' 197 | 198 | Note that the search string is actually a full regular expression, except for the ``{current_version}`` marker which is substituted as plain text. 199 | 200 | 201 | Using a custom version template 202 | +++++++++++++++++++++++++++++++ 203 | 204 | If you are using a version schema like ``1.2.3-alpha-4``, you may want to expose a variable that only contains the "public" part of the version string. (``1.2.3`` in this case). 205 | 206 | To do so, add a ``version_template`` option in the ``file`` section. The names used in the format string should match the group names in the regular expression. 207 | 208 | 209 | .. code-block:: js 210 | 211 | /* in version.js */ 212 | 213 | export FULL_VERSION = '1.2.3-alpha-4'; 214 | export PUBLIC_VERSION = '1.2.3'; 215 | 216 | .. code-block:: ini 217 | 218 | 219 | [[file]] 220 | src = "version.js" 221 | version_template = "{major}.{minor}.{patch}" 222 | search = "export PUBLIC_VERSION = '{current_version}'" 223 | 224 | [[file]] 225 | src = "version.js" 226 | search = "export FULL_VERSION = '{current_version}'" 227 | 228 | 229 | Running commands before commit 230 | ++++++++++++++++++++++++++++++ 231 | 232 | You can specify a list of hooks to be run after the file have changed, but before the commit is made and pushed. 233 | 234 | This is useful if some of the files under version control are generated through an external program. 235 | 236 | Here's an example: 237 | 238 | 239 | .. code-block:: ini 240 | 241 | [[before_commit]] 242 | name = "Check Changelog" 243 | cmd = "grep -q -F {new_version} Changelog.rst" 244 | 245 | 246 | The name is mandatory. The command will be executed via the shell, after the ``{new_version}`` placeholder is replaced with the new version. 247 | 248 | Any hook that fails will interrupt the bump. You may want to run ``git reset --hard`` before trying again to undo the changes made in the files. 249 | 250 | Running commands after push 251 | +++++++++++++++++++++++++++ 252 | 253 | You can specify a list of hooks to be run right after the tag has been pushed, using an `[[after_push]]` section. 254 | 255 | This is useful if you need the command to run on a clean repository, without un-committed changes, for instance to publish ``rust`` packages: 256 | 257 | .. code-block:: ini 258 | 259 | [[after_push]] 260 | name = "Publish to crates.io" 261 | cmd = "cargo publish" 262 | 263 | 264 | Setting default values for version fields 265 | +++++++++++++++++++++++++++++++++++++++++ 266 | 267 | 268 | (Added in 6.6.0) 269 | 270 | If you have a ``version_template`` that includes fields that don't always have a match 271 | (e.g. prerelease info), 272 | you can set a default value to use instead of ``None``, 273 | which would raise an error. 274 | 275 | For example: 276 | 277 | .. code-block:: ini 278 | 279 | [version] 280 | current = "1.2.3" 281 | regex = """ 282 | (?P\d+) 283 | \. 284 | (?P\d+) 285 | \. 286 | (?P\d+) 287 | (\- 288 | (?P.+) 289 | )? 290 | """ 291 | 292 | [[file]] 293 | src = "version.py" 294 | version_template = '({major}, {minor}, {patch}, "{extra}")' 295 | search = "version_info = {current_version}" 296 | 297 | [[field]] 298 | # the name of the field 299 | name = "extra" 300 | # the default value to use, if there is no match 301 | default = "" 302 | 303 | 304 | Working with git providers that don't support --atomic 305 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 306 | 307 | If the push destination does not support ``--atomic``, 308 | add ``atomic_push=false`` to the config file, 309 | under the ``[git]`` section: 310 | 311 | ..code-block:: ini 312 | 313 | [git] 314 | atomic_push = false 315 | 316 | -------------------------------------------------------------------------------- /tbump/file_bumper.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import re 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | from typing import Dict, List, Optional, Pattern 6 | 7 | import cli_ui as ui 8 | 9 | from tbump.action import Action 10 | from tbump.config import Config, File, get_config_file 11 | from tbump.error import Error 12 | 13 | 14 | @dataclass 15 | class ChangeRequest: 16 | src: str 17 | old_string: str 18 | new_string: str 19 | search: Optional[str] = None 20 | 21 | 22 | class Patch(Action): 23 | def __init__( 24 | self, working_path: Path, src: str, lineno: int, old_line: str, new_line: str 25 | ): 26 | super().__init__() 27 | self.working_path = working_path 28 | self.src = src 29 | self.lineno = lineno 30 | self.old_line = old_line 31 | self.new_line = new_line 32 | 33 | def print_self(self) -> None: 34 | from tbump.cli import print_diff 35 | 36 | print_diff( 37 | self.src, self.lineno + 1, self.old_line.strip(), self.new_line.strip() 38 | ) 39 | 40 | def do(self) -> None: 41 | self.apply() 42 | 43 | @staticmethod 44 | def get_ending(line: bytes) -> bytes: 45 | if line.endswith(b"\r\n"): 46 | return b"\r\n" 47 | else: 48 | return b"\n" 49 | 50 | def apply(self) -> None: 51 | file_path = self.working_path / self.src 52 | contents = file_path.read_bytes() 53 | lines = contents.splitlines(keepends=True) 54 | old_line = lines[self.lineno] 55 | lines[self.lineno] = self.new_line.encode() + Patch.get_ending(old_line) 56 | text = b"".join(lines) 57 | file_path.write_bytes(text) 58 | 59 | 60 | class BadSubstitution(Error): 61 | def __init__( 62 | self, 63 | *, 64 | src: str, 65 | verb: str, 66 | groups: Dict[str, str], 67 | template: str, 68 | version: str 69 | ): 70 | super().__init__() 71 | self.src = src 72 | self.verb = verb 73 | self.groups = groups 74 | self.template = template 75 | self.version = version 76 | 77 | def print_error(self) -> None: 78 | message = [ 79 | " ", 80 | self.src + ":", 81 | " refusing to ", 82 | self.verb, 83 | " version containing 'None'\n", 84 | ] 85 | message += [ 86 | "More info:\n", 87 | " * version groups: ", 88 | repr(self.groups), 89 | "\n" " * template: ", 90 | self.template, 91 | "\n", 92 | " * version: ", 93 | self.version, 94 | "\n", 95 | ] 96 | ui.error(*message, end="", sep="") 97 | 98 | 99 | class InvalidVersion(Error): 100 | def __init__(self, *, version: str, regex: Pattern[str]): 101 | super().__init__() 102 | self.version = version 103 | self.regex = regex 104 | 105 | def print_error(self) -> None: 106 | ui.error("Could not parse", self.version, "as a valid version string") 107 | 108 | 109 | class SourceFileNotFound(Error): 110 | def __init__(self, *, src: str): 111 | super().__init__() 112 | self.src = src 113 | 114 | def print_error(self) -> None: 115 | ui.error("the file", self.src, "does not exist") 116 | 117 | 118 | class CurrentVersionNotFound(Error): 119 | def __init__(self, *, src: str, current_version_string: str): 120 | super().__init__() 121 | self.src = src 122 | self.current_version_string = current_version_string 123 | 124 | # TODO: raise just once for all errors 125 | def print_error(self) -> None: 126 | ui.error( 127 | "Current version string: (%s)" % self.current_version_string, 128 | "not found in", 129 | self.src, 130 | ) 131 | 132 | 133 | def should_replace(line: str, old_string: str, search: Optional[str] = None) -> bool: 134 | if not search: 135 | return old_string in line 136 | else: 137 | return (old_string in line) and (re.search(search, line) is not None) 138 | 139 | 140 | def on_version_containing_none( 141 | src: str, verb: str, version: str, *, groups: Dict[str, str], template: str 142 | ) -> None: 143 | raise BadSubstitution( 144 | src=src, verb=verb, version=version, groups=groups, template=template 145 | ) 146 | 147 | 148 | class FileBumper: 149 | def __init__(self, working_path: Path, config: Config): 150 | self.working_path = working_path 151 | self.files = config.files 152 | self.fields = config.fields 153 | self.version_regex = config.version_regex 154 | self.current_version = config.current_version 155 | 156 | self.current_groups = self.parse_version(self.current_version) 157 | self.new_version = "" 158 | self.new_groups: Dict[str, str] = {} 159 | 160 | def parse_version(self, version: str) -> Dict[str, str]: 161 | assert self.version_regex 162 | regex_match = self.version_regex.fullmatch(version) 163 | if regex_match is None: 164 | raise InvalidVersion(version=version, regex=self.version_regex) 165 | groups = regex_match.groupdict() 166 | 167 | # apply default fields from config 168 | for field in self.fields: 169 | if groups.get(field.name) is None: 170 | groups[field.name] = str(field.default) 171 | return groups 172 | 173 | def check_files_exist(self) -> None: 174 | assert self.files 175 | for file in self.files: 176 | expected_path = self.working_path / file.src 177 | files_found = glob.glob(str(expected_path), recursive=True) 178 | if not files_found: 179 | raise SourceFileNotFound(src=file.src) 180 | 181 | def get_patches(self, new_version: str) -> List[Patch]: 182 | self.new_version = new_version 183 | self.new_groups = self.parse_version(self.new_version) 184 | change_requests = self.compute_change_requests() 185 | patches = [] 186 | for change_request in change_requests: 187 | patches_for_request = self.compute_patches_for_change_request( 188 | change_request 189 | ) 190 | patches.extend(patches_for_request) 191 | return patches 192 | 193 | def compute_patches_for_change_request( 194 | self, change_request: ChangeRequest 195 | ) -> List[Patch]: 196 | old_string = change_request.old_string 197 | new_string = change_request.new_string 198 | search = change_request.search 199 | patches = [] 200 | 201 | file_path_glob = self.working_path / change_request.src 202 | for file_path_str in glob.glob(str(file_path_glob), recursive=True): 203 | file_path = Path(file_path_str) 204 | expanded_src = file_path.relative_to(self.working_path) 205 | old_lines = file_path.read_text().splitlines(keepends=False) 206 | 207 | for i, old_line in enumerate(old_lines): 208 | if should_replace(old_line, old_string, search): 209 | new_line = old_line.replace(old_string, new_string) 210 | patch = Patch( 211 | self.working_path, str(expanded_src), i, old_line, new_line 212 | ) 213 | patches.append(patch) 214 | if not patches: 215 | raise CurrentVersionNotFound( 216 | src=change_request.src, current_version_string=old_string 217 | ) 218 | return patches 219 | 220 | def compute_change_requests(self) -> List[ChangeRequest]: 221 | # When bumping files in a project, we need to bump: 222 | # * every file listed in the config file 223 | # * and the `current_version` value in tbump's config file 224 | change_requests = [] 225 | for file in self.files: 226 | change_request = self.compute_change_request_for_file(file) 227 | if change_request.old_string == change_request.new_string: 228 | continue 229 | change_requests.append(change_request) 230 | return change_requests 231 | 232 | def compute_change_request_for_file(self, file: File) -> ChangeRequest: 233 | if file.version_template: 234 | current_version = file.version_template.format(**self.current_groups) 235 | if "None" in current_version: 236 | on_version_containing_none( 237 | file.src, 238 | "look for", 239 | current_version, 240 | groups=self.current_groups, 241 | template=file.version_template, 242 | ) 243 | new_version = file.version_template.format(**self.new_groups) 244 | if "None" in new_version: 245 | on_version_containing_none( 246 | file.src, 247 | "replace by", 248 | new_version, 249 | groups=self.new_groups, 250 | template=file.version_template, 251 | ) 252 | else: 253 | current_version = self.current_version 254 | new_version = self.new_version 255 | 256 | to_search = None 257 | if file.search: 258 | to_search = file.search.format(current_version=re.escape(current_version)) 259 | 260 | return ChangeRequest(file.src, current_version, new_version, search=to_search) 261 | 262 | 263 | def bump_files(new_version: str, repo_path: Optional[Path] = None) -> None: 264 | repo_path = repo_path or Path(".") 265 | config_file = get_config_file(repo_path) 266 | bumper = FileBumper(repo_path, config_file.get_config()) 267 | bumper.check_files_exist() 268 | patches = bumper.get_patches(new_version=new_version) 269 | n = len(patches) 270 | for i, patch in enumerate(patches): 271 | ui.info_count(i, n, patch.src) 272 | patch.print_self() 273 | patch.apply() 274 | -------------------------------------------------------------------------------- /Changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 6.11.0 5 | ------ 6 | 7 | * Atomic pushes, introduced in version 6.5, are not supported 8 | everywhere. Starting with this release, you can now set 9 | ``atomic_push=false`` in the config file to use the old behavior 10 | (pushing the branch and the tag separately). Initial patch by @mlongtin0 11 | 12 | 13 | 6.10.0 (2023-05-21) 14 | ------------------ 15 | 16 | Bug fixes: 17 | ++++++++++ 18 | 19 | * Fix #156, where the ``pyproject.toml`` file could not be parsed - patch by @vipcxj 20 | * Fix #158 : also display changes when setting the current version in 21 | the ``pyproject.toml`` or ``tbump.toml`` files 22 | 23 | Other 24 | +++++ 25 | 26 | * Bump some dependencies 27 | * Add ``--tag-message`` command line option to specify a custom tag message 28 | (by default it's the same as the tag name) - Patch by Michael Boulton 29 | * Add support for Python 3.11 30 | 31 | 6.9.0 (2022-03-27) 32 | ------------------ 33 | 34 | * Add ``tbump current-version`` command, to print the current version. 35 | Path by @blink1073 36 | 37 | 6.8.0 (2022-03-27) 38 | ------------------ 39 | 40 | * Allow usage of ``python -m tbump`` in addition to just ``tbump`` 41 | 42 | 6.7.0 (2021-12-22) 43 | ------------------ 44 | 45 | * Drop support for Python 3.6 46 | * Drop dependency on ``attr`` 47 | 48 | 6.6.1 (2021-12-17) 49 | ------------------ 50 | 51 | * Relax dependency on `cli-ui` 52 | * Use a better example in the README (patch by @umonoca) 53 | 54 | 6.6.0 (2021-11-11) 55 | ------------------ 56 | 57 | Add support for other config paths 58 | ++++++++++++++++++++++++++++++++++ 59 | 60 | Added ``-c, --config`` to ``tbump`` command line, allowing using 61 | a different config file than `tbump.toml` (based on early work by 62 | @achary) 63 | 64 | Add support default values for versions fields 65 | ++++++++++++++++++++++++++++++++++++++++++++++ 66 | 67 | Added new ``[[field]]`` option for setting default values for version fields 68 | with no match in the version regex (e.g. prerelease fields), 69 | avoiding errors when these fields are present in a version_template. 70 | Patch by @minrk. 71 | 72 | For example: 73 | 74 | .. code-block:: toml 75 | 76 | [[field]] 77 | name = "prerelease" 78 | default = "" 79 | 80 | Other 81 | +++++ 82 | 83 | * Relax dependency on `attrs` - we used to have ``attrs <20, >=19``, now we have ``attrs >= 20``. 84 | 85 | 6.5.0 (2021-10-16) 86 | ------------------ 87 | 88 | Instead of pushing twice, which spawns two workflows, ``tbump`` now runs 89 | ``git push --atomic ``. Patch by @InCogNiTo124. 90 | 91 | 6.4.1 (2021-10-05) 92 | ------------------- 93 | 94 | Add support for Python 3.10 95 | 96 | 6.4.0 (2021-09-14) 97 | ------------------- 98 | 99 | Breaking change 100 | +++++++++++++++ 101 | 102 | If you are using `tbump` with a `pyproject.toml` to bump a project using `poetry`, 103 | you may have found that the `version` key in `[tool.poetry]` was implicitly bumped. 104 | 105 | This was considered to be a bug, which means you now have to tell `tbump` about `poetry` explicitly: 106 | 107 | .. code-block:: toml 108 | 109 | # new 110 | [[tool.tbump.file]] 111 | src = "pyproject.toml" 112 | search = 'version = "{current_version}"' 113 | 114 | Bug fixes 115 | +++++++++ 116 | 117 | * Fix #103: Invalid config: Key 'file' error using pyproject.toml, 118 | caused by an update in `tomlkit` 119 | * Fix #93: Do not patch version in `[tool.poetry]` implicitly 120 | 121 | Misc 122 | ++++ 123 | 124 | * Default development branch is now called `main`. 125 | * Make all `tbump` imports consistent 126 | * Fix compatibly with Python 3.10 127 | 128 | 6.3.2 (2021-04-19) 129 | ------------------ 130 | 131 | * Move out of the TankerHQ organization 132 | * Fix bug in ``tbump init --pyproject`` 133 | * Allow usage of newer ``tomlkit`` versions 134 | 135 | 6.3.1 (2021-02-05) 136 | ------------------ 137 | 138 | * Add a ``--no-tag-push`` option to create the tag but not push it 139 | 140 | 6.3.0 (2021-02-05) 141 | ------------------ 142 | 143 | More flexible workflow 144 | +++++++++++++++++++++++ 145 | 146 | * Add a ``--no-push`` option to create the commit and the tag, but not push them 147 | * Add a ``--no-tag`` option to skip creating the tag 148 | 149 | Note that if you want to create a commit and run the hooks but nothing else, you 150 | must use ``tbump --no-tag --no-push ``. 151 | 152 | If you only want to patch the files use ``tbump --only-patch``. 153 | 154 | See `#65 `_ for more details 155 | 156 | pyproject.toml support 157 | ++++++++++++++++++++++ 158 | 159 | Idea and initial implementation by @pmav99 160 | 161 | * If no ``tbump.toml`` file is present, but a ``pyproject.toml`` file 162 | containing a ``tool.tbump`` section exists, read the configuration from 163 | there. 164 | 165 | * ``tbump init``: add a ``--pyproject`` option to append configuration into 166 | an existing ``pyproject.toml`` instead of generating the ``tbump.toml`` file 167 | 168 | Bug fixes 169 | +++++++++ 170 | 171 | * Fix invalid syntax in generated config file (`#80 `_). Patch by `@snadorp`. 172 | 173 | v6.2.0 (2020-11-24) 174 | ------------------- 175 | 176 | * Drop dependency on ``Path Pie`` 177 | * Drop support for Python 3.5, add support for Python 3.9 178 | 179 | v6.1.1 (2020-07-23) 180 | ------------------- 181 | 182 | * Mark this project as typed 183 | 184 | v6.1.0 (2020-06-15) 185 | ------------------- 186 | 187 | * If ``github_url`` is found in the config file, display 188 | a link suggesting to create a release on GitHub after 189 | the tag is pushed 190 | 191 | v6.0.7 (2020-01-28) 192 | ------------------- 193 | 194 | * Relax constraint on `path` version 195 | 196 | v6.0.6 (2020-01-28) 197 | ------------------- 198 | 199 | * Switch to `poetry `_ for dependencies management and packaging. 200 | 201 | v6.0.5 (2020-01-28) 202 | ------------------- 203 | 204 | * Fix incorrect `python_requires` metadata 205 | * Fix incorrect `entry_points` metadata 206 | 207 | v6.0.3 (2020-01-23) 208 | ------------------- 209 | 210 | * Fix `#44`: when running `tbump init`, do not fail if no files are found matching the current version. 211 | 212 | v6.0.2 (2019-07-19) 213 | ------------------- 214 | 215 | * Implement `#36 `_: The ``--only-patch`` flag now allows skipping any git operations or hook commands. 216 | 217 | v6.0.1 (2019-07-16) 218 | ------------------- 219 | 220 | * Fix `#41 `_: When run with ``--dry-run``, don't abort if git state is incorrect, just print the error message at the end. 221 | 222 | v6.0.0 (2019-07-15) 223 | ------------------- 224 | 225 | * **Breaking change**: Search strings are now regular expressions 226 | * **Breaking change**: Allow globs in paths (breaking if paths contained ``*``, ``?``, ``[`` or ``]`` characters). 227 | 228 | v5.0.4 (2019-03-13) 229 | ------------------- 230 | * Preserve line endings when patching files. 231 | 232 | v5.0.3 (2018-12-18) 233 | ------------------- 234 | 235 | * Use new and shiny `cli-ui `_ package instead of old `python-cli-ui` 236 | 237 | v5.0.2 (2018-10-11) 238 | ------------------- 239 | 240 | * Rename ``before_push`` section to ``before_commit``: it better reflects at which 241 | moment the hook runs. Note that you can still use ``before_push`` or even ``hook`` if 242 | you want. 243 | 244 | v5.0.1 (2018-10-11) 245 | ------------------- 246 | 247 | * Expose ``tbump.bump_files()`` convenience function. 248 | 249 | 250 | v5.0.0 (2018-08-27) 251 | ------------------- 252 | 253 | * **Breaking change**: tbump can now run hooks *after* the push is made. Thus 254 | ``[[hook]]`` sections should be renamed to ``[before_push]]`` or 255 | ``[[after_push]]``: 256 | 257 | .. code-block:: ini 258 | 259 | # Before (< 5.0.0), running before commit by default: 260 | [[hook]] 261 | name = "some hook" 262 | cmd = "some command" 263 | 264 | # After (>= 5.00), more explicit syntax: 265 | [[before_push]] 266 | name = "some hook" 267 | cmd = "some command" 268 | 269 | # New feature: running after push is made: 270 | [[after_push]] 271 | name = "some other hook" 272 | cmd = "some other command" 273 | 274 | * ``tbump init`` now takes the current version directly on the command line instead of interactively asking for it 275 | 276 | 277 | v4.0.0 (2018-07-13) 278 | ------------------- 279 | 280 | * Re-add ``--dry-run`` 281 | * Add ``tbump init`` to interactively create the ``tbump.toml`` configuration file 282 | 283 | v3.0.1 (2018-07-12) 284 | ------------------- 285 | 286 | * Bug fix: make sure to push the tag *after* the branch. See `#20 `_ for the details. 287 | 288 | v3.0.0 (2018-05-14) 289 | -------------------- 290 | 291 | * New feature: you can now specify commands to be run after files have been patched and right before git commands are executed. 292 | 293 | .. code-block:: ini 294 | 295 | [[hook]] 296 | name = "Update Cargo.lock" 297 | cmd = "cargo check" 298 | 299 | 300 | v2.0.0 (2018-04-26) 301 | ------------------- 302 | 303 | * Dry run behavior is now activated by default. We start by computing all the changes and then ask if they look good before doing anything. This also means we no 304 | longer need to pause right before calling ``git push``. Consequently, the ``--dry-run`` option is gone. 305 | 306 | * Fix inconsistency: 'current version' was sometimes called 'old version'. 307 | 308 | v1.0.2 (2018-04-09) 309 | ------------------- 310 | 311 | * Fix printing a big ugly stacktrace when looking for the old version number failed for one or more files. 312 | 313 | v1.0.1 (2018-04-05) 314 | ------------------- 315 | 316 | 317 | * Use annotated tags instead of lightweight tags. Patch by @tux3. See `PR #7 `_ for the rationale. 318 | * When the current branch does not track anything, ask if we should proceed with file replacements and automatic commit and tag (but do not push) instead of aborting immediately. 319 | 320 | v1.0.0 (2018-01-16) 321 | ------------------- 322 | 323 | 324 | * First stable release. 325 | 326 | Since we use `semver `_ this means tbump is now considered stable. 327 | 328 | Enjoy! 329 | 330 | v0.0.9 (2018-01-13) 331 | ------------------- 332 | 333 | 334 | * Fix regression when using the same file twice 335 | 336 | v0.0.8 (2018-01-05) 337 | ------------------- 338 | 339 | * Allow replacing different types of version. For instance, you may want to write ``pub_version="1.42"`` in one file and ``full_version="1.2.42-rc1"`` in an other. 340 | * Add ``--dry-run`` command line argument 341 | * Improve error handling 342 | * Validate git commit message template 343 | * Validate that current version matches expected regex 344 | * Make sure new version matches the expected regex 345 | * Make sure that custom version templates only contain known groups 346 | * Avoid leaving the repo in an inconsistent state if no match is found 347 | -------------------------------------------------------------------------------- /tbump/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import textwrap 3 | import urllib.parse 4 | from contextlib import suppress 5 | from dataclasses import dataclass 6 | from enum import Enum 7 | from pathlib import Path 8 | from typing import Dict, List, Optional, Union, cast 9 | 10 | import cli_ui as ui 11 | import docopt 12 | from packaging.version import InvalidVersion 13 | from packaging.version import parse as parse_version 14 | 15 | from tbump.config import get_config_file 16 | from tbump.error import Error 17 | from tbump.executor import Executor 18 | from tbump.file_bumper import FileBumper 19 | from tbump.git import GitError 20 | from tbump.git_bumper import GitBumper, GitBumperOptions 21 | from tbump.hooks import HooksRunner 22 | from tbump.init import init 23 | 24 | TBUMP_VERSION = "6.11.0" 25 | 26 | USAGE = textwrap.dedent( 27 | """ 28 | Usage: 29 | tbump [options] 30 | tbump [options] current-version 31 | tbump [options] init [--pyproject] 32 | tbump --help 33 | tbump --version 34 | 35 | Options: 36 | -h --help Show this screen. 37 | -v --version Show version. 38 | -C --cwd= Set working directory to . 39 | -c --config= Use specified toml config file. When not set, `tbump.toml` is assumed. 40 | --non-interactive Never prompt for confirmation. Useful for automated scripts. 41 | --dry-run Only display the changes that would be made. 42 | --tag-message= Message to use for tag instead of being based on the tag template 43 | --only-patch Only patches files, skipping any git operations or hook commands. 44 | --no-tag Do not create a tag 45 | --no-push Do not push after creating the commit and/or tag 46 | --no-tag-push Create a tag, but don't push it 47 | """ 48 | ) 49 | 50 | 51 | class Canceled(Error): 52 | def print_error(self) -> None: 53 | ui.error("Canceled by user") 54 | 55 | 56 | @dataclass 57 | class BumpOptions: 58 | working_path: Path 59 | new_version: str 60 | interactive: bool = True 61 | dry_run: bool = False 62 | config_path: Optional[Path] = None 63 | tag_message: Optional[str] = None 64 | 65 | 66 | class Command(Enum): 67 | bump = "bump" 68 | init = "init" 69 | current_version = "current_version" 70 | version = "version" 71 | 72 | 73 | def print_diff(filename: str, lineno: int, old: str, new: str) -> None: 74 | # fmt: off 75 | ui.info( 76 | ui.red, "- ", ui.reset, 77 | ui.bold, filename, ":", lineno, ui.reset, 78 | " ", ui.red, old, 79 | sep="", 80 | ) 81 | ui.info( 82 | ui.green, "+ ", ui.reset, 83 | ui.bold, filename, ":", lineno, ui.reset, 84 | " ", ui.green, new, 85 | sep="", 86 | ) 87 | # fmt: on 88 | 89 | 90 | @dataclass 91 | class GivenCliArguments: 92 | """ 93 | Values of the CLI arguments that were given. 94 | 95 | Bool values indicate that that argument was given, NOT the intended behavior of the program. 96 | """ 97 | 98 | command: Command 99 | bump_new_version: Optional[str] 100 | init_current_version: Optional[str] 101 | init_pyproject: bool 102 | working_path: Optional[Path] 103 | config_path: Optional[Path] 104 | tag_message: Optional[str] 105 | non_interactive: bool 106 | dry_run: bool 107 | only_patch: bool 108 | no_tag: bool 109 | no_push: bool 110 | no_tag_push: bool 111 | 112 | @classmethod 113 | def from_opts( 114 | cls, opt_dict: Dict[str, Union[bool, Optional[str]]] 115 | ) -> "GivenCliArguments": 116 | def _get_path(key: str) -> Optional[Path]: 117 | value = opt_dict[key] 118 | if value is None: 119 | return None 120 | return Path(cast(str, value)) 121 | 122 | def _get_str(key: str) -> Optional[str]: 123 | return cast(Optional[str], opt_dict[key]) 124 | 125 | def _get_bool(key: str) -> bool: 126 | return cast(bool, opt_dict[key]) 127 | 128 | # docopt has a hard time parsing the commands because run_bump uses that same cli slot for 129 | # the new version. This corrects those issues. 130 | command = Command.bump 131 | new_version = opt_dict[""] 132 | if new_version == "init" or opt_dict["init"]: 133 | command = Command.init 134 | elif new_version == "current-version": 135 | command = Command.current_version 136 | elif opt_dict["--version"]: 137 | command = Command.version 138 | 139 | return cls( 140 | command=command, 141 | bump_new_version=_get_str(""), 142 | init_current_version=_get_str(""), 143 | init_pyproject=_get_bool("--pyproject"), 144 | working_path=_get_path("--cwd"), 145 | config_path=_get_path("--config"), 146 | non_interactive=_get_bool("--non-interactive"), 147 | dry_run=_get_bool("--dry-run"), 148 | only_patch=_get_bool("--only-patch"), 149 | tag_message=_get_str("--tag-message"), 150 | no_tag=_get_bool("--no-tag"), 151 | no_push=_get_bool("--no-push"), 152 | no_tag_push=_get_bool("--no-tag-push"), 153 | ) 154 | 155 | 156 | def run(cmd: List[str]) -> None: 157 | opt_dict = docopt.docopt(USAGE, argv=cmd) 158 | arguments = GivenCliArguments.from_opts(opt_dict) 159 | 160 | if arguments.command == Command.version: 161 | print("tbump", TBUMP_VERSION) 162 | return 163 | 164 | # when running `tbump init` (with current_version missing), 165 | # docopt thinks we are running `tbump` with new_version = "init" 166 | # bail out early in this case 167 | if arguments.command == Command.init and arguments.init_current_version is None: 168 | sys.exit(USAGE) 169 | 170 | # if a path wasn't given, use current working directory 171 | working_path = arguments.working_path or Path.cwd() 172 | 173 | # Ditto for `tbump current-version` 174 | if arguments.command == Command.current_version: 175 | config_file = get_config_file( 176 | working_path, 177 | specified_config_path=arguments.config_path, 178 | ) 179 | config = config_file.get_config() 180 | print(config.current_version) 181 | return 182 | 183 | if arguments.command == Command.init: 184 | run_init(arguments, working_path) 185 | return 186 | 187 | run_bump(arguments, working_path, arguments.tag_message) 188 | 189 | 190 | def run_init(arguments: GivenCliArguments, working_path: Path) -> None: 191 | init( 192 | working_path, 193 | current_version=cast(str, arguments.init_current_version), 194 | use_pyproject=arguments.init_pyproject, 195 | specified_config_path=arguments.config_path, 196 | ) 197 | 198 | 199 | def run_bump( 200 | arguments: GivenCliArguments, working_path: Path, tag_message: Optional[str] 201 | ) -> None: 202 | bump_options = BumpOptions( 203 | working_path=working_path, 204 | tag_message=tag_message, 205 | new_version=cast(str, arguments.bump_new_version), 206 | config_path=arguments.config_path, 207 | dry_run=arguments.dry_run, 208 | interactive=not arguments.non_interactive, 209 | ) 210 | 211 | bump(bump_options, _construct_operations(arguments)) 212 | 213 | 214 | class NotANewVersion(Error): 215 | def __init__(self) -> None: 216 | super().__init__() 217 | 218 | def print_error(self) -> None: 219 | ui.error("New version is the same as the previous one") 220 | 221 | 222 | class OlderNewVersion(Error): 223 | def __init__(self, *, new_version: str, current_version: str) -> None: 224 | self.new_version = new_version 225 | self.current_version = current_version 226 | super().__init__() 227 | 228 | def print_error(self) -> None: 229 | ui.error( 230 | ui.reset, 231 | "New version", 232 | ui.bold, 233 | self.new_version, 234 | ui.reset, 235 | "is older than current version", 236 | ui.bold, 237 | self.current_version, 238 | ) 239 | 240 | 241 | def bump(options: BumpOptions, operations: List[str]) -> None: 242 | working_path = options.working_path 243 | new_version = options.new_version 244 | interactive = options.interactive 245 | dry_run = options.dry_run 246 | specified_config_path = options.config_path 247 | 248 | config_file = get_config_file( 249 | options.working_path, specified_config_path=specified_config_path 250 | ) 251 | config = config_file.get_config() 252 | 253 | check_versions(current=config.current_version, new=new_version) 254 | 255 | # fmt: off 256 | ui.info_1( 257 | "Bumping from", ui.bold, config.current_version, 258 | ui.reset, "to", ui.bold, new_version, 259 | ) 260 | # fmt: on 261 | 262 | bumper_options = GitBumperOptions( 263 | working_path=options.working_path, 264 | tag_message=options.tag_message, 265 | ) 266 | git_bumper = GitBumper(bumper_options, operations) 267 | git_bumper.set_config(config) 268 | git_state_error = None 269 | try: 270 | git_bumper.check_dirty() 271 | git_bumper.check_branch_state(new_version) 272 | except GitError as e: 273 | if dry_run: 274 | git_state_error = e 275 | else: 276 | raise 277 | 278 | file_bumper = FileBumper(working_path, config) 279 | file_bumper.check_files_exist() 280 | config_file.set_new_version(new_version) 281 | 282 | executor = Executor(new_version, file_bumper, config_file) 283 | 284 | hooks_runner = HooksRunner(working_path, config.current_version, operations) 285 | if "hooks" in operations: 286 | for hook in config.hooks: 287 | hooks_runner.add_hook(hook) 288 | 289 | executor.add_git_and_hook_actions(new_version, git_bumper, hooks_runner) 290 | 291 | if interactive: 292 | executor.print_self(dry_run=True) 293 | if not dry_run: 294 | proceed = ui.ask_yes_no("Looking good?", default=False) 295 | if not proceed: 296 | raise Canceled() 297 | 298 | if dry_run: 299 | if git_state_error: 300 | ui.error("Git repository state is invalid") 301 | git_state_error.print_error() 302 | sys.exit(1) 303 | else: 304 | return 305 | 306 | executor.run() 307 | 308 | if config.github_url and "push_tag" in operations: 309 | tag_name = git_bumper.get_tag_name(new_version) 310 | suggest_creating_github_release(config.github_url, tag_name) 311 | 312 | 313 | def check_versions(*, current: str, new: str) -> None: 314 | if current == new: 315 | raise NotANewVersion() 316 | 317 | with suppress(InvalidVersion): 318 | parsed_current = parse_version(current) 319 | parsed_new = parse_version(new) 320 | if parsed_new < parsed_current: 321 | raise OlderNewVersion(current_version=current, new_version=new) 322 | 323 | 324 | def suggest_creating_github_release(github_url: str, tag_name: str) -> None: 325 | query_string = urllib.parse.urlencode({"tag": tag_name}) 326 | if not github_url.endswith("/"): 327 | github_url += "/" 328 | full_url = github_url + "releases/new?" + query_string 329 | ui.info() 330 | ui.info("Note: create a new release on GitHub by visiting:") 331 | ui.info(ui.tabs(1), full_url) 332 | 333 | 334 | def main(args: Optional[List[str]] = None) -> None: 335 | # Suppress backtrace if exception derives from Error 336 | if not args: 337 | args = sys.argv[1:] 338 | try: 339 | run(args) 340 | except Error as error: 341 | error.print_error() 342 | sys.exit(1) 343 | 344 | 345 | def _construct_operations(arguments: GivenCliArguments) -> List[str]: 346 | operations = ["patch", "hooks", "commit", "tag", "push_commit", "push_tag"] 347 | if arguments.only_patch: 348 | operations = ["patch"] 349 | if arguments.no_push: 350 | operations.remove("push_commit") 351 | operations.remove("push_tag") 352 | if arguments.no_tag_push: 353 | # may have been removed by the above line 354 | if "push_tag" in operations: 355 | operations.remove("push_tag") 356 | if arguments.no_tag: 357 | operations.remove("tag") 358 | # Also remove push_tag if it's still in the list: 359 | if "push_tag" in operations: 360 | operations.remove("push_tag") 361 | return operations 362 | -------------------------------------------------------------------------------- /tbump/config.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import re 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | from typing import Any, Dict, List, Optional, Pattern, Tuple, Union 6 | 7 | import cli_ui as ui 8 | import schema 9 | import tomlkit 10 | from tomlkit.toml_document import TOMLDocument 11 | 12 | from tbump.action import Action 13 | from tbump.error import Error 14 | from tbump.hooks import HOOKS_CLASSES, Hook 15 | 16 | 17 | @dataclass 18 | class File: 19 | src: str 20 | search: Optional[str] = None 21 | version_template: Optional[str] = None 22 | 23 | 24 | @dataclass 25 | class Field: 26 | name: str 27 | default: Optional[Union[str, int]] = None 28 | 29 | 30 | @dataclass 31 | class Config: 32 | current_version: str 33 | version_regex: Pattern[str] 34 | 35 | git_tag_template: str 36 | git_message_template: str 37 | atomic_push: bool 38 | sign: bool 39 | 40 | files: List[File] 41 | hooks: List[Hook] 42 | fields: List[Field] 43 | 44 | github_url: Optional[str] 45 | 46 | 47 | class ConfigFileUpdater(Action, metaclass=abc.ABCMeta): 48 | """Base class representing a config file""" 49 | 50 | def __init__(self, project_path: Path, path: Path, doc: TOMLDocument): 51 | self.project_path = project_path 52 | self.path = path 53 | self.doc = doc 54 | 55 | @property 56 | def relative_path(self) -> Path: 57 | return self.project_path / self.path 58 | 59 | def print_self(self) -> None: 60 | from tbump.cli import print_diff 61 | 62 | old_text = self.path.read_text() 63 | new_text = tomlkit.dumps(self.doc) 64 | 65 | lineno = 1 66 | for old_line, new_line in zip(old_text.splitlines(), new_text.splitlines()): 67 | if old_line != new_line: 68 | print_diff( 69 | str(self.relative_path), 70 | lineno + 1, 71 | old_line.strip(), 72 | new_line.strip(), 73 | ) 74 | lineno += 1 75 | 76 | def do(self) -> None: 77 | new_text = tomlkit.dumps(self.doc) 78 | self.path.write_text(new_text) 79 | 80 | @abc.abstractmethod 81 | def get_parsed(self) -> dict: 82 | """Return a plain dictionary, suitable for validation 83 | by the `schema` library 84 | """ 85 | 86 | @abc.abstractmethod 87 | def set_new_version(self, version: str) -> None: 88 | pass 89 | 90 | def get_config(self) -> Config: 91 | """Return a validated Config instance""" 92 | parsed = self.get_parsed() 93 | res = from_parsed_config(parsed) 94 | validate_config(res) 95 | return res 96 | 97 | 98 | class TbumpTomlUpdater(ConfigFileUpdater): 99 | """Represent config inside a tbump.toml file""" 100 | 101 | def __init__(self, project_path: Path, path: Path, doc: TOMLDocument): 102 | super().__init__(project_path, path, doc) 103 | 104 | def get_parsed(self) -> dict: 105 | # Document -> dict 106 | return self.doc.value 107 | 108 | def set_new_version(self, version: str) -> None: 109 | self.doc["version"]["current"] = version # type: ignore[index] 110 | 111 | 112 | class PyprojectUpdater(ConfigFileUpdater): 113 | """Represent a config inside a pyproject.toml file, 114 | under the [tool.tbump] key 115 | """ 116 | 117 | def __init__(self, project_path: Path, path: Path, doc: TOMLDocument): 118 | super().__init__(project_path, path, doc) 119 | 120 | @staticmethod 121 | def unwrap_data(data: Any) -> Any: 122 | if hasattr(data, "unwrap") and callable(data.unwrap): 123 | return data.unwrap() 124 | else: 125 | return data 126 | 127 | def get_parsed(self) -> dict: 128 | try: 129 | tool_section = PyprojectUpdater.unwrap_data( 130 | self.doc["tool"]["tbump"] # type: ignore[index] 131 | ) 132 | except KeyError as e: 133 | raise InvalidConfig(parse_error=e) 134 | 135 | return tool_section # type: ignore[no-any-return] 136 | 137 | def set_new_version(self, new_version: str) -> None: 138 | self.doc["tool"]["tbump"]["version"]["current"] = new_version # type: ignore[index] 139 | 140 | 141 | def validate_template(name: str, pattern: str, value: str) -> None: 142 | if pattern not in value: 143 | message = "%s should contain the string %s" % (name, pattern) 144 | raise schema.SchemaError(message) 145 | 146 | 147 | def validate_git_tag_template(value: str) -> None: 148 | validate_template("git.tag_template", "{new_version}", value) 149 | 150 | 151 | def validate_git_message_template(value: str) -> None: 152 | validate_template("git.message_template", "{new_version}", value) 153 | 154 | 155 | def validate_version_template( 156 | src: str, version_template: str, known_groups: Dict[str, str] 157 | ) -> None: 158 | try: 159 | version_template.format(**known_groups) 160 | except KeyError as e: 161 | message = "version template for '%s' contains unknown group: %s" % (src, e) 162 | raise schema.SchemaError(message) 163 | 164 | 165 | def validate_hook_cmd(cmd: str) -> None: 166 | try: 167 | cmd.format(new_version="dummy", current_version="dummy") 168 | except KeyError as e: 169 | message = "hook cmd: '%s' uses unknown placeholder: %s" % (cmd, e) 170 | raise schema.SchemaError(message) 171 | 172 | 173 | def validate_basic_schema(config: dict) -> None: 174 | """First pass of validation, using schema""" 175 | # Note: asserts that we won't get KeyError or invalid types 176 | # when building or initial Config instance 177 | file_schema = schema.Schema( 178 | { 179 | "src": str, 180 | schema.Optional("search"): str, 181 | schema.Optional("version_template"): str, 182 | } 183 | ) 184 | 185 | field_schema = schema.Schema( 186 | { 187 | "name": str, 188 | schema.Optional("default"): schema.Or(str, int), 189 | } 190 | ) 191 | 192 | hook_schema = schema.Schema({"name": str, "cmd": str}) 193 | 194 | def validate_re(regex: str) -> str: 195 | re.compile(regex, re.VERBOSE) 196 | return regex 197 | 198 | tbump_schema = schema.Schema( 199 | { 200 | "version": {"current": str, "regex": schema.Use(validate_re)}, 201 | "git": { 202 | "message_template": str, 203 | "tag_template": str, 204 | schema.Optional("atomic_push"): bool, 205 | schema.Optional("sign"): bool, 206 | }, 207 | "file": [file_schema], 208 | schema.Optional("field"): [field_schema], 209 | schema.Optional("hook"): [hook_schema], # retro-compat 210 | schema.Optional("before_push"): [hook_schema], # retro-compat 211 | schema.Optional("before_commit"): [hook_schema], 212 | schema.Optional("after_push"): [hook_schema], 213 | schema.Optional("github_url"): str, 214 | } 215 | ) 216 | tbump_schema.validate(config) 217 | 218 | 219 | def validate_config(cfg: Config) -> None: 220 | """Second pass of validation, using the Config 221 | class. 222 | 223 | """ 224 | # Note: separated from validate_basic_schema to keep error 225 | # messages user friendly 226 | 227 | current_version = cfg.current_version 228 | 229 | validate_git_message_template(cfg.git_message_template) 230 | validate_git_tag_template(cfg.git_tag_template) 231 | 232 | match = cfg.version_regex.fullmatch(current_version) 233 | if not match: 234 | message = "Current version: %s does not match version regex" % current_version 235 | raise schema.SchemaError(message) 236 | current_version_regex_groups = match.groupdict() 237 | 238 | for file_config in cfg.files: 239 | version_template = file_config.version_template 240 | if version_template: 241 | validate_version_template( 242 | file_config.src, version_template, current_version_regex_groups 243 | ) 244 | 245 | for hook in cfg.hooks: 246 | validate_hook_cmd(hook.cmd) 247 | 248 | 249 | def get_config_file( 250 | project_path: Path, *, specified_config_path: Optional[Path] = None 251 | ) -> ConfigFileUpdater: 252 | try: 253 | config_type, config_path = _get_config_path_and_type( 254 | project_path, specified_config_path 255 | ) 256 | res = _get_config_file(project_path, config_type, config_path) 257 | # Make sure config is correct before returning it 258 | res.get_config() 259 | return res 260 | except IOError as io_error: 261 | raise InvalidConfig(io_error=io_error) 262 | except schema.SchemaError as parse_error: 263 | raise InvalidConfig(parse_error=parse_error) 264 | 265 | 266 | def _get_config_path_and_type( 267 | project_path: Path, specified_config_path: Optional[Path] = None 268 | ) -> Tuple[str, Path]: 269 | if specified_config_path: 270 | return "tbump.toml", specified_config_path 271 | 272 | toml_path = project_path / "tbump.toml" 273 | if toml_path.exists(): 274 | return "tbump.toml", toml_path 275 | 276 | pyproject_path = project_path / "pyproject.toml" 277 | if pyproject_path.exists(): 278 | return "pyproject.toml", pyproject_path 279 | 280 | raise ConfigNotFound(project_path) 281 | 282 | 283 | def _get_config_file( 284 | project_path: Path, config_type: str, config_path: Path 285 | ) -> ConfigFileUpdater: 286 | if config_type == "tbump.toml": 287 | doc = tomlkit.loads(config_path.read_text()) 288 | return TbumpTomlUpdater(project_path, config_path, doc) 289 | elif config_type == "pyproject.toml": 290 | doc = tomlkit.loads(config_path.read_text()) 291 | return PyprojectUpdater(project_path, config_path, doc) 292 | raise ValueError("unknown config_type: {config_type}") 293 | 294 | 295 | def from_parsed_config(parsed: dict) -> Config: 296 | validate_basic_schema(parsed) 297 | current_version = parsed["version"]["current"] 298 | git_message_template = parsed["git"]["message_template"] 299 | git_tag_template = parsed["git"]["tag_template"] 300 | atomic_push = parsed["git"].get("atomic_push", True) 301 | sign = parsed["git"].get("sign", False) 302 | version_regex = re.compile(parsed["version"]["regex"], re.VERBOSE) 303 | files = [] 304 | for file_dict in parsed["file"]: 305 | file_config = File( 306 | src=file_dict["src"], 307 | search=file_dict.get("search"), 308 | version_template=file_dict.get("version_template"), 309 | ) 310 | files.append(file_config) 311 | fields = [] 312 | for field_dict in parsed.get("field", []): 313 | field_config = Field( 314 | name=field_dict["name"], 315 | default=field_dict.get("default"), 316 | ) 317 | fields.append(field_config) 318 | hooks = [] 319 | for hook_type in ("hook", "before_push", "before_commit", "after_push"): 320 | cls = HOOKS_CLASSES[hook_type] 321 | if hook_type in parsed: 322 | for hook_dict in parsed[hook_type]: 323 | hook = cls(hook_dict["name"], hook_dict["cmd"]) 324 | hooks.append(hook) 325 | 326 | github_url = parsed.get("github_url") 327 | 328 | config = Config( 329 | current_version=current_version, 330 | version_regex=version_regex, 331 | git_message_template=git_message_template, 332 | git_tag_template=git_tag_template, 333 | atomic_push=atomic_push, 334 | sign=sign, 335 | fields=fields, 336 | files=files, 337 | hooks=hooks, 338 | github_url=github_url, 339 | ) 340 | 341 | validate_config(config) 342 | 343 | return config 344 | 345 | 346 | class ConfigNotFound(Error): 347 | def __init__(self, project_path: Path): 348 | self.project_path = project_path 349 | 350 | def print_error(self) -> None: 351 | ui.error("No configuration for tbump found in", self.project_path) 352 | ui.info("Please run `tbump init` to create a tbump.toml file") 353 | ui.info("Or add a [tool.tbump] section in the pyproject.toml file") 354 | 355 | 356 | class InvalidConfig(Error): 357 | def __init__( 358 | self, 359 | io_error: Optional[IOError] = None, 360 | parse_error: Optional[Exception] = None, 361 | ): 362 | super().__init__() 363 | self.io_error = io_error 364 | self.parse_error = parse_error 365 | 366 | def print_error(self) -> None: 367 | if self.io_error: 368 | ui.error("Could not read config file:", self.io_error) 369 | if self.parse_error: 370 | ui.error("Invalid config:", self.parse_error) 371 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Optional 3 | 4 | import pytest 5 | import tomlkit 6 | 7 | from tbump.cli import NotANewVersion, OlderNewVersion 8 | from tbump.cli import run as run_tbump 9 | from tbump.config import ConfigNotFound, InvalidConfig 10 | from tbump.error import Error 11 | from tbump.file_bumper import ( 12 | BadSubstitution, 13 | CurrentVersionNotFound, 14 | InvalidVersion, 15 | SourceFileNotFound, 16 | ) 17 | from tbump.git import run_git, run_git_captured 18 | from tbump.git_bumper import DirtyRepository, NoTrackedBranch, RefAlreadyExists 19 | from tests.conftest import GitRecorder, file_contains 20 | 21 | 22 | def files_bumped(test_repo: Path, config_path: Optional[Path] = None) -> bool: 23 | config_path = config_path or test_repo / "tbump.toml" 24 | new_toml = tomlkit.loads(config_path.read_text()) 25 | current_version = new_toml["version"]["current"] # type: ignore[index] 26 | 27 | assert current_version == "1.2.41-alpha-2" 28 | 29 | return all( 30 | ( 31 | file_contains(test_repo / "package.json", '"version": "1.2.41-alpha-2"'), 32 | file_contains(test_repo / "package.json", '"other-dep": "1.2.41-alpha-1"'), 33 | file_contains(test_repo / "pub.js", "PUBLIC_VERSION = '1.2.41'"), 34 | ) 35 | ) 36 | 37 | 38 | def files_not_bumped(test_repo: Path) -> bool: 39 | toml_path = test_repo / "tbump.toml" 40 | new_toml = tomlkit.loads(toml_path.read_text()) 41 | assert new_toml["version"]["current"] == "1.2.41-alpha-1" # type: ignore[index] 42 | 43 | return all( 44 | ( 45 | file_contains(test_repo / "package.json", '"version": "1.2.41-alpha-1"'), 46 | file_contains(test_repo / "package.json", '"other-dep": "1.2.41-alpha-1"'), 47 | file_contains(test_repo / "pub.js", "PUBLIC_VERSION = '1.2.41'"), 48 | ) 49 | ) 50 | 51 | 52 | def commit_created(test_repo: Path) -> bool: 53 | _, out = run_git_captured(test_repo, "log", "--oneline") 54 | return "Bump to 1.2.41-alpha-2" in out 55 | 56 | 57 | def tag_created(test_repo: Path, tag_message: Optional[str] = None) -> bool: 58 | _, out = run_git_captured(test_repo, "tag", "-n1") 59 | 60 | tag_exists = "v1.2.41-alpha-2" in out 61 | 62 | if tag_message: 63 | tag_exists &= tag_message in out 64 | 65 | return tag_exists 66 | 67 | 68 | def tag_pushed(test_repo: Path) -> bool: 69 | rc, _ = run_git_captured( 70 | test_repo, 71 | "ls-remote", 72 | "--exit-code", 73 | "origin", 74 | "refs/tags/v1.2.41-alpha-2", 75 | check=False, 76 | ) 77 | return rc == 0 78 | 79 | 80 | def branch_pushed(test_repo: Path, previous_commit: str) -> bool: 81 | _, local_commit = run_git_captured(test_repo, "rev-parse", "HEAD") 82 | _, out = run_git_captured(test_repo, "ls-remote", "origin", "refs/heads/master") 83 | remote_commit = out.split()[0] 84 | return remote_commit != previous_commit 85 | return remote_commit == local_commit 86 | 87 | 88 | def bump_done( 89 | test_repo: Path, 90 | previous_commit: str, 91 | ) -> bool: 92 | return all( 93 | ( 94 | files_bumped(test_repo), 95 | commit_created(test_repo), 96 | tag_created(test_repo), 97 | branch_pushed(test_repo, previous_commit), 98 | tag_pushed(test_repo), 99 | ) 100 | ) 101 | 102 | 103 | def bump_not_done(test_repo: Path, previous_commit: str) -> bool: 104 | return all( 105 | ( 106 | files_not_bumped(test_repo), 107 | not commit_created(test_repo), 108 | not tag_created(test_repo), 109 | not branch_pushed(test_repo, previous_commit), 110 | not tag_pushed(test_repo), 111 | ) 112 | ) 113 | 114 | 115 | def only_patch_done(test_repo: Path, previous_commit: str) -> bool: 116 | return all( 117 | ( 118 | files_bumped(test_repo), 119 | not commit_created(test_repo), 120 | not tag_created(test_repo), 121 | not branch_pushed(test_repo, previous_commit), 122 | not tag_pushed(test_repo), 123 | ) 124 | ) 125 | 126 | 127 | def test_get_current_version(test_repo: Path, capsys: pytest.CaptureFixture) -> None: 128 | run_tbump(["-C", str(test_repo), "current-version"]) 129 | captured = capsys.readouterr() 130 | assert captured.out == "1.2.41-alpha-1\n" 131 | assert captured.err == "" 132 | 133 | 134 | def test_end_to_end_using_tbump_toml(test_repo: Path) -> None: 135 | _, previous_commit = run_git_captured(test_repo, "rev-parse", "HEAD") 136 | run_tbump(["-C", str(test_repo), "1.2.41-alpha-2", "--non-interactive"]) 137 | 138 | assert bump_done(test_repo, previous_commit) 139 | 140 | 141 | def test_end_to_end_using_tbump_toml_no_atomic( 142 | test_repo: Path, git_recorder: GitRecorder 143 | ) -> None: 144 | tbump_toml = test_repo / "tbump.toml" 145 | doc = tomlkit.loads(tbump_toml.read_text()) 146 | doc["git"]["atomic_push"] = False # type: ignore[index] 147 | tbump_toml.write_text(tomlkit.dumps(doc)) 148 | run_git(test_repo, "add", ".") 149 | run_git(test_repo, "commit", "--message", "tbump: do not use atomic push") 150 | 151 | _, previous_commit = run_git_captured(test_repo, "rev-parse", "HEAD") 152 | run_tbump( 153 | [ 154 | "-C", 155 | str(test_repo), 156 | "1.2.41-alpha-2", 157 | "--non-interactive", 158 | ] 159 | ) 160 | 161 | assert bump_done(test_repo, previous_commit) 162 | last_command = git_recorder.commands()[-1] 163 | assert "push" in last_command 164 | assert "--atomic" not in last_command 165 | 166 | 167 | def test_end_to_end_using_pyproject_toml(test_pyproject_repo: Path) -> None: 168 | _, previous_commit = run_git_captured(test_pyproject_repo, "rev-parse", "HEAD") 169 | 170 | run_tbump(["-C", str(test_pyproject_repo), "0.2.0", "--non-interactive"]) 171 | 172 | pyproject_toml = test_pyproject_repo / "pyproject.toml" 173 | doc = tomlkit.loads(pyproject_toml.read_text()) 174 | assert doc["tool"]["tbump"]["version"]["current"] == "0.2.0" # type: ignore[index] 175 | assert doc["tool"]["poetry"]["version"] == "0.2.0" # type: ignore[index] 176 | 177 | foo_py = test_pyproject_repo / "foo" / "__init__.py" 178 | actual = foo_py.read_text() 179 | assert "0.2.0" in actual 180 | 181 | 182 | def test_using_specified_path( 183 | test_repo: Path, 184 | ) -> None: 185 | run_git(test_repo, "mv", "tbump.toml", "other.toml") 186 | run_git(test_repo, "commit", "--message", "rename tbump.toml -> other.toml") 187 | config_path = test_repo / "other.toml" 188 | 189 | # fmt: off 190 | run_tbump( 191 | [ 192 | "-C", str(test_repo), 193 | "--config", str(config_path), 194 | "--only-patch", 195 | "--non-interactive", 196 | "1.2.41-alpha-2", 197 | ] 198 | ) 199 | # fmt: on 200 | 201 | assert files_bumped(test_repo, config_path=config_path) 202 | 203 | 204 | def test_dry_run_interactive(test_repo: Path) -> None: 205 | _, previous_commit = run_git_captured(test_repo, "rev-parse", "HEAD") 206 | run_tbump(["-C", str(test_repo), "1.2.41-alpha-2", "--dry-run"]) 207 | assert bump_not_done(test_repo, previous_commit) 208 | 209 | 210 | def test_dry_run_non_interactive(test_repo: Path) -> None: 211 | _, previous_commit = run_git_captured(test_repo, "rev-parse", "HEAD") 212 | run_tbump( 213 | ["-C", str(test_repo), "1.2.41-alpha-2", "--dry-run", "--non-interactive"] 214 | ) 215 | 216 | assert bump_not_done(test_repo, previous_commit) 217 | 218 | 219 | def test_only_patch(test_repo: Path) -> None: 220 | _, previous_commit = run_git_captured(test_repo, "rev-parse", "HEAD") 221 | run_tbump( 222 | ["-C", str(test_repo), "1.2.41-alpha-2", "--non-interactive", "--only-patch"] 223 | ) 224 | 225 | assert only_patch_done(test_repo, previous_commit) 226 | 227 | 228 | def test_on_outdated_branch(test_repo: Path) -> None: 229 | """Make sure no tag is pushed when running tbump on an outdated branch""" 230 | # See https://github.com/dmerejkowsky/tbump/issues/20 ¨ for details 231 | 232 | # Make sure the branch is out of date 233 | run_git(test_repo, "commit", "--message", "commit I did not make", "--allow-empty") 234 | run_git(test_repo, "push", "origin", "master") 235 | run_git(test_repo, "reset", "--hard", "HEAD~1") 236 | 237 | with pytest.raises(Error): 238 | run_tbump(["-C", str(test_repo), "1.2.41-alpha-2", "--non-interactive"]) 239 | assert not tag_pushed(test_repo) 240 | 241 | 242 | def test_tbump_toml_not_found(test_repo: Path) -> None: 243 | toml_path = test_repo / "tbump.toml" 244 | toml_path.unlink() 245 | with pytest.raises(ConfigNotFound): 246 | run_tbump(["-C", str(test_repo), "1.2.42", "--non-interactive"]) 247 | 248 | 249 | def test_tbump_toml_bad_syntax(test_repo: Path) -> None: 250 | toml_path = test_repo / "tbump.toml" 251 | bad_toml = tomlkit.loads(toml_path.read_text()) 252 | del bad_toml["git"] 253 | toml_path.write_text(tomlkit.dumps(bad_toml)) 254 | with pytest.raises(InvalidConfig): 255 | run_tbump(["-C", str(test_repo), "1.2.42", "--non-interactive"]) 256 | 257 | 258 | def test_new_version_does_not_match(test_repo: Path) -> None: 259 | with pytest.raises(InvalidVersion): 260 | run_tbump(["-C", str(test_repo), "1.2.41a2", "--non-interactive"]) 261 | 262 | 263 | def test_abort_if_file_does_not_exist(test_repo: Path) -> None: 264 | (test_repo / "package.json").unlink() 265 | run_git(test_repo, "add", "--update") 266 | run_git(test_repo, "commit", "--message", "remove package.json") 267 | with pytest.raises(SourceFileNotFound): 268 | run_tbump(["-C", str(test_repo), "1.2.41-alpha-2", "--non-interactive"]) 269 | 270 | 271 | def test_interactive_abort(test_repo: Path, mocker: Any) -> None: 272 | ask_mock = mocker.patch("cli_ui.ask_yes_no") 273 | ask_mock.return_value = False 274 | 275 | with pytest.raises(Error): 276 | run_tbump(["-C", str(test_repo), "1.2.41-alpha-2"]) 277 | 278 | ask_mock.assert_called_with("Looking good?", default=False) 279 | assert file_contains(test_repo / "VERSION", "1.2.41-alpha-1") 280 | rc, out = run_git_captured(test_repo, "tag", "--list") 281 | assert "v1.2.42-alpha-2" not in out 282 | 283 | 284 | def test_abort_if_dirty(test_repo: Path) -> None: 285 | version_path = test_repo / "VERSION" 286 | with version_path.open("a") as f: 287 | f.write("unstaged changes\n") 288 | 289 | with pytest.raises(DirtyRepository): 290 | run_tbump(["-C", str(test_repo), "1.2.41-alpha-2", "--non-interactive"]) 291 | 292 | 293 | def test_abort_if_tag_exists(test_repo: Path) -> None: 294 | run_git(test_repo, "tag", "v1.2.42") 295 | 296 | with pytest.raises(RefAlreadyExists): 297 | run_tbump(["-C", str(test_repo), "1.2.42", "--non-interactive"]) 298 | 299 | 300 | def test_abort_if_file_does_not_contain_current_version(test_repo: Path) -> None: 301 | invalid_src = test_repo / "foo.txt" 302 | invalid_src.write_text("this is foo") 303 | tbump_path = test_repo / "tbump.toml" 304 | with tbump_path.open("a") as f: 305 | f.write( 306 | """\ 307 | [[file]] 308 | src = "foo.txt" 309 | """ 310 | ) 311 | run_git(test_repo, "add", ".") 312 | run_git(test_repo, "commit", "--message", "add foo.txt") 313 | 314 | with pytest.raises(CurrentVersionNotFound): 315 | run_tbump(["-C", str(test_repo), "1.2.42", "--non-interactive"]) 316 | 317 | 318 | def test_no_tracked_branch_but_ref_exists(test_repo: Path) -> None: 319 | run_git(test_repo, "tag", "v1.2.41-alpha-2") 320 | 321 | with pytest.raises(RefAlreadyExists): 322 | run_tbump(["-C", str(test_repo), "1.2.41-alpha-2"]) 323 | 324 | 325 | def test_not_a_new_versions(test_repo: Path) -> None: 326 | with pytest.raises(NotANewVersion): 327 | run_tbump(["-C", str(test_repo), "1.2.41-alpha-1"]) 328 | 329 | 330 | def test_new_version_is_older(test_repo: Path) -> None: 331 | with pytest.raises(OlderNewVersion): 332 | run_tbump(["-C", str(test_repo), "1.2.40"]) 333 | 334 | 335 | def test_no_tracked_branch_non_interactive(test_repo: Path) -> None: 336 | run_git(test_repo, "checkout", "-b", "devel") 337 | 338 | with pytest.raises(NoTrackedBranch): 339 | run_tbump(["-C", str(test_repo), "1.2.42", "--non-interactive"]) 340 | 341 | 342 | def test_interactive_proceed(test_repo: Path, mocker: Any) -> None: 343 | ask_mock = mocker.patch("cli_ui.ask_yes_no") 344 | 345 | ask_mock.return_value = [True] 346 | run_tbump(["-C", str(test_repo), "1.2.42"]) 347 | ask_mock.assert_called_with("Looking good?", default=False) 348 | _, out = run_git_captured(test_repo, "ls-remote") 349 | assert "tags/v1.2.42" in out 350 | 351 | 352 | def test_do_not_add_untracked_files(test_repo: Path) -> None: 353 | (test_repo / "untracked.txt").write_text("please don't add me") 354 | run_tbump(["-C", str(test_repo), "1.2.42", "--non-interactive"]) 355 | _, out = run_git_captured(test_repo, "show", "--stat", "HEAD") 356 | assert "untracked.txt" not in out 357 | 358 | 359 | def test_bad_substitution(test_repo: Path) -> None: 360 | toml_path = test_repo / "tbump.toml" 361 | new_toml = tomlkit.loads(toml_path.read_text()) 362 | new_toml["file"][0]["version_template"] = "{build}" # type: ignore[index] 363 | toml_path.write_text(tomlkit.dumps(new_toml)) 364 | run_git(test_repo, "add", ".") 365 | run_git(test_repo, "commit", "--message", "update repo") 366 | with pytest.raises(BadSubstitution): 367 | run_tbump(["-C", str(test_repo), "1.2.42"]) 368 | 369 | 370 | def test_no_push(test_repo: Path) -> None: 371 | _, previous_commit = run_git_captured(test_repo, "rev-parse", "HEAD") 372 | # We're not supposed to push anything, so we should not even check that the 373 | # current branch tracks something. 374 | run_git(test_repo, "branch", "--unset-upstream") 375 | 376 | run_tbump( 377 | ["-C", str(test_repo), "1.2.41-alpha-2", "--non-interactive", "--no-push"] 378 | ) 379 | 380 | assert commit_created(test_repo) 381 | assert tag_created(test_repo) 382 | assert not branch_pushed(test_repo, previous_commit) 383 | 384 | 385 | def test_no_tag(test_repo: Path) -> None: 386 | _, previous_commit = run_git_captured(test_repo, "rev-parse", "HEAD") 387 | 388 | run_tbump(["-C", str(test_repo), "1.2.41-alpha-2", "--non-interactive", "--no-tag"]) 389 | 390 | assert commit_created(test_repo) 391 | assert not tag_created(test_repo) 392 | 393 | 394 | def test_no_tag_no_push(test_repo: Path) -> None: 395 | _, previous_commit = run_git_captured(test_repo, "rev-parse", "HEAD") 396 | run_git(test_repo, "branch", "--unset-upstream") 397 | 398 | run_tbump( 399 | [ 400 | "-C", 401 | str(test_repo), 402 | "1.2.41-alpha-2", 403 | "--non-interactive", 404 | "--no-tag", 405 | "--no-push", 406 | ] 407 | ) 408 | 409 | assert commit_created(test_repo) 410 | assert not tag_created(test_repo) 411 | 412 | 413 | def test_no_tag_no_push_no_tag_push(test_repo: Path) -> None: 414 | _, previous_commit = run_git_captured(test_repo, "rev-parse", "HEAD") 415 | run_git(test_repo, "branch", "--unset-upstream") 416 | 417 | run_tbump( 418 | [ 419 | "-C", 420 | str(test_repo), 421 | "1.2.41-alpha-2", 422 | "--non-interactive", 423 | "--no-tag-push", 424 | "--no-push", 425 | ] 426 | ) 427 | 428 | assert commit_created(test_repo) 429 | assert tag_created(test_repo) 430 | assert not tag_pushed(test_repo) 431 | 432 | 433 | def test_create_tag_but_do_not_push_it(test_repo: Path) -> None: 434 | _, previous_commit = run_git_captured(test_repo, "rev-parse", "HEAD") 435 | 436 | run_tbump( 437 | [ 438 | "-C", 439 | str(test_repo), 440 | "1.2.41-alpha-2", 441 | "--non-interactive", 442 | "--no-tag-push", 443 | ] 444 | ) 445 | 446 | assert tag_created(test_repo) 447 | assert not tag_pushed(test_repo) 448 | 449 | 450 | def test_tag_message(test_repo: Path, tag_message: Optional[str]) -> None: 451 | args = [ 452 | "-C", 453 | str(test_repo), 454 | "1.2.41-alpha-2", 455 | "--non-interactive", 456 | "--no-tag-push", 457 | ] 458 | 459 | if tag_message: 460 | args.append("--tag-message='{}'".format(tag_message)) 461 | 462 | run_tbump(args) 463 | 464 | assert tag_created(test_repo, tag_message) 465 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "attrs" 5 | version = "24.2.0" 6 | description = "Classes Without Boilerplate" 7 | optional = false 8 | python-versions = ">=3.7" 9 | groups = ["dev"] 10 | files = [ 11 | {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, 12 | {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, 13 | ] 14 | 15 | [package.extras] 16 | benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] 17 | cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] 18 | dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] 19 | docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 20 | tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] 21 | tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] 22 | 23 | [[package]] 24 | name = "black" 25 | version = "24.10.0" 26 | description = "The uncompromising code formatter." 27 | optional = false 28 | python-versions = ">=3.9" 29 | groups = ["dev"] 30 | files = [ 31 | {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, 32 | {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, 33 | {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, 34 | {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, 35 | {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, 36 | {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, 37 | {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, 38 | {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, 39 | {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, 40 | {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, 41 | {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, 42 | {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, 43 | {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, 44 | {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, 45 | {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, 46 | {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, 47 | {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, 48 | {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, 49 | {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, 50 | {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, 51 | {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, 52 | {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, 53 | ] 54 | 55 | [package.dependencies] 56 | click = ">=8.0.0" 57 | mypy-extensions = ">=0.4.3" 58 | packaging = ">=22.0" 59 | pathspec = ">=0.9.0" 60 | platformdirs = ">=2" 61 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 62 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 63 | 64 | [package.extras] 65 | colorama = ["colorama (>=0.4.3)"] 66 | d = ["aiohttp (>=3.10)"] 67 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 68 | uvloop = ["uvloop (>=0.15.2)"] 69 | 70 | [[package]] 71 | name = "cli-ui" 72 | version = "0.18.0" 73 | description = "Build Nice User Interfaces In The Terminal" 74 | optional = false 75 | python-versions = "<4.0.0,>=3.8.1" 76 | groups = ["main"] 77 | files = [ 78 | {file = "cli_ui-0.18.0-py3-none-any.whl", hash = "sha256:8d9484586d8eaba9f94aebaa12aa876fabdf1a3a50bdca113b2cb739eeaf78fa"}, 79 | {file = "cli_ui-0.18.0.tar.gz", hash = "sha256:3e6c80ada5b4b09c6701ca93daf31df8b70486c64348d1fc7f3288ef3bd0479c"}, 80 | ] 81 | 82 | [package.dependencies] 83 | colorama = ">=0.4.1,<0.5.0" 84 | tabulate = ">=0.9.0,<0.10.0" 85 | unidecode = ">=1.3.6,<2.0.0" 86 | 87 | [[package]] 88 | name = "click" 89 | version = "8.1.7" 90 | description = "Composable command line interface toolkit" 91 | optional = false 92 | python-versions = ">=3.7" 93 | groups = ["dev"] 94 | files = [ 95 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 96 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 97 | ] 98 | 99 | [package.dependencies] 100 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 101 | 102 | [[package]] 103 | name = "colorama" 104 | version = "0.4.6" 105 | description = "Cross-platform colored terminal text." 106 | optional = false 107 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 108 | groups = ["main", "dev"] 109 | files = [ 110 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 111 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 112 | ] 113 | markers = {dev = "sys_platform == \"win32\" or platform_system == \"Windows\""} 114 | 115 | [[package]] 116 | name = "coverage" 117 | version = "7.6.9" 118 | description = "Code coverage measurement for Python" 119 | optional = false 120 | python-versions = ">=3.9" 121 | groups = ["dev"] 122 | files = [ 123 | {file = "coverage-7.6.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:85d9636f72e8991a1706b2b55b06c27545448baf9f6dbf51c4004609aacd7dcb"}, 124 | {file = "coverage-7.6.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:608a7fd78c67bee8936378299a6cb9f5149bb80238c7a566fc3e6717a4e68710"}, 125 | {file = "coverage-7.6.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96d636c77af18b5cb664ddf12dab9b15a0cfe9c0bde715da38698c8cea748bfa"}, 126 | {file = "coverage-7.6.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75cded8a3cff93da9edc31446872d2997e327921d8eed86641efafd350e1df1"}, 127 | {file = "coverage-7.6.9-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7b15f589593110ae767ce997775d645b47e5cbbf54fd322f8ebea6277466cec"}, 128 | {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:44349150f6811b44b25574839b39ae35291f6496eb795b7366fef3bd3cf112d3"}, 129 | {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d891c136b5b310d0e702e186d70cd16d1119ea8927347045124cb286b29297e5"}, 130 | {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:db1dab894cc139f67822a92910466531de5ea6034ddfd2b11c0d4c6257168073"}, 131 | {file = "coverage-7.6.9-cp310-cp310-win32.whl", hash = "sha256:41ff7b0da5af71a51b53f501a3bac65fb0ec311ebed1632e58fc6107f03b9198"}, 132 | {file = "coverage-7.6.9-cp310-cp310-win_amd64.whl", hash = "sha256:35371f8438028fdccfaf3570b31d98e8d9eda8bb1d6ab9473f5a390969e98717"}, 133 | {file = "coverage-7.6.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:932fc826442132dde42ee52cf66d941f581c685a6313feebed358411238f60f9"}, 134 | {file = "coverage-7.6.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:085161be5f3b30fd9b3e7b9a8c301f935c8313dcf928a07b116324abea2c1c2c"}, 135 | {file = "coverage-7.6.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc660a77e1c2bf24ddbce969af9447a9474790160cfb23de6be4fa88e3951c7"}, 136 | {file = "coverage-7.6.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c69e42c892c018cd3c8d90da61d845f50a8243062b19d228189b0224150018a9"}, 137 | {file = "coverage-7.6.9-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0824a28ec542a0be22f60c6ac36d679e0e262e5353203bea81d44ee81fe9c6d4"}, 138 | {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4401ae5fc52ad8d26d2a5d8a7428b0f0c72431683f8e63e42e70606374c311a1"}, 139 | {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98caba4476a6c8d59ec1eb00c7dd862ba9beca34085642d46ed503cc2d440d4b"}, 140 | {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ee5defd1733fd6ec08b168bd4f5387d5b322f45ca9e0e6c817ea6c4cd36313e3"}, 141 | {file = "coverage-7.6.9-cp311-cp311-win32.whl", hash = "sha256:f2d1ec60d6d256bdf298cb86b78dd715980828f50c46701abc3b0a2b3f8a0dc0"}, 142 | {file = "coverage-7.6.9-cp311-cp311-win_amd64.whl", hash = "sha256:0d59fd927b1f04de57a2ba0137166d31c1a6dd9e764ad4af552912d70428c92b"}, 143 | {file = "coverage-7.6.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:99e266ae0b5d15f1ca8d278a668df6f51cc4b854513daab5cae695ed7b721cf8"}, 144 | {file = "coverage-7.6.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9901d36492009a0a9b94b20e52ebfc8453bf49bb2b27bca2c9706f8b4f5a554a"}, 145 | {file = "coverage-7.6.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abd3e72dd5b97e3af4246cdada7738ef0e608168de952b837b8dd7e90341f015"}, 146 | {file = "coverage-7.6.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff74026a461eb0660366fb01c650c1d00f833a086b336bdad7ab00cc952072b3"}, 147 | {file = "coverage-7.6.9-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65dad5a248823a4996724a88eb51d4b31587aa7aa428562dbe459c684e5787ae"}, 148 | {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22be16571504c9ccea919fcedb459d5ab20d41172056206eb2994e2ff06118a4"}, 149 | {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f957943bc718b87144ecaee70762bc2bc3f1a7a53c7b861103546d3a403f0a6"}, 150 | {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae1387db4aecb1f485fb70a6c0148c6cdaebb6038f1d40089b1fc84a5db556f"}, 151 | {file = "coverage-7.6.9-cp312-cp312-win32.whl", hash = "sha256:1a330812d9cc7ac2182586f6d41b4d0fadf9be9049f350e0efb275c8ee8eb692"}, 152 | {file = "coverage-7.6.9-cp312-cp312-win_amd64.whl", hash = "sha256:b12c6b18269ca471eedd41c1b6a1065b2f7827508edb9a7ed5555e9a56dcfc97"}, 153 | {file = "coverage-7.6.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:899b8cd4781c400454f2f64f7776a5d87bbd7b3e7f7bda0cb18f857bb1334664"}, 154 | {file = "coverage-7.6.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61f70dc68bd36810972e55bbbe83674ea073dd1dcc121040a08cdf3416c5349c"}, 155 | {file = "coverage-7.6.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a289d23d4c46f1a82d5db4abeb40b9b5be91731ee19a379d15790e53031c014"}, 156 | {file = "coverage-7.6.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e216d8044a356fc0337c7a2a0536d6de07888d7bcda76febcb8adc50bdbbd00"}, 157 | {file = "coverage-7.6.9-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c026eb44f744acaa2bda7493dad903aa5bf5fc4f2554293a798d5606710055d"}, 158 | {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e77363e8425325384f9d49272c54045bbed2f478e9dd698dbc65dbc37860eb0a"}, 159 | {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:777abfab476cf83b5177b84d7486497e034eb9eaea0d746ce0c1268c71652077"}, 160 | {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:447af20e25fdbe16f26e84eb714ba21d98868705cb138252d28bc400381f6ffb"}, 161 | {file = "coverage-7.6.9-cp313-cp313-win32.whl", hash = "sha256:d872ec5aeb086cbea771c573600d47944eea2dcba8be5f3ee649bfe3cb8dc9ba"}, 162 | {file = "coverage-7.6.9-cp313-cp313-win_amd64.whl", hash = "sha256:fd1213c86e48dfdc5a0cc676551db467495a95a662d2396ecd58e719191446e1"}, 163 | {file = "coverage-7.6.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9e7484d286cd5a43744e5f47b0b3fb457865baf07bafc6bee91896364e1419"}, 164 | {file = "coverage-7.6.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1cf0872ee455c03e5674b5bca5e3e68e159379c1af0903e89f5eba9ccc3a"}, 165 | {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d10e07aa2b91835d6abec555ec8b2733347956991901eea6ffac295f83a30e4"}, 166 | {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13a9e2d3ee855db3dd6ea1ba5203316a1b1fd8eaeffc37c5b54987e61e4194ae"}, 167 | {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c38bf15a40ccf5619fa2fe8f26106c7e8e080d7760aeccb3722664c8656b030"}, 168 | {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d5275455b3e4627c8e7154feaf7ee0743c2e7af82f6e3b561967b1cca755a0be"}, 169 | {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8f8770dfc6e2c6a2d4569f411015c8d751c980d17a14b0530da2d7f27ffdd88e"}, 170 | {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8d2dfa71665a29b153a9681edb1c8d9c1ea50dfc2375fb4dac99ea7e21a0bcd9"}, 171 | {file = "coverage-7.6.9-cp313-cp313t-win32.whl", hash = "sha256:5e6b86b5847a016d0fbd31ffe1001b63355ed309651851295315031ea7eb5a9b"}, 172 | {file = "coverage-7.6.9-cp313-cp313t-win_amd64.whl", hash = "sha256:97ddc94d46088304772d21b060041c97fc16bdda13c6c7f9d8fcd8d5ae0d8611"}, 173 | {file = "coverage-7.6.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:adb697c0bd35100dc690de83154627fbab1f4f3c0386df266dded865fc50a902"}, 174 | {file = "coverage-7.6.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:be57b6d56e49c2739cdf776839a92330e933dd5e5d929966fbbd380c77f060be"}, 175 | {file = "coverage-7.6.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1592791f8204ae9166de22ba7e6705fa4ebd02936c09436a1bb85aabca3e599"}, 176 | {file = "coverage-7.6.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e12ae8cc979cf83d258acb5e1f1cf2f3f83524d1564a49d20b8bec14b637f08"}, 177 | {file = "coverage-7.6.9-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb5555cff66c4d3d6213a296b360f9e1a8e323e74e0426b6c10ed7f4d021e464"}, 178 | {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b9389a429e0e5142e69d5bf4a435dd688c14478a19bb901735cdf75e57b13845"}, 179 | {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:592ac539812e9b46046620341498caf09ca21023c41c893e1eb9dbda00a70cbf"}, 180 | {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a27801adef24cc30871da98a105f77995e13a25a505a0161911f6aafbd66e678"}, 181 | {file = "coverage-7.6.9-cp39-cp39-win32.whl", hash = "sha256:8e3c3e38930cfb729cb8137d7f055e5a473ddaf1217966aa6238c88bd9fd50e6"}, 182 | {file = "coverage-7.6.9-cp39-cp39-win_amd64.whl", hash = "sha256:e28bf44afa2b187cc9f41749138a64435bf340adfcacb5b2290c070ce99839d4"}, 183 | {file = "coverage-7.6.9-pp39.pp310-none-any.whl", hash = "sha256:f3ca78518bc6bc92828cd11867b121891d75cae4ea9e908d72030609b996db1b"}, 184 | {file = "coverage-7.6.9.tar.gz", hash = "sha256:4a8d8977b0c6ef5aeadcb644da9e69ae0dcfe66ec7f368c89c72e058bd71164d"}, 185 | ] 186 | 187 | [package.dependencies] 188 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 189 | 190 | [package.extras] 191 | toml = ["tomli ; python_full_version <= \"3.11.0a6\""] 192 | 193 | [[package]] 194 | name = "docopt-ng" 195 | version = "0.9.0" 196 | description = "Jazzband-maintained fork of docopt, the humane command line arguments parser." 197 | optional = false 198 | python-versions = ">=3.7" 199 | groups = ["main"] 200 | files = [ 201 | {file = "docopt_ng-0.9.0-py3-none-any.whl", hash = "sha256:bfe4c8b03f9fca424c24ee0b4ffa84bf7391cb18c29ce0f6a8227a3b01b81ff9"}, 202 | {file = "docopt_ng-0.9.0.tar.gz", hash = "sha256:91c6da10b5bb6f2e9e25345829fb8278c78af019f6fc40887ad49b060483b1d7"}, 203 | ] 204 | 205 | [[package]] 206 | name = "exceptiongroup" 207 | version = "1.2.2" 208 | description = "Backport of PEP 654 (exception groups)" 209 | optional = false 210 | python-versions = ">=3.7" 211 | groups = ["dev"] 212 | markers = "python_version < \"3.11\"" 213 | files = [ 214 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 215 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 216 | ] 217 | 218 | [package.extras] 219 | test = ["pytest (>=6)"] 220 | 221 | [[package]] 222 | name = "flake8" 223 | version = "7.1.0" 224 | description = "the modular source code checker: pep8 pyflakes and co" 225 | optional = false 226 | python-versions = ">=3.8.1" 227 | groups = ["dev"] 228 | files = [ 229 | {file = "flake8-7.1.0-py2.py3-none-any.whl", hash = "sha256:2e416edcc62471a64cea09353f4e7bdba32aeb079b6e360554c659a122b1bc6a"}, 230 | {file = "flake8-7.1.0.tar.gz", hash = "sha256:48a07b626b55236e0fb4784ee69a465fbf59d79eec1f5b4785c3d3bc57d17aa5"}, 231 | ] 232 | 233 | [package.dependencies] 234 | mccabe = ">=0.7.0,<0.8.0" 235 | pycodestyle = ">=2.12.0,<2.13.0" 236 | pyflakes = ">=3.2.0,<3.3.0" 237 | 238 | [[package]] 239 | name = "flake8-bugbear" 240 | version = "24.12.12" 241 | description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." 242 | optional = false 243 | python-versions = ">=3.8.1" 244 | groups = ["dev"] 245 | files = [ 246 | {file = "flake8_bugbear-24.12.12-py3-none-any.whl", hash = "sha256:1b6967436f65ca22a42e5373aaa6f2d87966ade9aa38d4baf2a1be550767545e"}, 247 | {file = "flake8_bugbear-24.12.12.tar.gz", hash = "sha256:46273cef0a6b6ff48ca2d69e472f41420a42a46e24b2a8972e4f0d6733d12a64"}, 248 | ] 249 | 250 | [package.dependencies] 251 | attrs = ">=22.2.0" 252 | flake8 = ">=6.0.0" 253 | 254 | [package.extras] 255 | dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit", "pytest", "tox"] 256 | 257 | [[package]] 258 | name = "flake8-comprehensions" 259 | version = "3.16.0" 260 | description = "A flake8 plugin to help you write better list/set/dict comprehensions." 261 | optional = false 262 | python-versions = ">=3.9" 263 | groups = ["dev"] 264 | files = [ 265 | {file = "flake8_comprehensions-3.16.0-py3-none-any.whl", hash = "sha256:7c1eadc9d22e765f39857798febe7766b4d9c519793c6c149e3e13bf99693f70"}, 266 | {file = "flake8_comprehensions-3.16.0.tar.gz", hash = "sha256:9cbf789905a8f03f9d350fb82b17b264d9a16c7ce3542b2a7b871ef568cafabe"}, 267 | ] 268 | 269 | [package.dependencies] 270 | flake8 = ">=3,<3.2 || >3.2" 271 | 272 | [[package]] 273 | name = "iniconfig" 274 | version = "2.0.0" 275 | description = "brain-dead simple config-ini parsing" 276 | optional = false 277 | python-versions = ">=3.7" 278 | groups = ["dev"] 279 | files = [ 280 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 281 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 282 | ] 283 | 284 | [[package]] 285 | name = "invoke" 286 | version = "2.2.0" 287 | description = "Pythonic task execution" 288 | optional = false 289 | python-versions = ">=3.6" 290 | groups = ["dev"] 291 | files = [ 292 | {file = "invoke-2.2.0-py3-none-any.whl", hash = "sha256:6ea924cc53d4f78e3d98bc436b08069a03077e6f85ad1ddaa8a116d7dad15820"}, 293 | {file = "invoke-2.2.0.tar.gz", hash = "sha256:ee6cbb101af1a859c7fe84f2a264c059020b0cb7fe3535f9424300ab568f6bd5"}, 294 | ] 295 | 296 | [[package]] 297 | name = "isort" 298 | version = "5.13.2" 299 | description = "A Python utility / library to sort Python imports." 300 | optional = false 301 | python-versions = ">=3.8.0" 302 | groups = ["dev"] 303 | files = [ 304 | {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, 305 | {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, 306 | ] 307 | 308 | [package.extras] 309 | colors = ["colorama (>=0.4.6)"] 310 | 311 | [[package]] 312 | name = "mccabe" 313 | version = "0.7.0" 314 | description = "McCabe checker, plugin for flake8" 315 | optional = false 316 | python-versions = ">=3.6" 317 | groups = ["dev"] 318 | files = [ 319 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 320 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 321 | ] 322 | 323 | [[package]] 324 | name = "mypy" 325 | version = "1.13.0" 326 | description = "Optional static typing for Python" 327 | optional = false 328 | python-versions = ">=3.8" 329 | groups = ["dev"] 330 | files = [ 331 | {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, 332 | {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, 333 | {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, 334 | {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, 335 | {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, 336 | {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, 337 | {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, 338 | {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, 339 | {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, 340 | {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, 341 | {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, 342 | {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, 343 | {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, 344 | {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, 345 | {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, 346 | {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, 347 | {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, 348 | {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, 349 | {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, 350 | {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, 351 | {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, 352 | {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, 353 | {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, 354 | {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, 355 | {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, 356 | {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, 357 | {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, 358 | {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, 359 | {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, 360 | {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, 361 | {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, 362 | {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, 363 | ] 364 | 365 | [package.dependencies] 366 | mypy-extensions = ">=1.0.0" 367 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 368 | typing-extensions = ">=4.6.0" 369 | 370 | [package.extras] 371 | dmypy = ["psutil (>=4.0)"] 372 | faster-cache = ["orjson"] 373 | install-types = ["pip"] 374 | mypyc = ["setuptools (>=50)"] 375 | reports = ["lxml"] 376 | 377 | [[package]] 378 | name = "mypy-extensions" 379 | version = "1.0.0" 380 | description = "Type system extensions for programs checked with the mypy type checker." 381 | optional = false 382 | python-versions = ">=3.5" 383 | groups = ["dev"] 384 | files = [ 385 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 386 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 387 | ] 388 | 389 | [[package]] 390 | name = "packaging" 391 | version = "24.2" 392 | description = "Core utilities for Python packages" 393 | optional = false 394 | python-versions = ">=3.8" 395 | groups = ["main", "dev"] 396 | files = [ 397 | {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, 398 | {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, 399 | ] 400 | 401 | [[package]] 402 | name = "pathspec" 403 | version = "0.12.1" 404 | description = "Utility library for gitignore style pattern matching of file paths." 405 | optional = false 406 | python-versions = ">=3.8" 407 | groups = ["dev"] 408 | files = [ 409 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 410 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 411 | ] 412 | 413 | [[package]] 414 | name = "pep8-naming" 415 | version = "0.14.1" 416 | description = "Check PEP-8 naming conventions, plugin for flake8" 417 | optional = false 418 | python-versions = ">=3.8" 419 | groups = ["dev"] 420 | files = [ 421 | {file = "pep8-naming-0.14.1.tar.gz", hash = "sha256:1ef228ae80875557eb6c1549deafed4dabbf3261cfcafa12f773fe0db9be8a36"}, 422 | {file = "pep8_naming-0.14.1-py3-none-any.whl", hash = "sha256:63f514fc777d715f935faf185dedd679ab99526a7f2f503abb61587877f7b1c5"}, 423 | ] 424 | 425 | [package.dependencies] 426 | flake8 = ">=5.0.0" 427 | 428 | [[package]] 429 | name = "platformdirs" 430 | version = "4.3.6" 431 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 432 | optional = false 433 | python-versions = ">=3.8" 434 | groups = ["dev"] 435 | files = [ 436 | {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, 437 | {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, 438 | ] 439 | 440 | [package.extras] 441 | docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] 442 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] 443 | type = ["mypy (>=1.11.2)"] 444 | 445 | [[package]] 446 | name = "pluggy" 447 | version = "1.5.0" 448 | description = "plugin and hook calling mechanisms for python" 449 | optional = false 450 | python-versions = ">=3.8" 451 | groups = ["dev"] 452 | files = [ 453 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 454 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 455 | ] 456 | 457 | [package.extras] 458 | dev = ["pre-commit", "tox"] 459 | testing = ["pytest", "pytest-benchmark"] 460 | 461 | [[package]] 462 | name = "pycodestyle" 463 | version = "2.12.1" 464 | description = "Python style guide checker" 465 | optional = false 466 | python-versions = ">=3.8" 467 | groups = ["dev"] 468 | files = [ 469 | {file = "pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3"}, 470 | {file = "pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521"}, 471 | ] 472 | 473 | [[package]] 474 | name = "pyflakes" 475 | version = "3.2.0" 476 | description = "passive checker of Python programs" 477 | optional = false 478 | python-versions = ">=3.8" 479 | groups = ["dev"] 480 | files = [ 481 | {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, 482 | {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, 483 | ] 484 | 485 | [[package]] 486 | name = "pytest" 487 | version = "8.3.4" 488 | description = "pytest: simple powerful testing with Python" 489 | optional = false 490 | python-versions = ">=3.8" 491 | groups = ["dev"] 492 | files = [ 493 | {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, 494 | {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, 495 | ] 496 | 497 | [package.dependencies] 498 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 499 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 500 | iniconfig = "*" 501 | packaging = "*" 502 | pluggy = ">=1.5,<2" 503 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 504 | 505 | [package.extras] 506 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 507 | 508 | [[package]] 509 | name = "pytest-cov" 510 | version = "6.0.0" 511 | description = "Pytest plugin for measuring coverage." 512 | optional = false 513 | python-versions = ">=3.9" 514 | groups = ["dev"] 515 | files = [ 516 | {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, 517 | {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, 518 | ] 519 | 520 | [package.dependencies] 521 | coverage = {version = ">=7.5", extras = ["toml"]} 522 | pytest = ">=4.6" 523 | 524 | [package.extras] 525 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] 526 | 527 | [[package]] 528 | name = "pytest-mock" 529 | version = "3.14.0" 530 | description = "Thin-wrapper around the mock package for easier use with pytest" 531 | optional = false 532 | python-versions = ">=3.8" 533 | groups = ["dev"] 534 | files = [ 535 | {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, 536 | {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, 537 | ] 538 | 539 | [package.dependencies] 540 | pytest = ">=6.2.5" 541 | 542 | [package.extras] 543 | dev = ["pre-commit", "pytest-asyncio", "tox"] 544 | 545 | [[package]] 546 | name = "schema" 547 | version = "0.7.7" 548 | description = "Simple data validation library" 549 | optional = false 550 | python-versions = "*" 551 | groups = ["main"] 552 | files = [ 553 | {file = "schema-0.7.7-py2.py3-none-any.whl", hash = "sha256:5d976a5b50f36e74e2157b47097b60002bd4d42e65425fcc9c9befadb4255dde"}, 554 | {file = "schema-0.7.7.tar.gz", hash = "sha256:7da553abd2958a19dc2547c388cde53398b39196175a9be59ea1caf5ab0a1807"}, 555 | ] 556 | 557 | [[package]] 558 | name = "tabulate" 559 | version = "0.9.0" 560 | description = "Pretty-print tabular data" 561 | optional = false 562 | python-versions = ">=3.7" 563 | groups = ["main"] 564 | files = [ 565 | {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"}, 566 | {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"}, 567 | ] 568 | 569 | [package.extras] 570 | widechars = ["wcwidth"] 571 | 572 | [[package]] 573 | name = "tomli" 574 | version = "2.2.1" 575 | description = "A lil' TOML parser" 576 | optional = false 577 | python-versions = ">=3.8" 578 | groups = ["dev"] 579 | markers = "python_full_version <= \"3.11.0a6\"" 580 | files = [ 581 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, 582 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, 583 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, 584 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, 585 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, 586 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, 587 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, 588 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, 589 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, 590 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, 591 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, 592 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, 593 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, 594 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, 595 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, 596 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, 597 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, 598 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, 599 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, 600 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, 601 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, 602 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, 603 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, 604 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, 605 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, 606 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, 607 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, 608 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, 609 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, 610 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, 611 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, 612 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, 613 | ] 614 | 615 | [[package]] 616 | name = "tomlkit" 617 | version = "0.11.8" 618 | description = "Style preserving TOML library" 619 | optional = false 620 | python-versions = ">=3.7" 621 | groups = ["main"] 622 | files = [ 623 | {file = "tomlkit-0.11.8-py3-none-any.whl", hash = "sha256:8c726c4c202bdb148667835f68d68780b9a003a9ec34167b6c673b38eff2a171"}, 624 | {file = "tomlkit-0.11.8.tar.gz", hash = "sha256:9330fc7faa1db67b541b28e62018c17d20be733177d290a13b24c62d1614e0c3"}, 625 | ] 626 | 627 | [[package]] 628 | name = "typing-extensions" 629 | version = "4.12.2" 630 | description = "Backported and Experimental Type Hints for Python 3.8+" 631 | optional = false 632 | python-versions = ">=3.8" 633 | groups = ["dev"] 634 | files = [ 635 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 636 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 637 | ] 638 | 639 | [[package]] 640 | name = "unidecode" 641 | version = "1.3.8" 642 | description = "ASCII transliterations of Unicode text" 643 | optional = false 644 | python-versions = ">=3.5" 645 | groups = ["main"] 646 | files = [ 647 | {file = "Unidecode-1.3.8-py3-none-any.whl", hash = "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39"}, 648 | {file = "Unidecode-1.3.8.tar.gz", hash = "sha256:cfdb349d46ed3873ece4586b96aa75258726e2fa8ec21d6f00a591d98806c2f4"}, 649 | ] 650 | 651 | [metadata] 652 | lock-version = "2.1" 653 | python-versions = "^3.9" 654 | content-hash = "bb92e29141ee4c4fd7a860865c256d814d03aa1c6f22c1835b2630d92a3292e6" 655 | --------------------------------------------------------------------------------