releez 0.2.2__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.
releez/github.py ADDED
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from dataclasses import dataclass
6
+ from urllib.parse import urlparse
7
+
8
+ from releez.errors import InvalidGitHubRemoteError, MissingGitHubDependencyError
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class PullRequest:
13
+ """A minimal representation of a created GitHub pull request.
14
+
15
+ Attributes:
16
+ url: The PR URL.
17
+ number: The PR number.
18
+ """
19
+
20
+ url: str
21
+ number: int
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class PullRequestCreateRequest:
26
+ """Parameters for creating a GitHub pull request.
27
+
28
+ Attributes:
29
+ remote_url: The git remote URL used to infer the GitHub repo.
30
+ token: GitHub token used for authentication.
31
+ base: The base branch for the PR.
32
+ head: The head branch for the PR.
33
+ title: The PR title.
34
+ body: The PR body.
35
+ labels: Labels to add to the PR.
36
+ """
37
+
38
+ remote_url: str
39
+ token: str
40
+ base: str
41
+ head: str
42
+ title: str
43
+ body: str
44
+ labels: list[str]
45
+
46
+
47
+ _SCP_SSH_RE = re.compile(
48
+ r'^git@(?P<host>[^:]+):(?P<full>[^/]+/[^/]+?)(?:\\.git)?$',
49
+ )
50
+ _SSH_URL_RE = re.compile(
51
+ r'^ssh://git@(?P<host>[^/]+)/(?P<full>[^/]+/[^/]+?)(?:\\.git)?$',
52
+ )
53
+ _HTTPS_RE = re.compile(
54
+ r'^https?://(?P<host>[^/]+)/(?P<full>[^/]+/[^/]+?)(?:\\.git)?$',
55
+ )
56
+
57
+
58
+ def _github_api_base_url_from_env() -> str | None:
59
+ api_url = os.getenv('RELEEZ_GITHUB_API_URL') or os.getenv('GITHUB_API_URL')
60
+ if api_url:
61
+ return api_url.rstrip('/')
62
+
63
+ server_url = os.getenv('RELEEZ_GITHUB_SERVER_URL') or os.getenv(
64
+ 'GITHUB_SERVER_URL',
65
+ )
66
+ if not server_url:
67
+ return None
68
+ return f'{server_url.rstrip("/")}/api/v3'
69
+
70
+
71
+ def _allowed_github_hosts_from_env() -> set[str]:
72
+ hosts = {'github.com'}
73
+
74
+ for var in (
75
+ 'RELEEZ_GITHUB_SERVER_URL',
76
+ 'GITHUB_SERVER_URL',
77
+ 'RELEEZ_GITHUB_API_URL',
78
+ 'GITHUB_API_URL',
79
+ ):
80
+ raw = os.getenv(var)
81
+ if not raw:
82
+ continue
83
+ parsed = urlparse(raw)
84
+ if parsed.hostname:
85
+ hosts.add(parsed.hostname)
86
+ continue
87
+ # allow plain host values (not URLs)
88
+ hosts.add(raw.strip().rstrip('/'))
89
+
90
+ return hosts
91
+
92
+
93
+ def _parse_github_full_name(remote_url: str) -> str:
94
+ remote_url = remote_url.strip()
95
+ for regex in (_SCP_SSH_RE, _SSH_URL_RE, _HTTPS_RE):
96
+ m = regex.match(remote_url)
97
+ if m:
98
+ host = m.group('host')
99
+ if host not in _allowed_github_hosts_from_env():
100
+ raise InvalidGitHubRemoteError(remote_url)
101
+ full_name = m.group('full')
102
+ if full_name.endswith('.git'):
103
+ full_name = full_name.removesuffix('.git')
104
+ return full_name
105
+ raise InvalidGitHubRemoteError(remote_url)
106
+
107
+
108
+ def create_pull_request(request: PullRequestCreateRequest) -> PullRequest:
109
+ """Create a GitHub pull request.
110
+
111
+ Args:
112
+ request: The parameters needed to create the pull request.
113
+
114
+ Returns:
115
+ The created PR URL and number.
116
+
117
+ Raises:
118
+ MissingGitHubDependencyError: If PyGithub is not installed.
119
+ InvalidGitHubRemoteError: If the remote URL cannot be mapped to a GitHub repo.
120
+ """
121
+ try:
122
+ from github import Github # noqa: PLC0415
123
+ except ImportError as exc:
124
+ raise MissingGitHubDependencyError from exc
125
+
126
+ full_name = _parse_github_full_name(request.remote_url)
127
+ base_url = _github_api_base_url_from_env()
128
+ gh = (
129
+ Github(
130
+ login_or_token=request.token,
131
+ base_url=base_url,
132
+ )
133
+ if base_url
134
+ else Github(request.token)
135
+ )
136
+ repo = gh.get_repo(full_name)
137
+ pr = repo.create_pull(
138
+ title=request.title,
139
+ body=request.body,
140
+ base=request.base,
141
+ head=request.head,
142
+ )
143
+ if request.labels:
144
+ pr.add_to_labels(*request.labels)
145
+ return PullRequest(url=pr.html_url, number=pr.number)
releez/process.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from typing import TYPE_CHECKING
5
+
6
+ from releez.errors import ExternalCommandError, MissingCliError
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+
12
+
13
+ def run_checked(
14
+ args: Sequence[str],
15
+ *,
16
+ cwd: Path | None = None,
17
+ capture_stdout: bool = True,
18
+ ) -> str:
19
+ """Run a command and raise a ReleezError on failure.
20
+
21
+ Args:
22
+ args: The command and arguments to execute.
23
+ cwd: Optional working directory for the command.
24
+ capture_stdout: If false, stdout is not captured.
25
+
26
+ Returns:
27
+ The stripped stdout of the command.
28
+
29
+ Raises:
30
+ MissingCliError: If the executable is not found.
31
+ ExternalCommandError: If the command exits non-zero.
32
+ """
33
+ try:
34
+ res = subprocess.run( # noqa: S603
35
+ list(args),
36
+ cwd=cwd,
37
+ check=True,
38
+ text=True,
39
+ stdout=subprocess.PIPE if capture_stdout else None,
40
+ stderr=subprocess.PIPE,
41
+ )
42
+ except FileNotFoundError as exc:
43
+ raise MissingCliError(args[0]) from exc
44
+ except subprocess.CalledProcessError as exc:
45
+ stderr = (exc.stderr or '').strip()
46
+ raise ExternalCommandError(
47
+ cmd_args=args,
48
+ returncode=exc.returncode,
49
+ stderr=stderr,
50
+ ) from exc
51
+
52
+ return (res.stdout or '').strip()
releez/release.py ADDED
@@ -0,0 +1,241 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from pathlib import Path
8
+
9
+ from git import Repo
10
+
11
+ from releez.cliff import GitCliff, GitCliffBump
12
+ from releez.errors import (
13
+ ChangelogFormatCommandRequiredError,
14
+ GitHubTokenRequiredError,
15
+ GitRemoteUrlRequiredError,
16
+ )
17
+ from releez.git_repo import (
18
+ checkout_remote_branch,
19
+ commit_file,
20
+ create_and_checkout_branch,
21
+ ensure_clean,
22
+ fetch,
23
+ open_repo,
24
+ push_set_upstream,
25
+ )
26
+ from releez.github import PullRequestCreateRequest, create_pull_request
27
+ from releez.utils import resolve_changelog_path, run_changelog_formatter
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class StartReleaseResult:
32
+ """Result of starting a release.
33
+
34
+ Attributes:
35
+ version: The computed next version.
36
+ release_notes_markdown: The generated release notes markdown.
37
+ release_branch: The created release branch, or None in dry-run mode.
38
+ pr_url: The created PR URL, or None if not created.
39
+ """
40
+
41
+ version: str
42
+ release_notes_markdown: str
43
+ release_branch: str | None
44
+ pr_url: str | None
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class StartReleaseInput:
49
+ """Inputs for starting a release.
50
+
51
+ Attributes:
52
+ bump: Bump mode for git-cliff.
53
+ version_override: Override the computed next version.
54
+ base_branch: Base branch for the release PR.
55
+ remote_name: Remote name to use.
56
+ labels: Labels to add to the PR.
57
+ title_prefix: Prefix for PR title / commit message.
58
+ changelog_path: Changelog file to prepend to.
59
+ run_changelog_format: If true, run the configured changelog formatter before commit.
60
+ changelog_format_cmd: Optional argv list to run for formatting (overrides config).
61
+ create_pr: If true, create a GitHub pull request.
62
+ github_token: GitHub token for PR creation.
63
+ dry_run: If true, do not modify the repo; just output version and notes.
64
+ """
65
+
66
+ bump: GitCliffBump
67
+ version_override: str | None
68
+ base_branch: str
69
+ remote_name: str
70
+ labels: list[str]
71
+ title_prefix: str
72
+ changelog_path: str
73
+ run_changelog_format: bool
74
+ changelog_format_cmd: list[str] | None
75
+ create_pr: bool
76
+ github_token: str | None
77
+ dry_run: bool
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class _MaybeCreatePullRequestInput:
82
+ """Inputs for optionally creating a pull request.
83
+
84
+ Attributes:
85
+ create_pr: If true, create a GitHub pull request.
86
+ github_token: GitHub token for PR creation.
87
+ remote_name: Remote name used to infer the repo URL.
88
+ base_branch: The base branch for the PR.
89
+ head_branch: The head branch for the PR.
90
+ title: The PR title.
91
+ body: The PR body.
92
+ labels: Labels to add to the PR.
93
+ """
94
+
95
+ create_pr: bool
96
+ github_token: str | None
97
+ remote_name: str
98
+ base_branch: str
99
+ head_branch: str
100
+ title: str
101
+ body: str
102
+ labels: list[str]
103
+
104
+
105
+ def _maybe_create_pull_request(
106
+ *,
107
+ repo: Repo,
108
+ pr_input: _MaybeCreatePullRequestInput,
109
+ ) -> str | None:
110
+ if not pr_input.create_pr:
111
+ return None
112
+ if not pr_input.github_token:
113
+ raise GitHubTokenRequiredError
114
+
115
+ remote_url = repo.remotes[pr_input.remote_name].url
116
+ if not remote_url:
117
+ raise GitRemoteUrlRequiredError(pr_input.remote_name)
118
+
119
+ request = PullRequestCreateRequest(
120
+ remote_url=remote_url,
121
+ token=pr_input.github_token,
122
+ base=pr_input.base_branch,
123
+ head=pr_input.head_branch,
124
+ title=pr_input.title,
125
+ body=pr_input.body,
126
+ labels=pr_input.labels,
127
+ )
128
+ pr = create_pull_request(request)
129
+ return pr.url
130
+
131
+
132
+ def _resolve_release_version(
133
+ *,
134
+ cliff: GitCliff,
135
+ release_input: StartReleaseInput,
136
+ ) -> str:
137
+ if release_input.version_override is not None:
138
+ return release_input.version_override
139
+ return cliff.compute_next_version(bump=release_input.bump)
140
+
141
+
142
+ def _format_changelog_if_requested(
143
+ *,
144
+ repo_root: Path,
145
+ changelog_path: Path,
146
+ release_input: StartReleaseInput,
147
+ ) -> None:
148
+ if not release_input.run_changelog_format:
149
+ return
150
+ if not release_input.changelog_format_cmd:
151
+ raise ChangelogFormatCommandRequiredError
152
+ run_changelog_formatter(
153
+ changelog_path=changelog_path,
154
+ repo_root=repo_root,
155
+ changelog_format_cmd=release_input.changelog_format_cmd,
156
+ )
157
+
158
+
159
+ def start_release(
160
+ release_input: StartReleaseInput,
161
+ ) -> StartReleaseResult:
162
+ """Start a release.
163
+
164
+ Args:
165
+ release_input: The input parameters for starting the release.
166
+
167
+ Returns:
168
+ The version, release notes, and (if created) branch/PR details.
169
+
170
+ Raises:
171
+ ReleezError: If a release step fails (git, git-cliff, or GitHub).
172
+ """
173
+ repo, info = open_repo()
174
+ ensure_clean(repo)
175
+ fetch(repo, remote_name=release_input.remote_name)
176
+
177
+ cliff = GitCliff(repo_root=info.root)
178
+ if not release_input.dry_run:
179
+ checkout_remote_branch(
180
+ repo,
181
+ remote_name=release_input.remote_name,
182
+ branch=release_input.base_branch,
183
+ )
184
+
185
+ version = _resolve_release_version(cliff=cliff, release_input=release_input)
186
+ notes = cliff.generate_unreleased_notes(version=version)
187
+
188
+ if release_input.dry_run:
189
+ return StartReleaseResult(
190
+ version=version,
191
+ release_notes_markdown=notes,
192
+ release_branch=None,
193
+ pr_url=None,
194
+ )
195
+
196
+ release_branch = f'release/{version}'
197
+ create_and_checkout_branch(repo, name=release_branch)
198
+
199
+ changelog = resolve_changelog_path(
200
+ changelog_path=release_input.changelog_path,
201
+ repo_root=info.root,
202
+ )
203
+ cliff.prepend_to_changelog(version=version, changelog_path=changelog)
204
+ _format_changelog_if_requested(
205
+ repo_root=info.root,
206
+ changelog_path=changelog,
207
+ release_input=release_input,
208
+ )
209
+
210
+ commit_file(
211
+ repo,
212
+ path=changelog,
213
+ message=f'{release_input.title_prefix}{version}',
214
+ )
215
+
216
+ push_set_upstream(
217
+ repo,
218
+ remote_name=release_input.remote_name,
219
+ branch=release_branch,
220
+ )
221
+
222
+ pr_url = _maybe_create_pull_request(
223
+ repo=repo,
224
+ pr_input=_MaybeCreatePullRequestInput(
225
+ create_pr=release_input.create_pr,
226
+ github_token=release_input.github_token,
227
+ remote_name=release_input.remote_name,
228
+ base_branch=release_input.base_branch,
229
+ head_branch=release_branch,
230
+ title=f'{release_input.title_prefix}{version}',
231
+ body=notes,
232
+ labels=release_input.labels,
233
+ ),
234
+ )
235
+
236
+ return StartReleaseResult(
237
+ version=version,
238
+ release_notes_markdown=notes,
239
+ release_branch=release_branch,
240
+ pr_url=pr_url,
241
+ )
releez/settings.py ADDED
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import AliasChoices, AliasGenerator, BaseModel, ConfigDict, Field
4
+ from pydantic_settings import (
5
+ BaseSettings,
6
+ PydanticBaseSettingsSource,
7
+ PyprojectTomlConfigSettingsSource,
8
+ SettingsConfigDict,
9
+ TomlConfigSettingsSource,
10
+ )
11
+
12
+ from releez.version_tags import AliasVersions
13
+
14
+
15
+ def _to_kebab(name: str) -> str:
16
+ """Convert snake_case to kebab-case."""
17
+ return name.replace('_', '-')
18
+
19
+
20
+ def _validation_alias(name: str) -> AliasChoices:
21
+ """Accept both snake_case and kebab-case for settings keys.
22
+
23
+ `AliasChoices` order matters: we list the snake_case field name first so if both
24
+ variants are present (e.g. env var + pyproject), the env var wins.
25
+ """
26
+ return AliasChoices(name, _to_kebab(name))
27
+
28
+
29
+ _ALIASES = AliasGenerator(
30
+ validation_alias=_validation_alias,
31
+ serialization_alias=_to_kebab,
32
+ )
33
+
34
+
35
+ class ReleezHooks(BaseModel):
36
+ """Hook-related configuration.
37
+
38
+ Attributes:
39
+ changelog_format: Optional argv list used to format the changelog (e.g.
40
+ ["dprint", "fmt", "{changelog}"]).
41
+ """
42
+
43
+ model_config = ConfigDict(
44
+ alias_generator=_ALIASES,
45
+ populate_by_name=True,
46
+ )
47
+
48
+ changelog_format: list[str] | None = None
49
+
50
+
51
+ class ReleezSettings(BaseSettings):
52
+ """Settings loaded from CLI args, env vars, and config files.
53
+
54
+ Precedence (highest first):
55
+ 1. Explicit init kwargs (CLI layer)
56
+ 2. RELEEZ_* env vars
57
+ 3. releez.toml
58
+ 4. pyproject.toml ([tool.releez])
59
+ """
60
+
61
+ model_config = SettingsConfigDict(
62
+ env_prefix='RELEEZ_',
63
+ env_nested_delimiter='__',
64
+ extra='ignore',
65
+ pyproject_toml_table_header=('tool', 'releez'),
66
+ alias_generator=_ALIASES,
67
+ populate_by_name=True,
68
+ )
69
+
70
+ base_branch: str = 'master'
71
+ git_remote: str = 'origin'
72
+ pr_labels: str = 'release'
73
+ pr_title_prefix: str = 'chore(release): '
74
+ changelog_path: str = 'CHANGELOG.md'
75
+ create_pr: bool = False
76
+ run_changelog_format: bool = False
77
+ alias_versions: AliasVersions = AliasVersions.none
78
+ hooks: ReleezHooks = Field(default_factory=ReleezHooks)
79
+
80
+ @classmethod
81
+ def settings_customise_sources(
82
+ cls,
83
+ settings_cls: type[BaseSettings],
84
+ init_settings: PydanticBaseSettingsSource,
85
+ env_settings: PydanticBaseSettingsSource,
86
+ dotenv_settings: PydanticBaseSettingsSource,
87
+ file_secret_settings: PydanticBaseSettingsSource,
88
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
89
+ """Customize settings sources for Releez."""
90
+ _ = (dotenv_settings, file_secret_settings)
91
+ releez_toml = TomlConfigSettingsSource(
92
+ settings_cls,
93
+ toml_file='releez.toml',
94
+ )
95
+ pyproject_toml = PyprojectTomlConfigSettingsSource(settings_cls)
96
+ return (
97
+ init_settings,
98
+ env_settings,
99
+ releez_toml,
100
+ pyproject_toml,
101
+ )
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from releez.subapps.changelog import changelog_app
4
+
5
+ __all__ = ['changelog_app']
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Annotated
4
+
5
+ import typer
6
+
7
+ if TYPE_CHECKING:
8
+ from pathlib import Path
9
+
10
+ from releez.cliff import GitCliff
11
+ from releez.errors import ChangelogFormatCommandRequiredError, ReleezError
12
+ from releez.git_repo import open_repo
13
+ from releez.utils import resolve_changelog_path, run_changelog_formatter
14
+
15
+ changelog_app = typer.Typer(help='Changelog utilities.')
16
+
17
+
18
+ def _run_changelog_formatter_with_message(
19
+ *,
20
+ changelog_path: Path,
21
+ repo_root: Path,
22
+ changelog_format_cmd: list[str],
23
+ ) -> None:
24
+ """Run the changelog formatter command and print success message."""
25
+ run_changelog_formatter(
26
+ changelog_path=changelog_path,
27
+ repo_root=repo_root,
28
+ changelog_format_cmd=changelog_format_cmd,
29
+ )
30
+ typer.secho(
31
+ '✓ Ran changelog format hook',
32
+ fg=typer.colors.GREEN,
33
+ )
34
+
35
+
36
+ @changelog_app.command('regenerate')
37
+ def changelog_regenerate(
38
+ *,
39
+ changelog_path: Annotated[
40
+ str,
41
+ typer.Option(
42
+ '--changelog-path',
43
+ help='Path to the changelog file.',
44
+ show_default=True,
45
+ ),
46
+ ] = 'CHANGELOG.md',
47
+ run_changelog_format: Annotated[
48
+ bool,
49
+ typer.Option(
50
+ '--run-changelog-format',
51
+ help='Run the configured changelog formatter after regeneration.',
52
+ show_default=True,
53
+ ),
54
+ ] = False,
55
+ changelog_format_cmd: Annotated[
56
+ list[str] | None,
57
+ typer.Option(
58
+ '--changelog-format-cmd',
59
+ help='Override changelog format command argv (repeatable).',
60
+ show_default=False,
61
+ ),
62
+ ] = None,
63
+ ) -> None:
64
+ """Regenerate the full changelog from git history."""
65
+ try:
66
+ if run_changelog_format and not changelog_format_cmd:
67
+ raise ChangelogFormatCommandRequiredError
68
+
69
+ _, info = open_repo()
70
+ changelog = resolve_changelog_path(changelog_path, info.root)
71
+
72
+ cliff = GitCliff(repo_root=info.root)
73
+ cliff.regenerate_changelog(changelog_path=changelog)
74
+ typer.secho(
75
+ f'✓ Regenerated changelog: {changelog}',
76
+ fg=typer.colors.GREEN,
77
+ )
78
+
79
+ if run_changelog_format and changelog_format_cmd:
80
+ _run_changelog_formatter_with_message(
81
+ changelog_path=changelog,
82
+ repo_root=info.root,
83
+ changelog_format_cmd=changelog_format_cmd,
84
+ )
85
+ except ReleezError as exc:
86
+ typer.secho(str(exc), err=True, fg=typer.colors.RED)
87
+ raise typer.Exit(code=1) from exc
releez/utils.py ADDED
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from releez.errors import ChangelogNotFoundError
6
+ from releez.process import run_checked
7
+
8
+
9
+ def resolve_changelog_path(changelog_path: str, repo_root: Path) -> Path:
10
+ """Resolve the changelog path relative to the repo root.
11
+
12
+ Args:
13
+ changelog_path: Path to the changelog file (absolute or relative).
14
+ repo_root: Root directory of the git repository.
15
+
16
+ Returns:
17
+ The resolved absolute path to the changelog.
18
+
19
+ Raises:
20
+ ChangelogNotFoundError: If the changelog file doesn't exist.
21
+ """
22
+ changelog = Path(changelog_path)
23
+ if not changelog.is_absolute():
24
+ changelog = repo_root / changelog
25
+ if not changelog.exists():
26
+ raise ChangelogNotFoundError(changelog)
27
+ return changelog
28
+
29
+
30
+ def run_changelog_formatter(
31
+ *,
32
+ changelog_path: Path,
33
+ repo_root: Path,
34
+ changelog_format_cmd: list[str],
35
+ ) -> None:
36
+ """Run the changelog formatter command.
37
+
38
+ Args:
39
+ changelog_path: Path to the changelog file to format.
40
+ repo_root: Root directory to run the command from.
41
+ changelog_format_cmd: Command argv list with optional {changelog} placeholder.
42
+ """
43
+ cmd = [arg.replace('{changelog}', str(changelog_path)) for arg in changelog_format_cmd]
44
+ run_checked(cmd, cwd=repo_root, capture_stdout=False)