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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+
6
+ from releez.cliff import GitCliff
7
+ from releez.errors import (
8
+ BuildNumberRequiredError,
9
+ PrereleaseNumberRequiredError,
10
+ )
11
+ from releez.git_repo import open_repo
12
+
13
+
14
+ class ArtifactVersionScheme(StrEnum):
15
+ """Output scheme for artifact versions."""
16
+
17
+ semver = 'semver'
18
+ docker = 'docker'
19
+ pep440 = 'pep440'
20
+
21
+
22
+ class PrereleaseType(StrEnum):
23
+ """Supported prerelease types.
24
+
25
+ These are deliberately limited so we can reliably map to PEP 440.
26
+ """
27
+
28
+ alpha = 'alpha'
29
+ beta = 'beta'
30
+ rc = 'rc'
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ArtifactVersionInput:
35
+ """Inputs for computing an artifact version.
36
+
37
+ Attributes:
38
+ scheme: Output scheme for the artifact version.
39
+ version_override: If set, use this instead of computing via git-cliff.
40
+ is_full_release: If true, output a full release version without prerelease markers.
41
+ prerelease_type: Prerelease label (e.g. alpha, beta, rc).
42
+ prerelease_number: Optional prerelease number (e.g. PR number for alpha123).
43
+ build_number: Build identifier for prerelease builds.
44
+ """
45
+
46
+ scheme: ArtifactVersionScheme
47
+ version_override: str | None
48
+ is_full_release: bool
49
+ prerelease_type: PrereleaseType
50
+ prerelease_number: int | None
51
+ build_number: int | None
52
+
53
+
54
+ _PEP440_PRERELEASE_MARKERS: dict[PrereleaseType, str] = {
55
+ PrereleaseType.alpha: 'a',
56
+ PrereleaseType.beta: 'b',
57
+ PrereleaseType.rc: 'rc',
58
+ }
59
+
60
+
61
+ def compute_artifact_version(artifact_input: ArtifactVersionInput) -> str:
62
+ """Compute an artifact version string.
63
+
64
+ Args:
65
+ artifact_input: The inputs for computing the version.
66
+
67
+ Returns:
68
+ The version string to apply to the artifact.
69
+
70
+ Raises:
71
+ BuildNumberRequiredError: If a prerelease build is missing a build number.
72
+ ReleezError: If git or git-cliff are unavailable, or git-cliff fails.
73
+ """
74
+ next_version = artifact_input.version_override or _compute_next_version()
75
+ if artifact_input.is_full_release:
76
+ return next_version
77
+
78
+ if artifact_input.build_number is None:
79
+ raise BuildNumberRequiredError
80
+
81
+ prerelease_type = artifact_input.prerelease_type.value
82
+ prerelease_number = artifact_input.prerelease_number
83
+ if prerelease_number is None:
84
+ raise PrereleaseNumberRequiredError
85
+ if artifact_input.scheme == ArtifactVersionScheme.semver:
86
+ return f'{next_version}-{prerelease_type}{prerelease_number}+{artifact_input.build_number}'
87
+ if artifact_input.scheme == ArtifactVersionScheme.docker:
88
+ return f'{next_version}-{prerelease_type}{prerelease_number}-{artifact_input.build_number}'
89
+ return _pep440_version(
90
+ next_version=next_version,
91
+ prerelease_type=artifact_input.prerelease_type,
92
+ prerelease_number=prerelease_number,
93
+ build_number=artifact_input.build_number,
94
+ )
95
+
96
+
97
+ def _compute_next_version() -> str:
98
+ _, info = open_repo()
99
+ cliff = GitCliff(repo_root=info.root)
100
+ return cliff.compute_next_version(bump='auto')
101
+
102
+
103
+ def _pep440_version(
104
+ *,
105
+ next_version: str,
106
+ prerelease_type: PrereleaseType,
107
+ prerelease_number: int | None,
108
+ build_number: int,
109
+ ) -> str:
110
+ marker = _PEP440_PRERELEASE_MARKERS[prerelease_type]
111
+ return f'{next_version}{marker}{prerelease_number}.dev{build_number}'
releez/cli.py ADDED
@@ -0,0 +1,559 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from releez.artifact_version import (
10
+ ArtifactVersionInput,
11
+ ArtifactVersionScheme,
12
+ PrereleaseType,
13
+ compute_artifact_version,
14
+ )
15
+ from releez.cliff import GitCliff, GitCliffBump
16
+ from releez.errors import (
17
+ ChangelogFormatCommandRequiredError,
18
+ ReleezError,
19
+ )
20
+ from releez.git_repo import create_tags, fetch, open_repo, push_tags
21
+ from releez.release import StartReleaseInput, start_release
22
+ from releez.settings import ReleezSettings
23
+ from releez.subapps import changelog_app
24
+ from releez.version_tags import AliasVersions, compute_version_tags, select_tags
25
+
26
+ app = typer.Typer(help='CLI tool for helping to manage release processes.')
27
+ release_app = typer.Typer(help='Release workflows (changelog + branch + PR).')
28
+ version_app = typer.Typer(help='Version utilities for CI/artifacts.')
29
+
30
+
31
+ @app.callback()
32
+ def _root(ctx: typer.Context) -> None:
33
+ settings = ReleezSettings()
34
+ ctx.obj = settings
35
+
36
+ default_map: dict[str, object] = {}
37
+ default_map['release'] = {
38
+ 'start': {
39
+ 'base': settings.base_branch,
40
+ 'remote': settings.git_remote,
41
+ 'labels': settings.pr_labels,
42
+ 'title_prefix': settings.pr_title_prefix,
43
+ 'changelog_path': settings.changelog_path,
44
+ 'create_pr': settings.create_pr,
45
+ 'run_changelog_format': settings.run_changelog_format,
46
+ 'changelog_format_cmd': settings.hooks.changelog_format,
47
+ },
48
+ 'tag': {
49
+ 'remote': settings.git_remote,
50
+ 'alias_versions': settings.alias_versions,
51
+ },
52
+ 'preview': {
53
+ 'alias_versions': settings.alias_versions,
54
+ },
55
+ }
56
+ default_map['version'] = {
57
+ 'artifact': {
58
+ 'alias_versions': settings.alias_versions,
59
+ },
60
+ }
61
+ default_map['changelog'] = {
62
+ 'regenerate': {
63
+ 'changelog_path': settings.changelog_path,
64
+ 'run_changelog_format': settings.run_changelog_format,
65
+ 'changelog_format_cmd': settings.hooks.changelog_format,
66
+ },
67
+ }
68
+
69
+ if ctx.default_map is None:
70
+ ctx.default_map = default_map
71
+ else:
72
+ ctx.default_map = {
73
+ **ctx.default_map,
74
+ **default_map,
75
+ }
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class _VersionArtifactArgs:
80
+ """CLI arguments for the `version artifact` command."""
81
+
82
+ scheme: ArtifactVersionScheme
83
+ version_override: str | None
84
+ is_full_release: bool
85
+ prerelease_type: PrereleaseType
86
+ prerelease_number: int | None
87
+ build_number: int | None
88
+
89
+
90
+ def _build_artifact_version_input(
91
+ *,
92
+ args: _VersionArtifactArgs,
93
+ ) -> ArtifactVersionInput:
94
+ return ArtifactVersionInput(
95
+ scheme=args.scheme,
96
+ version_override=args.version_override,
97
+ is_full_release=args.is_full_release,
98
+ prerelease_type=args.prerelease_type,
99
+ prerelease_number=args.prerelease_number,
100
+ build_number=args.build_number,
101
+ )
102
+
103
+
104
+ def _emit_artifact_version_output(
105
+ *,
106
+ artifact_version: str,
107
+ scheme: ArtifactVersionScheme,
108
+ is_full_release: bool,
109
+ alias_versions: AliasVersions,
110
+ ) -> None:
111
+ if scheme == ArtifactVersionScheme.pep440:
112
+ if alias_versions != AliasVersions.none:
113
+ typer.secho(
114
+ 'Note: --alias-versions is ignored for --scheme pep440.',
115
+ err=True,
116
+ fg=typer.colors.YELLOW,
117
+ )
118
+ typer.echo(artifact_version)
119
+ return
120
+
121
+ if alias_versions == AliasVersions.none:
122
+ typer.echo(artifact_version)
123
+ return
124
+
125
+ if not is_full_release:
126
+ typer.secho(
127
+ 'Note: --alias-versions is only applied for full releases; ignoring because --is-full-release is not set.',
128
+ err=True,
129
+ fg=typer.colors.YELLOW,
130
+ )
131
+ typer.echo(artifact_version)
132
+ return
133
+
134
+ tags = compute_version_tags(version=artifact_version)
135
+ for tag in select_tags(tags=tags, aliases=alias_versions):
136
+ typer.echo(tag)
137
+
138
+
139
+ def _resolve_release_version(
140
+ *,
141
+ repo_root: Path,
142
+ version_override: str | None,
143
+ ) -> str:
144
+ """Resolve the release version, defaulting to git-cliff."""
145
+ if version_override is not None:
146
+ return version_override
147
+ cliff = GitCliff(repo_root=repo_root)
148
+ return cliff.compute_next_version(bump='auto')
149
+
150
+
151
+ def _raise_changelog_format_command_required() -> None:
152
+ raise ChangelogFormatCommandRequiredError
153
+
154
+
155
+ @release_app.command('start')
156
+ def release_start( # noqa: PLR0913
157
+ *,
158
+ bump: Annotated[
159
+ GitCliffBump,
160
+ typer.Option(
161
+ help='Bump mode passed to git-cliff.',
162
+ show_default=True,
163
+ case_sensitive=False,
164
+ ),
165
+ ] = 'auto',
166
+ version_override: Annotated[
167
+ str | None,
168
+ typer.Option(
169
+ '--version-override',
170
+ help='Override version instead of computing via git-cliff.',
171
+ show_default=False,
172
+ ),
173
+ ] = None,
174
+ run_changelog_format: Annotated[
175
+ bool,
176
+ typer.Option(
177
+ '--run-changelog-format',
178
+ help='Run the configured changelog formatter before committing.',
179
+ show_default=True,
180
+ ),
181
+ ] = False,
182
+ changelog_format_cmd: Annotated[
183
+ list[str] | None,
184
+ typer.Option(
185
+ '--changelog-format-cmd',
186
+ help='Override changelog format command argv (repeatable).',
187
+ show_default=False,
188
+ ),
189
+ ] = None,
190
+ create_pr: Annotated[
191
+ bool,
192
+ typer.Option(
193
+ '--create-pr/--no-create-pr',
194
+ help='Create a GitHub PR (requires token).',
195
+ show_default=True,
196
+ ),
197
+ ] = False,
198
+ dry_run: Annotated[
199
+ bool,
200
+ typer.Option(
201
+ help='Compute version and notes without changing the repo.',
202
+ ),
203
+ ] = False,
204
+ base: Annotated[
205
+ str,
206
+ typer.Option(
207
+ help='Base branch for the release PR.',
208
+ show_default=True,
209
+ ),
210
+ ] = 'master',
211
+ remote: Annotated[
212
+ str,
213
+ typer.Option(
214
+ help='Remote name to use.',
215
+ show_default=True,
216
+ ),
217
+ ] = 'origin',
218
+ labels: Annotated[
219
+ str,
220
+ typer.Option(
221
+ help='Comma-separated label(s) to add to the PR (repeatable).',
222
+ show_default=True,
223
+ ),
224
+ ] = 'release',
225
+ title_prefix: Annotated[
226
+ str,
227
+ typer.Option(
228
+ help='Prefix for PR title.',
229
+ show_default=True,
230
+ ),
231
+ ] = 'chore(release): ',
232
+ changelog_path: Annotated[
233
+ str,
234
+ typer.Option(
235
+ '--changelog-path',
236
+ '--changelog',
237
+ help='Changelog file to prepend to.',
238
+ show_default=True,
239
+ ),
240
+ ] = 'CHANGELOG.md',
241
+ github_token: Annotated[
242
+ str | None,
243
+ typer.Option(
244
+ envvar=['RELEEZ_GITHUB_TOKEN', 'GITHUB_TOKEN'],
245
+ help='GitHub token for PR creation (prefer RELEEZ_GITHUB_TOKEN; falls back to GITHUB_TOKEN).',
246
+ show_default=False,
247
+ ),
248
+ ] = None,
249
+ ) -> None:
250
+ """Start a release branch and update the changelog.
251
+
252
+ Computes the next version using git-cliff, prepends the changelog, commits and pushes a
253
+ `release/<version>` branch, and optionally opens a GitHub PR.
254
+
255
+ Args:
256
+ bump: Bump mode for git-cliff.
257
+ version_override: Override the computed next version.
258
+ run_changelog_format: If true, run the configured changelog formatter before commit.
259
+ changelog_format_cmd: Override the configured changelog formatter argv.
260
+ create_pr: If true, create a GitHub pull request.
261
+ dry_run: If true, do not modify the repo; just output version and notes.
262
+ base: Base branch for the release PR.
263
+ remote: Remote name to use.
264
+ labels: Comma-separated labels to add to the PR.
265
+ title_prefix: Prefix for PR title.
266
+ changelog_path: Changelog file to prepend to.
267
+ github_token: GitHub token for PR creation.
268
+
269
+ Raises:
270
+ typer.Exit: If an error occurs during release processing.
271
+ """
272
+ try:
273
+ if run_changelog_format and not changelog_format_cmd:
274
+ _raise_changelog_format_command_required()
275
+
276
+ release_input = StartReleaseInput(
277
+ bump=bump,
278
+ version_override=version_override,
279
+ base_branch=base,
280
+ remote_name=remote,
281
+ labels=labels.split(',') if labels else [],
282
+ title_prefix=title_prefix,
283
+ changelog_path=changelog_path,
284
+ run_changelog_format=run_changelog_format,
285
+ changelog_format_cmd=changelog_format_cmd,
286
+ create_pr=create_pr,
287
+ github_token=github_token,
288
+ dry_run=dry_run,
289
+ )
290
+ result = start_release(release_input)
291
+ except ReleezError as exc:
292
+ typer.secho(str(exc), err=True, fg=typer.colors.RED)
293
+ raise typer.Exit(code=1) from exc
294
+ except Exception as exc: # pragma: no cover
295
+ typer.secho(f'Unexpected error: {exc}', err=True, fg=typer.colors.RED)
296
+ raise typer.Exit(code=1) from exc
297
+
298
+ typer.secho(f'Next version: {result.version}', fg=typer.colors.GREEN)
299
+ if dry_run:
300
+ typer.echo(result.release_notes_markdown)
301
+ return
302
+
303
+ typer.echo(f'Release branch: {result.release_branch}')
304
+ if result.pr_url:
305
+ typer.echo(f'PR created: {result.pr_url}')
306
+
307
+
308
+ @version_app.command('artifact')
309
+ def version_artifact( # noqa: PLR0913
310
+ *,
311
+ scheme: Annotated[
312
+ ArtifactVersionScheme,
313
+ typer.Option(
314
+ '--scheme',
315
+ help='Output scheme for the artifact version.',
316
+ show_default=True,
317
+ case_sensitive=False,
318
+ ),
319
+ ] = ArtifactVersionScheme.semver,
320
+ is_full_release: Annotated[
321
+ bool,
322
+ typer.Option(
323
+ help='If true, output a full release version without prerelease markers.',
324
+ show_default=True,
325
+ ),
326
+ ] = False,
327
+ prerelease_type: Annotated[
328
+ PrereleaseType,
329
+ typer.Option(
330
+ help='Prerelease label (alpha, beta, rc).',
331
+ show_default=True,
332
+ case_sensitive=False,
333
+ ),
334
+ ] = PrereleaseType.alpha,
335
+ prerelease_number: Annotated[
336
+ int | None,
337
+ typer.Option(
338
+ help='Optional prerelease number (e.g. PR number for alpha123).',
339
+ show_default=False,
340
+ ),
341
+ ] = None,
342
+ build_number: Annotated[
343
+ int | None,
344
+ typer.Option(
345
+ help='Build number for prerelease builds.',
346
+ show_default=False,
347
+ ),
348
+ ] = None,
349
+ version_override: Annotated[
350
+ str | None,
351
+ typer.Option(
352
+ '--version-override',
353
+ help='Override version instead of computing via git-cliff.',
354
+ show_default=False,
355
+ ),
356
+ ] = None,
357
+ alias_versions: Annotated[
358
+ AliasVersions,
359
+ typer.Option(
360
+ '--alias-versions',
361
+ help='For full releases, also output major/minor tags.',
362
+ show_default=True,
363
+ case_sensitive=False,
364
+ ),
365
+ ] = AliasVersions.none,
366
+ ) -> None:
367
+ """Compute an artifact version string."""
368
+ try:
369
+ artifact_args = _VersionArtifactArgs(
370
+ scheme=scheme,
371
+ version_override=version_override,
372
+ is_full_release=is_full_release,
373
+ prerelease_type=prerelease_type,
374
+ prerelease_number=prerelease_number,
375
+ build_number=build_number,
376
+ )
377
+ artifact_input = _build_artifact_version_input(args=artifact_args)
378
+ artifact_version = compute_artifact_version(artifact_input)
379
+ _emit_artifact_version_output(
380
+ artifact_version=artifact_version,
381
+ scheme=scheme,
382
+ is_full_release=is_full_release,
383
+ alias_versions=alias_versions,
384
+ )
385
+ except ReleezError as exc:
386
+ typer.secho(str(exc), err=True, fg=typer.colors.RED)
387
+ raise typer.Exit(code=1) from exc
388
+
389
+
390
+ @release_app.command('tag')
391
+ def release_tag(
392
+ *,
393
+ version_override: Annotated[
394
+ str | None,
395
+ typer.Option(
396
+ '--version-override',
397
+ help='Override release version to tag (x.y.z).',
398
+ show_default=False,
399
+ ),
400
+ ] = None,
401
+ alias_versions: Annotated[
402
+ AliasVersions,
403
+ typer.Option(
404
+ '--alias-versions',
405
+ help='Also create major/minor tags (v2, v2.3).',
406
+ show_default=True,
407
+ case_sensitive=False,
408
+ ),
409
+ ] = AliasVersions.none,
410
+ remote: Annotated[
411
+ str,
412
+ typer.Option(
413
+ '--remote',
414
+ help='Remote to push tags to.',
415
+ show_default=True,
416
+ ),
417
+ ] = 'origin',
418
+ ) -> None:
419
+ """Create git tag(s) for a release and push them."""
420
+ try:
421
+ repo, _info = open_repo()
422
+ fetch(repo, remote_name=remote)
423
+ version = _resolve_release_version(
424
+ repo_root=_info.root,
425
+ version_override=version_override,
426
+ )
427
+ tags = compute_version_tags(version=version)
428
+ selected = select_tags(tags=tags, aliases=alias_versions)
429
+ exact_tags = selected[:1]
430
+ alias_only_tags = selected[1:]
431
+
432
+ create_tags(repo, tags=exact_tags, force=False)
433
+ push_tags(repo, remote_name=remote, tags=exact_tags, force=False)
434
+
435
+ if alias_only_tags:
436
+ create_tags(repo, tags=alias_only_tags, force=True)
437
+ push_tags(
438
+ repo,
439
+ remote_name=remote,
440
+ tags=alias_only_tags,
441
+ force=True,
442
+ )
443
+ except ReleezError as exc:
444
+ typer.secho(str(exc), err=True, fg=typer.colors.RED)
445
+ raise typer.Exit(code=1) from exc
446
+
447
+ for tag in selected:
448
+ typer.echo(tag)
449
+
450
+
451
+ @release_app.command('preview')
452
+ def release_preview(
453
+ *,
454
+ version_override: Annotated[
455
+ str | None,
456
+ typer.Option(
457
+ '--version-override',
458
+ help='Override release version to preview (x.y.z).',
459
+ show_default=False,
460
+ ),
461
+ ] = None,
462
+ alias_versions: Annotated[
463
+ AliasVersions,
464
+ typer.Option(
465
+ '--alias-versions',
466
+ help='Include major/minor tags in the preview.',
467
+ show_default=True,
468
+ case_sensitive=False,
469
+ ),
470
+ ] = AliasVersions.none,
471
+ output: Annotated[
472
+ Path | None,
473
+ typer.Option(
474
+ '--output',
475
+ help='Write markdown preview to a file instead of stdout.',
476
+ show_default=False,
477
+ ),
478
+ ] = None,
479
+ ) -> None:
480
+ """Preview the version and tags that would be published."""
481
+ try:
482
+ _repo, info = open_repo()
483
+ version = _resolve_release_version(
484
+ repo_root=info.root,
485
+ version_override=version_override,
486
+ )
487
+
488
+ computed = compute_version_tags(version=version)
489
+ tags = select_tags(tags=computed, aliases=alias_versions)
490
+
491
+ markdown = '\n'.join(
492
+ [
493
+ '## `releez` release preview',
494
+ '',
495
+ f'- Version: `{version}`',
496
+ '- Tags:',
497
+ *[f' - `{tag}`' for tag in tags],
498
+ '',
499
+ ],
500
+ )
501
+
502
+ if output is not None:
503
+ output_path = Path(output)
504
+ output_path.write_text(markdown, encoding='utf-8')
505
+ else:
506
+ typer.echo(markdown)
507
+ except ReleezError as exc:
508
+ typer.secho(str(exc), err=True, fg=typer.colors.RED)
509
+ raise typer.Exit(code=1) from exc
510
+
511
+
512
+ @release_app.command('notes')
513
+ def release_notes(
514
+ *,
515
+ version_override: Annotated[
516
+ str | None,
517
+ typer.Option(
518
+ '--version-override',
519
+ help='Override release version for the notes section (x.y.z).',
520
+ show_default=False,
521
+ ),
522
+ ] = None,
523
+ output: Annotated[
524
+ Path | None,
525
+ typer.Option(
526
+ '--output',
527
+ help='Write release notes to a file instead of stdout.',
528
+ show_default=False,
529
+ ),
530
+ ] = None,
531
+ ) -> None:
532
+ """Generate the new changelog section for the release."""
533
+ try:
534
+ _, info = open_repo()
535
+ version = _resolve_release_version(
536
+ repo_root=info.root,
537
+ version_override=version_override,
538
+ )
539
+ cliff = GitCliff(repo_root=info.root)
540
+ notes = cliff.generate_unreleased_notes(version=version)
541
+
542
+ if output is not None:
543
+ output_path = Path(output)
544
+ output_path.write_text(notes, encoding='utf-8')
545
+ else:
546
+ typer.echo(notes)
547
+ except ReleezError as exc:
548
+ typer.secho(str(exc), err=True, fg=typer.colors.RED)
549
+ raise typer.Exit(code=1) from exc
550
+
551
+
552
+ app.add_typer(release_app, name='release')
553
+ app.add_typer(version_app, name='version')
554
+ app.add_typer(changelog_app, name='changelog')
555
+
556
+
557
+ def main() -> None:
558
+ """Main entry point for the CLI."""
559
+ app()