Skip to content

Commit

Permalink
Add initial config parsing & validation
Browse files Browse the repository at this point in the history
  • Loading branch information
sco1 committed Oct 8, 2024
1 parent 2fe508f commit 8a33f9b
Show file tree
Hide file tree
Showing 16 changed files with 458 additions and 14 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/lint_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13-dev"]
python-version: ["3.11", "3.12", "3.13-dev"]
fail-fast: false

env:
Expand Down
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,50 @@
# bumper
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/sco1-bumper/0.1.0?logo=python&logoColor=FFD43B)](https://pypi.org/project/sco1-bumper/)
[![PyPI](https://img.shields.io/pypi/v/sco1-bumper?logo=Python&logoColor=FFD43B)](https://pypi.org/project/sco1-bumper/)
[![PyPI - License](https://img.shields.io/pypi/l/bumper?color=magenta)](https://github.com/sco1/bumper/blob/main/LICENSE)
[![PyPI - License](https://img.shields.io/pypi/l/sco1-bumper?color=magenta)](https://github.com/sco1/bumper/blob/main/LICENSE)
[![pre-commit.ci status](https://results.pre-commit.ci/badge/github/sco1/bumper/main.svg)](https://results.pre-commit.ci/latest/github/sco1/bumper/main)

Automatically increment the project's version number.

Heavily inspired by [`bump2version`](https://github.com/c4urself/bump2version) and [`bumpversion`](https://github.com/peritus/bumpversion). While [`bump-my-version`](https://github.com/callowayproject/bump-my-version) is an excellent modern fork this functionality, I'd like a pared down version of the offered feature set for my personal projects.

## Configuration
`bumper` searches for its configuration options first in a `.bumper.toml` file, then in `pyproject.toml`; preference is given to whichever configuration is located first.
### Required Fields
#### `tool.bumper`
* `current_version` - The current software version. This is automatically incremented when bumping.

#### `tool.bumper.files`
* `file` - Path to target file relative to the repository root
* `search` - Replacement string to search for in the target file. Must contain a `{current_version}` tag if you want something to happen.

### Example Configuration
The basic configuration looks something like the following:

```toml
[tool.bumper]
current_version = "0.1.0"

[[tool.bumper.files]]
file = "./pyproject.toml"
search = 'version = "{current_version}"'
```

Multiple replacements within the same file can also be specified:

```toml
[tool.bumper]
current_version = "0.1.0"

[[tool.bumper.files]]
file = "./pyproject.toml"
search = 'version = "{current_version}"'

[[tool.bumper.files]]
file = "./README.md"
search = "sco1-bumper/{current_version}"

[[tool.bumper.files]]
file = "./README.md"
search = "rev: v{current_version}"
```
1 change: 1 addition & 0 deletions bumper/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CONFIG_PRIORITY = (".bumper.toml", "pyproject.toml")
Empty file added bumper/bump.py
Empty file.
2 changes: 1 addition & 1 deletion bumper/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
def main() -> None:
def main() -> None: # noqa: D103
raise NotImplementedError


Expand Down
82 changes: 82 additions & 0 deletions bumper/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from __future__ import annotations

import tomllib
import typing as t
from pathlib import Path

from packaging import version

BUMPER_REQUIRED_FIELDS = ("current_version",)
REPLACEMENT_REQUIRED_FIELDS = ("file", "search")


class BumperConfigError(Exception): ... # noqa: D101


class BumperFile(t.NamedTuple): # noqa: D101
file: Path
search: str

@classmethod
def from_toml(cls, file: str, search: str) -> BumperFile: # noqa: D102
return cls(file=Path(file), search=search)


def _validate_config(cfg: dict) -> None:
"""
Validate the provided parsed TOML output.
The provided TOML is assumed to contain something like the following:
```toml
[tool.bumper]
current_version = "0.1.0"
[[tool.bumper.files]]
file = "./pyproject.toml"
search = 'version = "{current_version}"'
```
Raises `BumperConfigError` if any of the required information is missing.
"""
if "tool" not in cfg:
raise BumperConfigError("Configuration file does not declare any tools")

if "bumper" not in cfg["tool"]:
raise BumperConfigError("Configuration does not declare any bumper configuration")

for rf in BUMPER_REQUIRED_FIELDS:
if rf not in cfg["tool"]["bumper"]:
raise BumperConfigError(f"Bumper tool declaration missing required field: '{rf}'")

if "files" not in cfg["tool"]["bumper"]:
raise BumperConfigError("Configuration does not declare any file replacements")

for file in cfg["tool"]["bumper"]["files"]:
for rf in REPLACEMENT_REQUIRED_FIELDS:
if rf not in file:
raise BumperConfigError(
f"File replacement declaration missing required field: '{rf}'"
)


PARSED_T: t.TypeAlias = tuple[version.Version, list[BumperFile]]


def parse_config(cfg_path: Path) -> PARSED_T:
"""
Parse the provided configuration file for its relevant information.
Incoming information relevant to bumper is validated & extracted for downstream use.
"""
if not cfg_path.exists():
raise ValueError(f"Configuration file does not exist: '{cfg_path}'")

with cfg_path.open("rb") as f:
loaded = tomllib.load(f)

_validate_config(loaded)
current_version = version.parse(loaded["tool"]["bumper"]["current_version"])
files = [BumperFile.from_toml(**f) for f in loaded["tool"]["bumper"]["files"]]

return current_version, files
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ classifiers = [
]

requires-python = ">=3.11"
dependencies = []
dependencies = [
"packaging~=24.1",
]

[project.urls]
Homepage = "https://github.com/sco1/"
Expand Down
3 changes: 3 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from pathlib import Path

TEST_DATA_DIR = Path(__file__).parent / "test_data"
103 changes: 103 additions & 0 deletions tests/test_config_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import tomllib
from pathlib import Path

import pytest
from packaging import version

from bumper.config import BumperConfigError, BumperFile, PARSED_T, _validate_config, parse_config
from tests import TEST_DATA_DIR

TOML_NO_TOOLS = """\
[project]
name = "sco1-bumper"
version = "0.1.0"
"""


def test_config_validation_no_tool_raises() -> None:
cfg = tomllib.loads(TOML_NO_TOOLS)
with pytest.raises(BumperConfigError, match="tools"):
_validate_config(cfg)


TOML_NO_BUMPER = """\
[tool.black]
line-length = 100
"""


def test_config_validation_no_bumper_raises() -> None:
cfg = tomllib.loads(TOML_NO_BUMPER)
with pytest.raises(BumperConfigError, match="bumper configuration"):
_validate_config(cfg)


TOML_MISSING_BUMPER_INFO = """\
[tool.bumper]
hello_world = "hi"
"""


def test_config_validation_missing_bumper_info_raises() -> None:
cfg = tomllib.loads(TOML_MISSING_BUMPER_INFO)
with pytest.raises(BumperConfigError, match="current_version"):
_validate_config(cfg)


TOML_MISSING_FILES = """\
[tool.bumper]
current_version = "0.1.0"
"""


def test_config_validation_no_files_raises() -> None:
cfg = tomllib.loads(TOML_MISSING_FILES)
with pytest.raises(BumperConfigError, match="any file replacements"):
_validate_config(cfg)


TOML_MISSING_FILE_INFO = """\
[tool.bumper]
current_version = "0.1.0"
[[tool.bumper.files]]
file = "./pyproject.toml"
"""


def test_config_validation_missing_file_info_raises() -> None:
cfg = tomllib.loads(TOML_MISSING_FILE_INFO)
with pytest.raises(BumperConfigError, match="search"):
_validate_config(cfg)


def test_nonexistent_config_raises() -> None:
with pytest.raises(ValueError, match="does not exist"):
parse_config(Path("ooga.booga"))


TRUTH_SINGLE_REPLACE = (
version.Version("0.1.0"),
[BumperFile(file=Path("./pyproject.toml"), search='version = "{current_version}"')],
)

TRUTH_MULTI_REPLACE = (
version.Version("0.1.0"),
[
BumperFile(file=Path("./pyproject.toml"), search='version = "{current_version}"'),
BumperFile(file=Path("./README.md"), search="sco1-bumper/{current_version}"),
BumperFile(file=Path("./README.md"), search="rev: v{current_version}"),
],
)

CONFIG_PARSER_TEST_CASES = (
(TEST_DATA_DIR / "sample_config.toml", TRUTH_SINGLE_REPLACE),
(TEST_DATA_DIR / "sample_pyproject.toml", TRUTH_SINGLE_REPLACE),
(TEST_DATA_DIR / "sample_config_multi_replace.toml", TRUTH_MULTI_REPLACE),
(TEST_DATA_DIR / "sample_pyproject_multi_replace.toml", TRUTH_MULTI_REPLACE),
)


@pytest.mark.parametrize(("cfg_path", "truth_parsed"), CONFIG_PARSER_TEST_CASES)
def test_config_parse(cfg_path: Path, truth_parsed: PARSED_T) -> None:
assert parse_config(cfg_path) == truth_parsed
6 changes: 6 additions & 0 deletions tests/test_data/sample_config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[tool.bumper]
current_version = "0.1.0"

[[tool.bumper.files]]
file = "./pyproject.toml"
search = 'version = "{current_version}"'
14 changes: 14 additions & 0 deletions tests/test_data/sample_config_multi_replace.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[tool.bumper]
current_version = "0.1.0"

[[tool.bumper.files]]
file = "./pyproject.toml"
search = 'version = "{current_version}"'

[[tool.bumper.files]]
file = "./README.md"
search = "sco1-bumper/{current_version}"

[[tool.bumper.files]]
file = "./README.md"
search = "rev: v{current_version}"
93 changes: 93 additions & 0 deletions tests/test_data/sample_pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
[project]
name = "sco1-bumper"
version = "0.1.0"
description = "Automatically increment the project's version number."
authors = [
{name = "sco1", email = "[email protected]"}
]
maintainers = [
{name = "sco1", email = "[email protected]"}
]

readme = "README.md"
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
]

requires-python = ">=3.11"
dependencies = [
"packaging~=24.1",
]

[project.urls]
Homepage = "https://github.com/sco1/"
Documentation = "https://github.com/sco1/bumper/blob/main/README.md"
Repository = "https://github.com/sco1/bumper"
Issues = "https://github.com/sco1/bumper/issues"
Changelog = "https://github.com/sco1/bumper/blob/main/CHANGELOG.md"

[project.scripts]
bumper = "bumper.cli:main"

[tool.uv]
dev-dependencies = [
"black~=24.10",
"flake8~=7.1",
"flake8-annotations~=3.1",
"isort~=5.13",
"mypy~=1.11",
"pre-commit~=4.0",
"pytest~=8.3",
"pytest-check~=2.4",
"pytest-cov~=5.0",
"pytest-randomly~=3.15",
"ruff~=0.6",
"tox~=4.18",
"tox-uv~=1.11",
]

[tool.black]
line-length = 100

[tool.isort]
case_sensitive = true
known_first_party = "bumper,tests"
no_lines_before = "LOCALFOLDER"
order_by_type = false
profile = "black"
line_length = 100

[tool.mypy]
disallow_incomplete_defs = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
ignore_missing_imports = true
no_implicit_optional = true
show_error_codes = true
warn_redundant_casts = true
warn_return_any = true
warn_unused_configs = true
warn_unused_ignores = true

[tool.bumper]
current_version = "0.1.0"

[[tool.bumper.files]]
file = "./pyproject.toml"
search = 'version = "{current_version}"'

[tool.hatch.build.targets.wheel]
packages = ["bumper/"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Loading

0 comments on commit 8a33f9b

Please sign in to comment.