conventional-release 0.1.0__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.
@@ -0,0 +1,8 @@
1
+ """standard-version-style CHANGELOG and release PRs from Conventional Commits."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("conventional-release")
7
+ except PackageNotFoundError: # running from a source tree that was never installed
8
+ __version__ = "0.0.0"
@@ -0,0 +1,3 @@
1
+ from conventional_release.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,123 @@
1
+ """Command line: `conventional-release <command>` (alias `crel`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ from conventional_release import __version__, cliff, release
12
+ from conventional_release.config import Config, ConfigError, load
13
+ from conventional_release.git import GitError
14
+ from conventional_release.versionfiles import VersionFileError
15
+
16
+ EXPECTED = (ConfigError, GitError, VersionFileError, cliff.CliffError, release.ReleaseError)
17
+
18
+
19
+ def _parser() -> argparse.ArgumentParser:
20
+ p = argparse.ArgumentParser(
21
+ prog="conventional-release",
22
+ description="standard-version-style CHANGELOG and release PRs from Conventional Commits.",
23
+ )
24
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
25
+ p.add_argument(
26
+ "-C", dest="root", type=Path, default=Path.cwd(), help="run as if started in this directory"
27
+ )
28
+ sub = p.add_subparsers(dest="command", required=True)
29
+
30
+ r = sub.add_parser("release", help="branch, CHANGELOG, version bump, commit, push, open a PR")
31
+ r.add_argument(
32
+ "bump", nargs="?", help="major | minor | patch | X.Y.Z (default: inferred from the commits)"
33
+ )
34
+ r.add_argument(
35
+ "--dry-run",
36
+ action="store_true",
37
+ help="print the version and the changelog section, change nothing",
38
+ )
39
+ r.add_argument("--no-push", action="store_true", help="stop after the local commit")
40
+ r.add_argument("--no-pr", action="store_true", help="push the branch but open no PR")
41
+
42
+ sub.add_parser("current", help="print the current version")
43
+ n = sub.add_parser("next", help="print the version the next release would get")
44
+ n.add_argument("bump", nargs="?", help="major | minor | patch | X.Y.Z")
45
+
46
+ no = sub.add_parser("notes", help="print one version's CHANGELOG section (release notes)")
47
+ no.add_argument("version")
48
+
49
+ d = sub.add_parser("detect", help="CI: is HEAD a release commit that still needs its tag?")
50
+ d.add_argument(
51
+ "--github-output",
52
+ action="store_true",
53
+ help="also append released/version/tag to $GITHUB_OUTPUT",
54
+ )
55
+
56
+ t = sub.add_parser("tag", help="CI: create the annotated tag for VERSION on HEAD")
57
+ t.add_argument("version")
58
+ t.add_argument("--push", action="store_true", help="push the tag to origin")
59
+
60
+ c = sub.add_parser("check-title", help="validate a PR title / commit subject")
61
+ c.add_argument("title")
62
+ return p
63
+
64
+
65
+ def main(argv: Sequence[str] | None = None) -> int:
66
+ args = _parser().parse_args(argv)
67
+ try:
68
+ config = load(args.root.resolve())
69
+ return _run(args, config)
70
+ except EXPECTED as e:
71
+ print(f"error: {e}", file=sys.stderr)
72
+ return 1
73
+
74
+
75
+ def _run(args: argparse.Namespace, config: Config) -> int:
76
+ if args.command == "current":
77
+ print(release.current_version(config) or "")
78
+ elif args.command == "next":
79
+ print(release.plan(config, args.bump)[1])
80
+ elif args.command == "notes":
81
+ print(release.notes(config, args.version), end="")
82
+ elif args.command == "check-title":
83
+ problems = release.check_title(config, args.title)
84
+ for problem in problems:
85
+ print(f"error: {problem}", file=sys.stderr)
86
+ return 1 if problems else 0
87
+ elif args.command == "detect":
88
+ result = release.detect(config)
89
+ print(result.outputs(), end="")
90
+ if result.reason:
91
+ print(result.reason, file=sys.stderr)
92
+ if args.github_output:
93
+ with open(os.environ["GITHUB_OUTPUT"], "a") as f:
94
+ f.write(result.outputs())
95
+ elif args.command == "tag":
96
+ print(release.create_tag(config, args.version, push=args.push))
97
+ elif args.command == "release":
98
+ return _release(args, config)
99
+ return 0
100
+
101
+
102
+ def _release(args: argparse.Namespace, config: Config) -> int:
103
+ current, version = release.plan(config, args.bump)
104
+ print(f"release {current or '(none)'} -> {version}", file=sys.stderr)
105
+ if args.dry_run:
106
+ print(
107
+ f"(dry run — nothing written; branch would be {config.branch(version)})\n",
108
+ file=sys.stderr,
109
+ )
110
+ print(cliff.unreleased_section(config, version))
111
+ return 0
112
+ release.cut(config, args.bump, push=not args.no_push, open_pr=not args.no_pr)
113
+ if args.no_push:
114
+ print(
115
+ f"committed on {config.branch(version)}; push it and open a PR titled "
116
+ f'"chore(release): {version}"',
117
+ file=sys.stderr,
118
+ )
119
+ else:
120
+ print(
121
+ "Squash-merge the PR with its title as-is; CI tags the merge commit.", file=sys.stderr
122
+ )
123
+ return 0
@@ -0,0 +1,197 @@
1
+ """Drive git-cliff with a generated config that renders standard-version's CHANGELOG shape:
2
+
3
+ ## 1.2.0 (2026-09-25)
4
+
5
+ ### ⚠ BREAKING CHANGES
6
+
7
+ * **api:** drop the v1 endpoints
8
+
9
+ ### Features
10
+
11
+ * **cli:** add --json ([#42](https://github.com/o/r/issues/42)) ([abc1234](https://github.com/o/r/commit/abc1234...))
12
+
13
+ The PR number comes from squash-merging: GitHub appends " (#N)" to the PR title, the title
14
+ becomes the commit subject, and a preprocessor turns it into a link. A project with its own
15
+ cliff.toml sets `cliff-config` and this module passes it through untouched.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import tempfile
26
+ from collections.abc import Iterator
27
+ from contextlib import contextmanager
28
+ from pathlib import Path
29
+
30
+ from conventional_release import git
31
+ from conventional_release.config import Config
32
+
33
+ HEADER = """\
34
+ # Changelog
35
+
36
+ All notable changes to this project will be documented in this file. Generated by
37
+ [conventional-release](https://github.com/MdaaaaO/conventional-release) from
38
+ [Conventional Commits](https://www.conventionalcommits.org).
39
+
40
+ """
41
+
42
+
43
+ class CliffError(Exception):
44
+ pass
45
+
46
+
47
+ def _q(value: str) -> str:
48
+ """A TOML basic string. JSON's escaping is a subset of TOML's."""
49
+ return json.dumps(value, ensure_ascii=False)
50
+
51
+
52
+ # Tera template. The trailing backslashes are TOML line continuations inside the """ string —
53
+ # they are what keeps the output free of stray blank lines.
54
+ _BODY = r"""
55
+ {% if version %}\
56
+ ## {{ version | trim_start_matches(pat=@@PREFIX@@) }} ({{ timestamp | date(format="%Y-%m-%d") }})
57
+ {% else %}\
58
+ ## Unreleased
59
+ {% endif %}\
60
+ {% set breaking = commits | filter(attribute="breaking", value=true) %}\
61
+ {% if breaking | length > 0 %}
62
+
63
+ ### ⚠ BREAKING CHANGES
64
+
65
+ {% for commit in breaking %}\
66
+ * {% if commit.scope %}**{{ commit.scope }}:** {% endif %}\
67
+ {% if commit.breaking_description %}{{ commit.breaking_description | trim }}\
68
+ {% else %}{{ commit.message | trim }}{% endif %}
69
+ {% endfor %}\
70
+ {% endif %}\
71
+ {% for group, commits in commits | group_by(attribute="group") %}
72
+
73
+ ### {{ group | striptags | trim }}
74
+
75
+ {% for commit in commits %}\
76
+ * {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | trim }}@@LINK@@
77
+ {% endfor %}\
78
+ {% endfor %}
79
+ """
80
+
81
+ _LINK = (
82
+ r' ([{{ commit.id | truncate(length=7, end="") }}]'
83
+ r"(@@URL@@/commit/{{ commit.id }}))"
84
+ )
85
+
86
+
87
+ def render_config(config: Config, repo_url: str | None, *, with_header: bool) -> str:
88
+ body = _BODY.replace("@@PREFIX@@", _q(config.tag_prefix))
89
+ body = body.replace("@@LINK@@", _LINK.replace("@@URL@@", repo_url) if repo_url else "")
90
+
91
+ parsers = [' { message = "^chore\\\\(release\\\\)", skip = true },']
92
+ for n, t in enumerate(config.types):
93
+ pattern = _q(rf"^{re.escape(t.type)}(\(.*\))?!?:")
94
+ if t.hidden:
95
+ parsers.append(f" {{ message = {pattern}, skip = true }},")
96
+ else:
97
+ group = _q(f"<!-- {n:02d} -->{t.section}")
98
+ parsers.append(f" {{ message = {pattern}, group = {group} }},")
99
+
100
+ preprocessors = []
101
+ if repo_url:
102
+ # (?m): a squash commit's message is the subject *plus* the PR body, so "$" must mean
103
+ # end of line — without the flag nothing matches.
104
+ pattern = _q(r"(?m)\s\(#([0-9]+)\)$")
105
+ replace = _q(" ([#${1}](" + repo_url + "/issues/${1}))")
106
+ preprocessors.append(f" {{ pattern = {pattern}, replace = {replace} }},")
107
+
108
+ header = HEADER if with_header else ""
109
+ return f'''\
110
+ [changelog]
111
+ header = {_q(header)}
112
+ body = """{body}"""
113
+ trim = true
114
+ footer = ""
115
+
116
+ [git]
117
+ conventional_commits = true
118
+ filter_unconventional = true
119
+ split_commits = false
120
+ protect_breaking_commits = true
121
+ sort_commits = "oldest"
122
+ tag_pattern = {_q("^" + re.escape(config.tag_prefix) + "[0-9]")}
123
+ commit_preprocessors = [
124
+ {chr(10).join(preprocessors)}
125
+ ]
126
+ commit_parsers = [
127
+ {chr(10).join(parsers)}
128
+ ]
129
+
130
+ [bump]
131
+ features_always_bump_minor = true
132
+ breaking_always_bump_major = true
133
+ initial_tag = {_q(config.tag(config.initial_version))}
134
+ '''
135
+
136
+
137
+ def binary() -> str:
138
+ """git-cliff from this interpreter's environment (the `git-cliff` wheel), else PATH."""
139
+ sibling = Path(sys.executable).parent / "git-cliff"
140
+ if sibling.is_file():
141
+ return str(sibling)
142
+ found = shutil.which("git-cliff")
143
+ if found is None:
144
+ raise CliffError("git-cliff not found — install the git-cliff package")
145
+ return found
146
+
147
+
148
+ def repo_url(config: Config) -> str | None:
149
+ if config.repo_url:
150
+ return config.repo_url.rstrip("/")
151
+ remote = git.remote_url(config.root)
152
+ return git.web_url(remote) if remote else None
153
+
154
+
155
+ @contextmanager
156
+ def _config_file(config: Config, *, with_header: bool) -> Iterator[Path]:
157
+ if config.cliff_config:
158
+ yield config.root / config.cliff_config
159
+ return
160
+ with tempfile.TemporaryDirectory(prefix="conventional-release-") as tmp:
161
+ path = Path(tmp) / "cliff.toml"
162
+ path.write_text(render_config(config, repo_url(config), with_header=with_header))
163
+ yield path
164
+
165
+
166
+ def run(config: Config, *args: str, with_header: bool = False) -> str:
167
+ with _config_file(config, with_header=with_header) as cfg:
168
+ proc = subprocess.run(
169
+ [binary(), "--config", str(cfg), *args],
170
+ cwd=config.root,
171
+ capture_output=True,
172
+ text=True,
173
+ )
174
+ if proc.returncode != 0:
175
+ raise CliffError(f"git-cliff {' '.join(args)} failed:\n{proc.stderr.strip()}")
176
+ return proc.stdout
177
+
178
+
179
+ def bumped_version(config: Config) -> str:
180
+ out = run(config, "--bumped-version").strip()
181
+ return out.removeprefix(config.tag_prefix)
182
+
183
+
184
+ def unreleased_section(config: Config, version: str) -> str:
185
+ """The section the next release will add, heading included, no header."""
186
+ return run(config, "--unreleased", "--tag", config.tag(version), "--strip", "all").strip()
187
+
188
+
189
+ def regenerate(config: Config, version: str) -> None:
190
+ run(
191
+ config,
192
+ "--tag",
193
+ config.tag(version),
194
+ "--output",
195
+ str(config.changelog_path),
196
+ with_header=True,
197
+ )
@@ -0,0 +1,164 @@
1
+ """Project configuration: `.conventional-release.toml`, or `[tool.conventional-release]` in
2
+ pyproject.toml. Every key is optional; the defaults reproduce standard-version's behaviour."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import tomllib
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ CONFIG_FILE = ".conventional-release.toml"
12
+
13
+
14
+ class ConfigError(Exception):
15
+ pass
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CommitType:
20
+ type: str
21
+ section: str
22
+ hidden: bool = False
23
+
24
+
25
+ # The Angular set, which Conventional Commits grew out of, in the order standard-version's
26
+ # `.versionrc` examples use. Every type gets a section by default; set `hidden = true` to drop one.
27
+ DEFAULT_TYPES: tuple[CommitType, ...] = (
28
+ CommitType("feat", "Features"),
29
+ CommitType("fix", "Bug Fixes"),
30
+ CommitType("docs", "Documentation"),
31
+ CommitType("style", "Styling"),
32
+ CommitType("refactor", "Refactors"),
33
+ CommitType("perf", "Performance"),
34
+ CommitType("test", "Tests"),
35
+ CommitType("build", "Build System"),
36
+ CommitType("ci", "CI"),
37
+ CommitType("chore", "Chore"),
38
+ CommitType("revert", "Reverts"),
39
+ )
40
+
41
+ VERSION_SOURCES = ("file", "tag")
42
+ CHANGELOG_MODES = ("prepend", "regenerate")
43
+
44
+ # Probed in this order when `version-files` is not set and `version-source = "file"`.
45
+ AUTODETECT_FILES = ("pyproject.toml", "package.json", "Cargo.toml", "VERSION", "version")
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Config:
50
+ root: Path
51
+ version_source: str = "file"
52
+ version_files: tuple[str, ...] = ()
53
+ changelog: str = "CHANGELOG.md"
54
+ changelog_mode: str = "prepend"
55
+ tag_prefix: str = "v"
56
+ base_branch: str = "main"
57
+ branch_prefix: str = "release/"
58
+ initial_version: str = "0.1.0"
59
+ repo_url: str | None = None
60
+ cliff_config: str | None = None
61
+ subject_lowercase: bool = True
62
+ types: tuple[CommitType, ...] = field(default=DEFAULT_TYPES)
63
+
64
+ @property
65
+ def changelog_path(self) -> Path:
66
+ return self.root / self.changelog
67
+
68
+ def tag(self, version: str) -> str:
69
+ return f"{self.tag_prefix}{version}"
70
+
71
+ def branch(self, version: str) -> str:
72
+ return f"{self.branch_prefix}{self.tag(version)}"
73
+
74
+
75
+ _SCALAR_KEYS = {
76
+ "version-source": ("version_source", str),
77
+ "changelog": ("changelog", str),
78
+ "changelog-mode": ("changelog_mode", str),
79
+ "tag-prefix": ("tag_prefix", str),
80
+ "base-branch": ("base_branch", str),
81
+ "branch-prefix": ("branch_prefix", str),
82
+ "initial-version": ("initial_version", str),
83
+ "repo-url": ("repo_url", str),
84
+ "cliff-config": ("cliff_config", str),
85
+ "subject-lowercase": ("subject_lowercase", bool),
86
+ }
87
+
88
+
89
+ def _raw(root: Path) -> tuple[dict[str, Any], str]:
90
+ dedicated = root / CONFIG_FILE
91
+ if dedicated.is_file():
92
+ with dedicated.open("rb") as f:
93
+ return tomllib.load(f), CONFIG_FILE
94
+ pyproject = root / "pyproject.toml"
95
+ if pyproject.is_file():
96
+ with pyproject.open("rb") as f:
97
+ data = tomllib.load(f)
98
+ section = data.get("tool", {}).get("conventional-release")
99
+ if section is not None:
100
+ return section, "pyproject.toml [tool.conventional-release]"
101
+ return {}, "defaults"
102
+
103
+
104
+ def load(root: Path) -> Config:
105
+ raw, where = _raw(root)
106
+ kwargs: dict[str, Any] = {}
107
+ for key, value in raw.items():
108
+ if key in _SCALAR_KEYS:
109
+ name, kind = _SCALAR_KEYS[key]
110
+ if not isinstance(value, kind):
111
+ raise ConfigError(f"{where}: {key} must be a {kind.__name__}")
112
+ kwargs[name] = value
113
+ elif key == "version-files":
114
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
115
+ raise ConfigError(f"{where}: version-files must be a list of paths")
116
+ kwargs["version_files"] = tuple(value)
117
+ elif key == "types":
118
+ kwargs["types"] = _types(value, where)
119
+ else:
120
+ raise ConfigError(f"{where}: unknown key {key!r}")
121
+
122
+ config = Config(root=root, **kwargs)
123
+ if config.version_source not in VERSION_SOURCES:
124
+ raise ConfigError(f"{where}: version-source must be one of {VERSION_SOURCES}")
125
+ if config.changelog_mode not in CHANGELOG_MODES:
126
+ raise ConfigError(f"{where}: changelog-mode must be one of {CHANGELOG_MODES}")
127
+ return config
128
+
129
+
130
+ def _types(value: Any, where: str) -> tuple[CommitType, ...]:
131
+ if not isinstance(value, list) or not value:
132
+ raise ConfigError(f"{where}: types must be a non-empty array of tables")
133
+ out = []
134
+ for item in value:
135
+ if not isinstance(item, dict) or not isinstance(item.get("type"), str):
136
+ raise ConfigError(f"{where}: every types entry needs a string 'type'")
137
+ section = item.get("section", item["type"])
138
+ hidden = item.get("hidden", False)
139
+ if not isinstance(section, str) or not isinstance(hidden, bool):
140
+ raise ConfigError(f"{where}: types.{item['type']}: section is a string, hidden a bool")
141
+ out.append(CommitType(item["type"], section, hidden))
142
+ return tuple(out)
143
+
144
+
145
+ def version_files(config: Config) -> list[Path]:
146
+ """The files that carry the version, auto-detected when not configured."""
147
+ if config.version_source == "tag":
148
+ return []
149
+ if config.version_files:
150
+ paths = [config.root / p for p in config.version_files]
151
+ missing = [str(p.relative_to(config.root)) for p in paths if not p.is_file()]
152
+ if missing:
153
+ raise ConfigError(f"version-files not found: {', '.join(missing)}")
154
+ return paths
155
+ from conventional_release import versionfiles
156
+
157
+ for name in AUTODETECT_FILES:
158
+ path = config.root / name
159
+ if path.is_file() and versionfiles.has_version(path):
160
+ return [path]
161
+ raise ConfigError(
162
+ "no version file found (looked for " + ", ".join(AUTODETECT_FILES) + "). "
163
+ 'Set version-files, or version-source = "tag" if the git tag is the version.'
164
+ )
@@ -0,0 +1,68 @@
1
+ """Thin wrappers over the git CLI. Every call raises on a non-zero exit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+
10
+ class GitError(Exception):
11
+ pass
12
+
13
+
14
+ def run(root: Path, *args: str) -> str:
15
+ proc = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
16
+ if proc.returncode != 0:
17
+ raise GitError(f"git {' '.join(args)}: {proc.stderr.strip() or proc.stdout.strip()}")
18
+ return proc.stdout.strip()
19
+
20
+
21
+ def status_porcelain(root: Path) -> str:
22
+ return run(root, "status", "--porcelain")
23
+
24
+
25
+ def latest_tag(root: Path, prefix: str) -> str | None:
26
+ try:
27
+ return run(root, "describe", "--tags", "--abbrev=0", "--match", f"{prefix}[0-9]*")
28
+ except GitError:
29
+ return None
30
+
31
+
32
+ def tag_exists(root: Path, tag: str) -> bool:
33
+ try:
34
+ run(root, "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}")
35
+ except GitError:
36
+ return False
37
+ return True
38
+
39
+
40
+ def branch_exists(root: Path, branch: str) -> bool:
41
+ try:
42
+ run(root, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}")
43
+ except GitError:
44
+ return False
45
+ return True
46
+
47
+
48
+ def head_subject(root: Path) -> str:
49
+ return run(root, "log", "-1", "--pretty=%s")
50
+
51
+
52
+ def remote_url(root: Path, remote: str = "origin") -> str | None:
53
+ try:
54
+ return run(root, "remote", "get-url", remote)
55
+ except GitError:
56
+ return None
57
+
58
+
59
+ _SSH = re.compile(r"^(?:ssh://)?git@([^:/]+)[:/](.+?)(?:\.git)?/?$")
60
+ _HTTPS = re.compile(r"^https?://(?:[^@/]+@)?([^/]+)/(.+?)(?:\.git)?/?$")
61
+
62
+
63
+ def web_url(remote: str) -> str | None:
64
+ """git@github.com:o/r.git, https://x@github.com/o/r → https://github.com/o/r"""
65
+ for pattern in (_SSH, _HTTPS):
66
+ if m := pattern.match(remote):
67
+ return f"https://{m.group(1)}/{m.group(2)}"
68
+ return None
File without changes