commit-and-tag-version 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. commit_and_tag_version/__init__.py +68 -0
  2. commit_and_tag_version/checkpoint.py +18 -0
  3. commit_and_tag_version/cli.py +76 -0
  4. commit_and_tag_version/commit_parser.py +77 -0
  5. commit_and_tag_version/config.py +109 -0
  6. commit_and_tag_version/defaults.py +68 -0
  7. commit_and_tag_version/format_commit_message.py +2 -0
  8. commit_and_tag_version/git.py +115 -0
  9. commit_and_tag_version/lifecycles/__init__.py +0 -0
  10. commit_and_tag_version/lifecycles/bump.py +179 -0
  11. commit_and_tag_version/lifecycles/changelog.py +149 -0
  12. commit_and_tag_version/lifecycles/commit.py +43 -0
  13. commit_and_tag_version/lifecycles/tag.py +36 -0
  14. commit_and_tag_version/models.py +71 -0
  15. commit_and_tag_version/run_lifecycle_script.py +30 -0
  16. commit_and_tag_version/updaters/__init__.py +80 -0
  17. commit_and_tag_version/updaters/base.py +8 -0
  18. commit_and_tag_version/updaters/csproj.py +19 -0
  19. commit_and_tag_version/updaters/gradle.py +19 -0
  20. commit_and_tag_version/updaters/json_updater.py +44 -0
  21. commit_and_tag_version/updaters/maven.py +36 -0
  22. commit_and_tag_version/updaters/openapi.py +19 -0
  23. commit_and_tag_version/updaters/plain_text.py +9 -0
  24. commit_and_tag_version/updaters/python_updater.py +25 -0
  25. commit_and_tag_version/updaters/yaml_updater.py +19 -0
  26. commit_and_tag_version/write_file.py +7 -0
  27. commit_and_tag_version-0.0.1.dist-info/METADATA +349 -0
  28. commit_and_tag_version-0.0.1.dist-info/RECORD +31 -0
  29. commit_and_tag_version-0.0.1.dist-info/WHEEL +4 -0
  30. commit_and_tag_version-0.0.1.dist-info/entry_points.txt +2 -0
  31. commit_and_tag_version-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,68 @@
1
+ """Versioning using semver and CHANGELOG generation powered by Conventional Commits."""
2
+
3
+ from pathlib import Path
4
+
5
+ from commit_and_tag_version.checkpoint import print_error
6
+ from commit_and_tag_version.git import get_latest_semver_tag
7
+ from commit_and_tag_version.lifecycles.bump import bump
8
+ from commit_and_tag_version.lifecycles.changelog import (
9
+ START_OF_LAST_RELEASE_PATTERN,
10
+ changelog,
11
+ )
12
+ from commit_and_tag_version.lifecycles.commit import commit
13
+ from commit_and_tag_version.lifecycles.tag import tag
14
+ from commit_and_tag_version.models import Config
15
+ from commit_and_tag_version.updaters import resolve_updater_object
16
+
17
+
18
+ def commit_and_tag_version(config: Config) -> None:
19
+ if config.header and START_OF_LAST_RELEASE_PATTERN.search(config.header):
20
+ raise ValueError(
21
+ f"custom changelog header must not match {START_OF_LAST_RELEASE_PATTERN.pattern}"
22
+ )
23
+
24
+ pkg = None
25
+ version = None
26
+
27
+ for package_file in config.package_files:
28
+ updater_obj = resolve_updater_object(package_file)
29
+ if not updater_obj:
30
+ continue
31
+ pkg_path = Path.cwd() / updater_obj["filename"]
32
+ try:
33
+ contents = pkg_path.read_text(encoding="utf-8")
34
+ updater = updater_obj["updater"]
35
+ pkg = {
36
+ "version": updater.read_version(contents),
37
+ "private": (
38
+ updater.is_private(contents) if hasattr(updater, "is_private") else False
39
+ ),
40
+ }
41
+ version = pkg["version"]
42
+ break
43
+ except (FileNotFoundError, KeyError, ValueError):
44
+ continue
45
+
46
+ all_skipped = (
47
+ config.skip.bump and config.skip.changelog and config.skip.commit and config.skip.tag
48
+ )
49
+
50
+ if version is None:
51
+ if all_skipped:
52
+ return
53
+ if config.git_tag_fallback:
54
+ version = get_latest_semver_tag(config.tag_prefix)
55
+ else:
56
+ raise RuntimeError("no package file found")
57
+
58
+ if all_skipped:
59
+ return
60
+
61
+ try:
62
+ new_version = bump(config, version) if not config.skip.bump else version
63
+ changelog(config, new_version)
64
+ commit(config, new_version)
65
+ tag(new_version, pkg["private"] if pkg else False, config)
66
+ except Exception as err:
67
+ print_error(str(err), silent=config.silent)
68
+ raise
@@ -0,0 +1,18 @@
1
+ import sys
2
+
3
+
4
+ def checkpoint(msg: str, args: list[str], silent: bool = False, dry_run: bool = False) -> None:
5
+ if silent:
6
+ return
7
+ figure = "\u2713" if not dry_run else "\u2299"
8
+ formatted_args = [f"\033[1m{a}\033[0m" for a in args]
9
+ formatted_msg = msg
10
+ for arg in formatted_args:
11
+ formatted_msg = formatted_msg.replace("%s", arg, 1)
12
+ print(f" {figure} {formatted_msg}", file=sys.stderr)
13
+
14
+
15
+ def print_error(msg: str, silent: bool = False) -> None:
16
+ if silent:
17
+ return
18
+ print(f"\033[31m{msg}\033[0m", file=sys.stderr)
@@ -0,0 +1,76 @@
1
+ import sys
2
+
3
+ import click
4
+
5
+ from commit_and_tag_version import commit_and_tag_version
6
+ from commit_and_tag_version.config import load_config
7
+
8
+
9
+ @click.command(name="commit-and-tag-version")
10
+ @click.option(
11
+ "--release-as", "-r", type=str, default=None, help="Specify release type or exact version"
12
+ )
13
+ @click.option(
14
+ "--prerelease",
15
+ "-p",
16
+ type=str,
17
+ default=None,
18
+ is_flag=False,
19
+ flag_value="",
20
+ help="Make a prerelease with optional tag id",
21
+ )
22
+ @click.option(
23
+ "--first-release", "-f", is_flag=True, default=False, help="Is this the first release?"
24
+ )
25
+ @click.option("--sign", "-s", is_flag=True, default=False, help="GPG sign commits and tags")
26
+ @click.option("--signoff", is_flag=True, default=False, help="Add Signed-off-by trailer")
27
+ @click.option("--no-verify", "-n", is_flag=True, default=False, help="Bypass git hooks")
28
+ @click.option("--commit-all", "-a", is_flag=True, default=False, help="Commit all staged changes")
29
+ @click.option("--dry-run", is_flag=True, default=False, help="Simulate without making changes")
30
+ @click.option("--silent", is_flag=True, default=False, help="Suppress output")
31
+ @click.option("--tag-prefix", "-t", type=str, default=None, help="Tag prefix (default: v)")
32
+ @click.option("--tag-force", is_flag=True, default=False, help="Replace existing tag")
33
+ @click.option("--config", "-c", type=click.Path(), default=None, help="Custom config file path")
34
+ @click.option("--infile", "-i", type=str, default=None, help="Changelog file path")
35
+ @click.option("--release-count", type=int, default=None, help="Number of releases in changelog")
36
+ @click.option("--header", type=str, default=None, help="Custom changelog header")
37
+ @click.option(
38
+ "--release-commit-message-format", type=str, default=None, help="Commit message format"
39
+ )
40
+ @click.option("--commit-url-format", type=str, default=None, help="Commit URL format template")
41
+ @click.option("--compare-url-format", type=str, default=None, help="Compare URL format template")
42
+ @click.option("--issue-url-format", type=str, default=None, help="Issue URL format template")
43
+ @click.option(
44
+ "--git-tag-fallback/--no-git-tag-fallback",
45
+ default=None,
46
+ help="Fallback to git tags for version",
47
+ )
48
+ @click.option("--path", type=str, default=None, help="Only populate commits under this path")
49
+ @click.option("--skip-bump", is_flag=True, default=False, help="Skip version bump")
50
+ @click.option("--skip-changelog", is_flag=True, default=False, help="Skip changelog generation")
51
+ @click.option("--skip-commit", is_flag=True, default=False, help="Skip git commit")
52
+ @click.option("--skip-tag", is_flag=True, default=False, help="Skip git tag")
53
+ @click.version_option()
54
+ def main(**kwargs):
55
+ """Versioning using semver and CHANGELOG generation powered by Conventional Commits."""
56
+ cli_args = {k: v for k, v in kwargs.items() if v is not None}
57
+
58
+ skip_args = {}
59
+ for skip_flag in ["skip_bump", "skip_changelog", "skip_commit", "skip_tag"]:
60
+ val = cli_args.pop(skip_flag, None)
61
+ if val:
62
+ field = skip_flag.replace("skip_", "")
63
+ skip_args[field] = True
64
+
65
+ try:
66
+ config = load_config(cli_args)
67
+
68
+ if skip_args:
69
+ for k, v in skip_args.items():
70
+ setattr(config.skip, k, v)
71
+
72
+ commit_and_tag_version(config)
73
+ except Exception as err:
74
+ if not kwargs.get("silent"):
75
+ click.echo(f"Error: {err}", err=True)
76
+ sys.exit(1)
@@ -0,0 +1,77 @@
1
+ import re
2
+
3
+ import semver as semver_mod
4
+
5
+ from commit_and_tag_version.models import ParsedCommit
6
+
7
+ COMMIT_PATTERN = re.compile(
8
+ r"^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s*(?P<subject>.+)"
9
+ )
10
+ BREAKING_CHANGE_PATTERN = re.compile(r"^BREAKING[\- ]CHANGE:\s*(?P<note>.+)", re.MULTILINE)
11
+ ISSUE_PATTERN = re.compile(r"#(\d+)")
12
+
13
+
14
+ def parse_commit(raw: str) -> ParsedCommit | None:
15
+ lines = raw.strip().split("\n")
16
+ if not lines:
17
+ return None
18
+
19
+ header = lines[0]
20
+ match = COMMIT_PATTERN.match(header)
21
+ if not match:
22
+ return None
23
+
24
+ body_lines = []
25
+ if len(lines) > 2:
26
+ body_lines = lines[2:]
27
+ body = "\n".join(body_lines) if body_lines else None
28
+ full_text = raw.strip()
29
+
30
+ breaking = match.group("breaking") == "!"
31
+ breaking_note = None
32
+
33
+ breaking_match = BREAKING_CHANGE_PATTERN.search(full_text)
34
+ if breaking_match:
35
+ breaking = True
36
+ breaking_note = breaking_match.group("note").strip()
37
+
38
+ references = []
39
+ for issue_match in ISSUE_PATTERN.finditer(full_text):
40
+ ref = f"#{issue_match.group(1)}"
41
+ if ref not in references:
42
+ references.append(ref)
43
+
44
+ return ParsedCommit(
45
+ type=match.group("type"),
46
+ scope=match.group("scope"),
47
+ subject=match.group("subject").strip(),
48
+ body=body,
49
+ breaking=breaking,
50
+ breaking_note=breaking_note,
51
+ references=references,
52
+ raw=raw.strip(),
53
+ )
54
+
55
+
56
+ def recommend_bump(
57
+ commits: list[ParsedCommit | None],
58
+ current_version: str = "1.0.0",
59
+ ) -> str:
60
+ has_breaking = False
61
+ has_feat = False
62
+
63
+ for commit in commits:
64
+ if commit is None:
65
+ continue
66
+ if commit.breaking:
67
+ has_breaking = True
68
+ if commit.type == "feat":
69
+ has_feat = True
70
+
71
+ pre_major = semver_mod.Version.parse(current_version) < semver_mod.Version.parse("1.0.0")
72
+
73
+ if has_breaking:
74
+ return "minor" if pre_major else "major"
75
+ if has_feat:
76
+ return "minor"
77
+ return "patch"
@@ -0,0 +1,109 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from commit_and_tag_version.defaults import get_default_config
5
+ from commit_and_tag_version.models import Config
6
+
7
+ try:
8
+ import tomllib
9
+ except ModuleNotFoundError:
10
+ try:
11
+ import tomli as tomllib # type: ignore[no-redef]
12
+ except ModuleNotFoundError:
13
+ tomllib = None # type: ignore[assignment]
14
+
15
+ CAMEL_TO_SNAKE: dict[str, str] = {
16
+ "tagPrefix": "tag_prefix",
17
+ "releaseAs": "release_as",
18
+ "noVerify": "no_verify",
19
+ "commitAll": "commit_all",
20
+ "firstRelease": "first_release",
21
+ "dryRun": "dry_run",
22
+ "tagForce": "tag_force",
23
+ "releaseCount": "release_count",
24
+ "releaseCommitMessageFormat": "release_commit_message_format",
25
+ "commitUrlFormat": "commit_url_format",
26
+ "compareUrlFormat": "compare_url_format",
27
+ "issueUrlFormat": "issue_url_format",
28
+ "gitTagFallback": "git_tag_fallback",
29
+ "packageFiles": "package_files",
30
+ "bumpFiles": "bump_files",
31
+ }
32
+
33
+
34
+ def _normalize_keys(raw: dict) -> dict:
35
+ normalized: dict = {}
36
+ for key, value in raw.items():
37
+ snake_key = CAMEL_TO_SNAKE.get(key, key)
38
+ normalized[snake_key] = value
39
+ return normalized
40
+
41
+
42
+ def _load_json_file(path: Path) -> dict:
43
+ return json.loads(path.read_text(encoding="utf-8"))
44
+
45
+
46
+ def _load_pyproject_toml(path: Path) -> dict:
47
+ if tomllib is None:
48
+ return {}
49
+ with open(path, "rb") as f:
50
+ data = tomllib.load(f)
51
+ return data.get("tool", {}).get("commit-and-tag-version", {})
52
+
53
+
54
+ def _discover_file_config(config_path: str | None = None) -> dict:
55
+ if config_path:
56
+ path = Path(config_path)
57
+ if path.exists():
58
+ return _normalize_keys(_load_json_file(path))
59
+ return {}
60
+
61
+ cwd = Path.cwd()
62
+
63
+ versionrc = cwd / ".versionrc"
64
+ if versionrc.exists():
65
+ return _normalize_keys(_load_json_file(versionrc))
66
+
67
+ versionrc_json = cwd / ".versionrc.json"
68
+ if versionrc_json.exists():
69
+ return _normalize_keys(_load_json_file(versionrc_json))
70
+
71
+ pyproject = cwd / "pyproject.toml"
72
+ if pyproject.exists():
73
+ raw = _load_pyproject_toml(pyproject)
74
+ if raw:
75
+ return _normalize_keys(raw)
76
+
77
+ return {}
78
+
79
+
80
+ def _apply_to_config(config: Config, overrides: dict) -> None:
81
+ skip_raw = overrides.pop("skip", None)
82
+ scripts_raw = overrides.pop("scripts", None)
83
+
84
+ for key, value in overrides.items():
85
+ if hasattr(config, key):
86
+ setattr(config, key, value)
87
+
88
+ if skip_raw and isinstance(skip_raw, dict):
89
+ for k, v in skip_raw.items():
90
+ if hasattr(config.skip, k):
91
+ setattr(config.skip, k, v)
92
+
93
+ if scripts_raw and isinstance(scripts_raw, dict):
94
+ for k, v in scripts_raw.items():
95
+ if hasattr(config.scripts, k):
96
+ setattr(config.scripts, k, v)
97
+
98
+
99
+ def load_config(cli_args: dict) -> Config:
100
+ config = get_default_config()
101
+
102
+ config_path = cli_args.pop("config", None)
103
+ file_overrides = _discover_file_config(config_path)
104
+ _apply_to_config(config, file_overrides)
105
+
106
+ cli_overrides = {k: v for k, v in cli_args.items() if v is not None}
107
+ _apply_to_config(config, cli_overrides)
108
+
109
+ return config
@@ -0,0 +1,68 @@
1
+ from commit_and_tag_version.models import Config, ScriptsConfig, SkipConfig
2
+
3
+ DEFAULT_PACKAGE_FILES: list[str] = ["package.json", "bower.json", "manifest.json"]
4
+
5
+ DEFAULT_BUMP_FILES: list[str] = [
6
+ "package.json",
7
+ "bower.json",
8
+ "manifest.json",
9
+ "package-lock.json",
10
+ "npm-shrinkwrap.json",
11
+ ]
12
+
13
+ DEFAULT_INFILE = "CHANGELOG.md"
14
+ DEFAULT_TAG_PREFIX = "v"
15
+ DEFAULT_RELEASE_COUNT = 1
16
+ DEFAULT_PRESET = "conventionalcommits"
17
+
18
+ DEFAULT_RELEASE_COMMIT_MESSAGE_FORMAT = "chore(release): {{currentTag}}"
19
+
20
+ DEFAULT_HEADER = (
21
+ "# Changelog\n\nAll notable changes to this project will be documented in this file. "
22
+ "See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) "
23
+ "for commit guidelines.\n"
24
+ )
25
+
26
+ DEFAULT_COMMIT_TYPES: list[dict] = [
27
+ {"type": "feat", "section": "Features"},
28
+ {"type": "fix", "section": "Bug Fixes"},
29
+ {"type": "chore", "section": "Chores", "hidden": True},
30
+ {"type": "docs", "section": "Documentation", "hidden": True},
31
+ {"type": "style", "section": "Styles", "hidden": True},
32
+ {"type": "refactor", "section": "Refactoring", "hidden": True},
33
+ {"type": "perf", "section": "Performance", "hidden": True},
34
+ {"type": "test", "section": "Tests", "hidden": True},
35
+ {"type": "build", "section": "Build System", "hidden": True},
36
+ {"type": "ci", "section": "CI", "hidden": True},
37
+ ]
38
+
39
+
40
+ def get_default_config() -> Config:
41
+ return Config(
42
+ package_files=list(DEFAULT_PACKAGE_FILES),
43
+ bump_files=list(DEFAULT_BUMP_FILES),
44
+ infile=DEFAULT_INFILE,
45
+ tag_prefix=DEFAULT_TAG_PREFIX,
46
+ preset=DEFAULT_PRESET,
47
+ release_as=None,
48
+ prerelease=None,
49
+ sign=False,
50
+ signoff=False,
51
+ no_verify=False,
52
+ commit_all=False,
53
+ first_release=False,
54
+ dry_run=False,
55
+ silent=False,
56
+ tag_force=False,
57
+ release_count=DEFAULT_RELEASE_COUNT,
58
+ skip=SkipConfig(),
59
+ scripts=ScriptsConfig(),
60
+ header=DEFAULT_HEADER,
61
+ commit_url_format=None,
62
+ compare_url_format=None,
63
+ issue_url_format=None,
64
+ release_commit_message_format=DEFAULT_RELEASE_COMMIT_MESSAGE_FORMAT,
65
+ git_tag_fallback=True,
66
+ path=None,
67
+ types=None,
68
+ )
@@ -0,0 +1,2 @@
1
+ def format_commit_message(raw_msg: str, new_version: str) -> str:
2
+ return str(raw_msg).replace("{{currentTag}}", new_version)
@@ -0,0 +1,115 @@
1
+ import re
2
+ import subprocess
3
+
4
+ import semver
5
+
6
+
7
+ def _run_git(args: list[str], dry_run: bool = False) -> str:
8
+ if dry_run:
9
+ return ""
10
+ result = subprocess.run(
11
+ ["git", *args],
12
+ capture_output=True,
13
+ text=True,
14
+ check=True,
15
+ )
16
+ return result.stdout
17
+
18
+
19
+ def git_add(files: list[str], dry_run: bool = False) -> None:
20
+ _run_git(["add", *files], dry_run=dry_run)
21
+
22
+
23
+ def git_commit(
24
+ message: str,
25
+ sign: bool = False,
26
+ signoff: bool = False,
27
+ no_verify: bool = False,
28
+ dry_run: bool = False,
29
+ files: list[str] | None = None,
30
+ ) -> None:
31
+ cmd = ["commit"]
32
+ if sign:
33
+ cmd.append("--gpg-sign")
34
+ if signoff:
35
+ cmd.append("--signoff")
36
+ if no_verify:
37
+ cmd.append("--no-verify")
38
+ cmd.extend(["-m", message])
39
+ if files:
40
+ cmd.extend(files)
41
+ _run_git(cmd, dry_run=dry_run)
42
+
43
+
44
+ def git_tag(
45
+ name: str,
46
+ message: str,
47
+ sign: bool = False,
48
+ force: bool = False,
49
+ dry_run: bool = False,
50
+ ) -> None:
51
+ cmd = ["tag"]
52
+ if sign:
53
+ cmd.append("-s")
54
+ else:
55
+ cmd.append("-a")
56
+ if force:
57
+ cmd.append("-f")
58
+ cmd.extend([name, "-m", message])
59
+ _run_git(cmd, dry_run=dry_run)
60
+
61
+
62
+ def get_current_branch() -> str:
63
+ return _run_git(["rev-parse", "--abbrev-ref", "HEAD"]).strip()
64
+
65
+
66
+ def get_semver_tags(tag_prefix: str, prerelease: str | None = None) -> list[str]:
67
+ output = _run_git(["tag", "-l", f"{tag_prefix}*"])
68
+ if not output.strip():
69
+ return []
70
+
71
+ tags = []
72
+ prefix_pattern = re.compile(f"^{re.escape(tag_prefix)}")
73
+ for line in output.strip().split("\n"):
74
+ stripped = prefix_pattern.sub("", line.strip())
75
+ if semver.Version.is_valid(stripped):
76
+ tags.append(stripped)
77
+
78
+ if prerelease is not None:
79
+ filtered = []
80
+ for tag in tags:
81
+ v = semver.Version.parse(tag)
82
+ if v.prerelease is None:
83
+ filtered.append(tag)
84
+ elif v.prerelease.split(".")[0] == prerelease:
85
+ filtered.append(tag)
86
+ tags = filtered
87
+
88
+ tags.sort(key=lambda t: semver.Version.parse(t), reverse=True)
89
+ return tags
90
+
91
+
92
+ def get_latest_semver_tag(tag_prefix: str, prerelease: str | None = None) -> str:
93
+ tags = get_semver_tags(tag_prefix, prerelease=prerelease)
94
+ if not tags:
95
+ return "1.0.0"
96
+ return tags[0]
97
+
98
+
99
+ def git_log_raw(
100
+ from_tag: str | None = None,
101
+ to: str = "HEAD",
102
+ path: str | None = None,
103
+ ) -> list[str]:
104
+ if from_tag:
105
+ range_spec = f"{from_tag}..{to}"
106
+ else:
107
+ range_spec = to
108
+ cmd = ["log", range_spec, "--format=%B%x00"]
109
+ if path:
110
+ cmd.extend(["--", path])
111
+ output = _run_git(cmd)
112
+ if not output.strip():
113
+ return []
114
+ messages = [m.strip() for m in output.split("\x00") if m.strip()]
115
+ return messages
File without changes