semvertag 0.1.0__tar.gz
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.
- semvertag-0.1.0/PKG-INFO +71 -0
- semvertag-0.1.0/README.md +45 -0
- semvertag-0.1.0/pyproject.toml +93 -0
- semvertag-0.1.0/semvertag/__init__.py +0 -0
- semvertag-0.1.0/semvertag/__main__.py +178 -0
- semvertag-0.1.0/semvertag/_commit_parse.py +29 -0
- semvertag-0.1.0/semvertag/_errors.py +17 -0
- semvertag-0.1.0/semvertag/_output.py +78 -0
- semvertag-0.1.0/semvertag/_redact.py +20 -0
- semvertag-0.1.0/semvertag/_settings.py +107 -0
- semvertag-0.1.0/semvertag/_transport.py +84 -0
- semvertag-0.1.0/semvertag/_types.py +46 -0
- semvertag-0.1.0/semvertag/_use_case.py +127 -0
- semvertag-0.1.0/semvertag/ioc.py +101 -0
- semvertag-0.1.0/semvertag/providers/__init__.py +0 -0
- semvertag-0.1.0/semvertag/providers/_base.py +12 -0
- semvertag-0.1.0/semvertag/providers/_http.py +62 -0
- semvertag-0.1.0/semvertag/providers/gitlab.py +219 -0
- semvertag-0.1.0/semvertag/py.typed +0 -0
- semvertag-0.1.0/semvertag/strategies/__init__.py +0 -0
- semvertag-0.1.0/semvertag/strategies/_base.py +11 -0
- semvertag-0.1.0/semvertag/strategies/branch_prefix.py +36 -0
- semvertag-0.1.0/semvertag/strategies/conventional_commits.py +55 -0
semvertag-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: semvertag
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Auto-tag GitLab repos with semantic version tags — one tool, two strategies.
|
|
5
|
+
Keywords: semver,gitlab,ci,auto-tag,conventional-commits
|
|
6
|
+
Author: Artur Shiriev
|
|
7
|
+
Author-email: Artur Shiriev <me@shiriev.ru>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Requires-Dist: typer
|
|
17
|
+
Requires-Dist: rich
|
|
18
|
+
Requires-Dist: semver
|
|
19
|
+
Requires-Dist: pydantic-settings
|
|
20
|
+
Requires-Dist: modern-di-typer
|
|
21
|
+
Requires-Dist: httpx2
|
|
22
|
+
Requires-Python: >=3.10, <4
|
|
23
|
+
Project-URL: repository, https://github.com/modern-python/semvertag
|
|
24
|
+
Project-URL: docs, https://semvertag.readthedocs.io
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# semvertag
|
|
28
|
+
|
|
29
|
+
[](https://github.com/modern-python/semvertag/actions/workflows/ci.yml)
|
|
30
|
+
[](https://codecov.io/gh/modern-python/semvertag)
|
|
31
|
+
|
|
32
|
+
Auto-tag your GitLab repository with semantic version tags from CI — one tool, two strategies.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
uvx semvertag tag
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Use it in GitLab CI
|
|
41
|
+
|
|
42
|
+
Include the Catalog component in your `.gitlab-ci.yml`:
|
|
43
|
+
|
|
44
|
+
```yaml
|
|
45
|
+
include:
|
|
46
|
+
- component: gitlab.com/modern-python/semvertag/semvertag@v0.1.0
|
|
47
|
+
inputs:
|
|
48
|
+
strategy: branch-prefix # or: conventional-commits
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The component runs `uvx semvertag tag` against your repo on the
|
|
52
|
+
default branch. semvertag inspects the latest commit + tag history,
|
|
53
|
+
decides the appropriate semver bump, and creates the new tag via the
|
|
54
|
+
GitLab API.
|
|
55
|
+
|
|
56
|
+
## Strategies
|
|
57
|
+
|
|
58
|
+
- **branch-prefix** (default): the latest commit on the default branch
|
|
59
|
+
must be a merge commit whose source branch starts with `feature/`
|
|
60
|
+
(minor), `bugfix/`, or `hotfix/` (patch).
|
|
61
|
+
- **conventional-commits**: parses the latest commit's
|
|
62
|
+
[Conventional Commits](https://www.conventionalcommits.org/)
|
|
63
|
+
header (`feat:` minor, `fix:`/`perf:` patch, `!` or `BREAKING
|
|
64
|
+
CHANGE:` major).
|
|
65
|
+
|
|
66
|
+
Both are configurable via env vars. See [docs](https://semvertag.readthedocs.io)
|
|
67
|
+
for the full configuration surface.
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# semvertag
|
|
2
|
+
|
|
3
|
+
[](https://github.com/modern-python/semvertag/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/modern-python/semvertag)
|
|
5
|
+
|
|
6
|
+
Auto-tag your GitLab repository with semantic version tags from CI — one tool, two strategies.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
uvx semvertag tag
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Use it in GitLab CI
|
|
15
|
+
|
|
16
|
+
Include the Catalog component in your `.gitlab-ci.yml`:
|
|
17
|
+
|
|
18
|
+
```yaml
|
|
19
|
+
include:
|
|
20
|
+
- component: gitlab.com/modern-python/semvertag/semvertag@v0.1.0
|
|
21
|
+
inputs:
|
|
22
|
+
strategy: branch-prefix # or: conventional-commits
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The component runs `uvx semvertag tag` against your repo on the
|
|
26
|
+
default branch. semvertag inspects the latest commit + tag history,
|
|
27
|
+
decides the appropriate semver bump, and creates the new tag via the
|
|
28
|
+
GitLab API.
|
|
29
|
+
|
|
30
|
+
## Strategies
|
|
31
|
+
|
|
32
|
+
- **branch-prefix** (default): the latest commit on the default branch
|
|
33
|
+
must be a merge commit whose source branch starts with `feature/`
|
|
34
|
+
(minor), `bugfix/`, or `hotfix/` (patch).
|
|
35
|
+
- **conventional-commits**: parses the latest commit's
|
|
36
|
+
[Conventional Commits](https://www.conventionalcommits.org/)
|
|
37
|
+
header (`feat:` minor, `fix:`/`perf:` patch, `!` or `BREAKING
|
|
38
|
+
CHANGE:` major).
|
|
39
|
+
|
|
40
|
+
Both are configurable via env vars. See [docs](https://semvertag.readthedocs.io)
|
|
41
|
+
for the full configuration surface.
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
MIT
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "semvertag"
|
|
3
|
+
description = "Auto-tag GitLab repos with semantic version tags — one tool, two strategies."
|
|
4
|
+
authors = [{ name = "Artur Shiriev", email = "me@shiriev.ru" }]
|
|
5
|
+
requires-python = ">=3.10,<4"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
keywords = ["semver", "gitlab", "ci", "auto-tag", "conventional-commits"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Programming Language :: Python :: 3.10",
|
|
11
|
+
"Programming Language :: Python :: 3.11",
|
|
12
|
+
"Programming Language :: Python :: 3.12",
|
|
13
|
+
"Programming Language :: Python :: 3.13",
|
|
14
|
+
"Programming Language :: Python :: 3.14",
|
|
15
|
+
"Typing :: Typed",
|
|
16
|
+
"Topic :: Software Development :: Libraries",
|
|
17
|
+
]
|
|
18
|
+
version = "0.1.0"
|
|
19
|
+
dependencies = [
|
|
20
|
+
"typer",
|
|
21
|
+
"rich",
|
|
22
|
+
"semver",
|
|
23
|
+
"pydantic-settings",
|
|
24
|
+
"modern-di-typer",
|
|
25
|
+
"httpx2",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
semvertag = "semvertag.__main__:main"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
repository = "https://github.com/modern-python/semvertag"
|
|
33
|
+
docs = "https://semvertag.readthedocs.io"
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["uv_build"]
|
|
37
|
+
build-backend = "uv_build"
|
|
38
|
+
|
|
39
|
+
[tool.uv.build-backend]
|
|
40
|
+
module-name = "semvertag"
|
|
41
|
+
module-root = ""
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"pytest",
|
|
46
|
+
"pytest-cov",
|
|
47
|
+
"pytest-xdist",
|
|
48
|
+
"pytest-randomly",
|
|
49
|
+
]
|
|
50
|
+
lint = [
|
|
51
|
+
"ruff",
|
|
52
|
+
"ty",
|
|
53
|
+
"eof-fixer",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
fix = true
|
|
58
|
+
unsafe-fixes = true
|
|
59
|
+
line-length = 120
|
|
60
|
+
target-version = "py310"
|
|
61
|
+
extend-exclude = ["docs", "_autosemver_reference", "_archive/bmad"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["ALL"]
|
|
65
|
+
ignore = [
|
|
66
|
+
"D1",
|
|
67
|
+
"FBT",
|
|
68
|
+
"TRY003",
|
|
69
|
+
"EM102",
|
|
70
|
+
"D203",
|
|
71
|
+
"D212",
|
|
72
|
+
"COM812",
|
|
73
|
+
"ISC001",
|
|
74
|
+
"S105",
|
|
75
|
+
]
|
|
76
|
+
isort.lines-after-imports = 2
|
|
77
|
+
isort.no-lines-before = ["standard-library", "local-folder"]
|
|
78
|
+
|
|
79
|
+
[tool.ruff.lint.extend-per-file-ignores]
|
|
80
|
+
"tests/**/*.py" = ["S101", "SLF001"]
|
|
81
|
+
|
|
82
|
+
[tool.pytest.ini_options]
|
|
83
|
+
addopts = "--cov=. --cov-report term-missing"
|
|
84
|
+
testpaths = ["tests"]
|
|
85
|
+
|
|
86
|
+
[tool.coverage.report]
|
|
87
|
+
exclude_also = ["if typing.TYPE_CHECKING:", "if TYPE_CHECKING:"]
|
|
88
|
+
|
|
89
|
+
[tool.coverage.run]
|
|
90
|
+
omit = ["_autosemver_reference/*", "_archive/bmad/*", "tests/*"]
|
|
91
|
+
|
|
92
|
+
[tool.ty.src]
|
|
93
|
+
exclude = ["_autosemver_reference", "_archive/bmad", "docs"]
|
|
File without changes
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import errno
|
|
2
|
+
import importlib.metadata
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
import modern_di_typer
|
|
6
|
+
import pydantic
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from semvertag import ioc
|
|
10
|
+
from semvertag._errors import ConfigError, SemvertagError
|
|
11
|
+
from semvertag._output import Output, build_json_output, build_rich_output
|
|
12
|
+
from semvertag._settings import Settings, apply_cli_overlay
|
|
13
|
+
from semvertag._use_case import SemvertagUseCase
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_PACKAGE_NAME: typing.Final = "semvertag"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
MAIN_APP: typing.Final = typer.Typer(
|
|
20
|
+
name="semvertag",
|
|
21
|
+
help=("Auto-tag GitLab repos with semantic version tags — one tool, two strategies."),
|
|
22
|
+
no_args_is_help=True,
|
|
23
|
+
add_completion=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
modern_di_typer.setup_di(MAIN_APP, ioc.container)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _version_callback(value: bool) -> None:
|
|
30
|
+
if not value:
|
|
31
|
+
return
|
|
32
|
+
try:
|
|
33
|
+
version = importlib.metadata.version(_PACKAGE_NAME)
|
|
34
|
+
except importlib.metadata.PackageNotFoundError:
|
|
35
|
+
version = "0"
|
|
36
|
+
typer.echo(version)
|
|
37
|
+
raise typer.Exit
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _collect_overrides( # noqa: PLR0913
|
|
41
|
+
*,
|
|
42
|
+
project_id: int | None,
|
|
43
|
+
strategy: str | None,
|
|
44
|
+
token: str | None,
|
|
45
|
+
default_branch: str | None,
|
|
46
|
+
gitlab_endpoint: str | None,
|
|
47
|
+
request_timeout: float | None,
|
|
48
|
+
) -> dict[str, typing.Any]:
|
|
49
|
+
overrides: dict[str, typing.Any] = {}
|
|
50
|
+
if project_id is not None:
|
|
51
|
+
overrides["project_id"] = project_id
|
|
52
|
+
if strategy is not None:
|
|
53
|
+
overrides["strategy"] = strategy
|
|
54
|
+
if token is not None:
|
|
55
|
+
overrides["gitlab.token"] = pydantic.SecretStr(token)
|
|
56
|
+
if default_branch is not None:
|
|
57
|
+
overrides["default_branch"] = default_branch
|
|
58
|
+
if gitlab_endpoint is not None:
|
|
59
|
+
overrides["gitlab.endpoint"] = gitlab_endpoint
|
|
60
|
+
if request_timeout is not None:
|
|
61
|
+
overrides["request_timeout"] = request_timeout
|
|
62
|
+
return overrides
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _config_error_from_validation(exc: pydantic.ValidationError) -> ConfigError:
|
|
66
|
+
first: typing.Final = exc.errors()[0]
|
|
67
|
+
loc: typing.Final = ".".join(str(part) for part in first.get("loc", ()))
|
|
68
|
+
detail: typing.Final = first.get("msg", "invalid value")
|
|
69
|
+
msg: typing.Final = f"Configuration error at '{loc}': {detail}. Check environment variables and command-line flags."
|
|
70
|
+
return ConfigError(msg)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@MAIN_APP.callback()
|
|
74
|
+
def _main_callback( # noqa: PLR0913
|
|
75
|
+
ctx: typer.Context,
|
|
76
|
+
project_id: typing.Annotated[
|
|
77
|
+
int | None,
|
|
78
|
+
typer.Option("--project-id", help="GitLab project id (or set CI_PROJECT_ID)."),
|
|
79
|
+
] = None,
|
|
80
|
+
strategy: typing.Annotated[
|
|
81
|
+
str | None,
|
|
82
|
+
typer.Option("--strategy", help="Bump strategy: branch-prefix | conventional-commits."),
|
|
83
|
+
] = None,
|
|
84
|
+
token: typing.Annotated[
|
|
85
|
+
str | None,
|
|
86
|
+
typer.Option("--token", help="API token (overrides SEMVERTAG_TOKEN)."),
|
|
87
|
+
] = None,
|
|
88
|
+
default_branch: typing.Annotated[
|
|
89
|
+
str | None,
|
|
90
|
+
typer.Option("--default-branch", help="Default branch name override."),
|
|
91
|
+
] = None,
|
|
92
|
+
gitlab_endpoint: typing.Annotated[
|
|
93
|
+
str | None,
|
|
94
|
+
typer.Option("--gitlab-endpoint", help="GitLab API endpoint URL."),
|
|
95
|
+
] = None,
|
|
96
|
+
request_timeout: typing.Annotated[
|
|
97
|
+
float | None,
|
|
98
|
+
typer.Option("--request-timeout", help="Per-request timeout in seconds (clamped to 10)."),
|
|
99
|
+
] = None,
|
|
100
|
+
_version: typing.Annotated[
|
|
101
|
+
bool | None,
|
|
102
|
+
typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version and exit."),
|
|
103
|
+
] = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
if ctx.resilient_parsing:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
settings = Settings()
|
|
110
|
+
try:
|
|
111
|
+
overrides = _collect_overrides(
|
|
112
|
+
project_id=project_id,
|
|
113
|
+
strategy=strategy,
|
|
114
|
+
token=token,
|
|
115
|
+
default_branch=default_branch,
|
|
116
|
+
gitlab_endpoint=gitlab_endpoint,
|
|
117
|
+
request_timeout=request_timeout,
|
|
118
|
+
)
|
|
119
|
+
settings = apply_cli_overlay(settings, overrides)
|
|
120
|
+
except ValueError as exc:
|
|
121
|
+
raise ConfigError(str(exc)) from exc
|
|
122
|
+
except pydantic.ValidationError as exc:
|
|
123
|
+
err = _config_error_from_validation(exc)
|
|
124
|
+
typer.echo(f"Error: {err}", err=True)
|
|
125
|
+
raise typer.Exit(code=err.exit_code) from err
|
|
126
|
+
except ConfigError as err:
|
|
127
|
+
typer.echo(f"Error: {err}", err=True)
|
|
128
|
+
raise typer.Exit(code=err.exit_code) from err
|
|
129
|
+
|
|
130
|
+
app_container = modern_di_typer.fetch_di_container(ctx)
|
|
131
|
+
app_container.set_context(Settings, settings)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@modern_di_typer.inject
|
|
135
|
+
def _resolve_use_case(
|
|
136
|
+
use_case: typing.Annotated[SemvertagUseCase, modern_di_typer.FromDI(SemvertagUseCase)],
|
|
137
|
+
) -> SemvertagUseCase:
|
|
138
|
+
return use_case
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@MAIN_APP.command("tag")
|
|
142
|
+
def _tag_command(
|
|
143
|
+
ctx: typer.Context,
|
|
144
|
+
quiet: typing.Annotated[
|
|
145
|
+
bool,
|
|
146
|
+
typer.Option("--quiet", help="Suppress progress narrative; final result still emits."),
|
|
147
|
+
] = False,
|
|
148
|
+
json_flag: typing.Annotated[
|
|
149
|
+
bool,
|
|
150
|
+
typer.Option("--json", help="Emit a JSON envelope on stdout instead of human-readable output."),
|
|
151
|
+
] = False,
|
|
152
|
+
) -> None:
|
|
153
|
+
output: Output = build_json_output(quiet=quiet) if json_flag else build_rich_output(quiet=quiet)
|
|
154
|
+
try:
|
|
155
|
+
use_case = _resolve_use_case(ctx=ctx)
|
|
156
|
+
use_case(output=output)
|
|
157
|
+
except ImportError as exc:
|
|
158
|
+
err = ConfigError(f"Required module unavailable: {exc}.")
|
|
159
|
+
output.error(str(err))
|
|
160
|
+
raise typer.Exit(code=err.exit_code) from exc
|
|
161
|
+
except SemvertagError as err:
|
|
162
|
+
output.error(str(err))
|
|
163
|
+
raise typer.Exit(code=err.exit_code) from err
|
|
164
|
+
except BrokenPipeError as exc:
|
|
165
|
+
raise typer.Exit(code=0) from exc
|
|
166
|
+
except OSError as exc:
|
|
167
|
+
if exc.errno == errno.EPIPE:
|
|
168
|
+
raise typer.Exit(code=0) from exc
|
|
169
|
+
raise
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main() -> None:
|
|
173
|
+
with ioc.container:
|
|
174
|
+
MAIN_APP()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__": # pragma: no cover
|
|
178
|
+
main()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def subject_line(message: str) -> str:
|
|
5
|
+
for line in message.splitlines():
|
|
6
|
+
if line.strip():
|
|
7
|
+
return line.rstrip()
|
|
8
|
+
return ""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def body_lines(message: str) -> list[str]:
|
|
12
|
+
lines: typing.Final = message.splitlines()
|
|
13
|
+
subject_seen = False
|
|
14
|
+
separator_seen = False
|
|
15
|
+
collected: list[str] = []
|
|
16
|
+
for line in lines:
|
|
17
|
+
if not subject_seen:
|
|
18
|
+
if line.strip():
|
|
19
|
+
subject_seen = True
|
|
20
|
+
continue
|
|
21
|
+
if not separator_seen:
|
|
22
|
+
if not line.strip():
|
|
23
|
+
separator_seen = True
|
|
24
|
+
continue
|
|
25
|
+
collected.append(line.rstrip())
|
|
26
|
+
continue
|
|
27
|
+
if line.strip():
|
|
28
|
+
collected.append(line.rstrip())
|
|
29
|
+
return collected
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SemvertagError(Exception):
|
|
5
|
+
exit_code: typing.ClassVar[int] = 1
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(SemvertagError):
|
|
9
|
+
exit_code: typing.ClassVar[int] = 2
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthError(SemvertagError):
|
|
13
|
+
exit_code: typing.ClassVar[int] = 3
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProviderAPIError(SemvertagError):
|
|
17
|
+
exit_code: typing.ClassVar[int] = 4
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
import rich.console
|
|
7
|
+
|
|
8
|
+
from semvertag._redact import redact
|
|
9
|
+
from semvertag._types import RunResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_COMMIT_SHORT_LEN: typing.Final = 7
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Output(typing.Protocol):
|
|
16
|
+
def progress(self, message: str) -> None: ...
|
|
17
|
+
def emit(self, result: RunResult) -> None: ...
|
|
18
|
+
def error(self, message: str) -> None: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
22
|
+
class RichOutput:
|
|
23
|
+
info_console: rich.console.Console
|
|
24
|
+
error_console: rich.console.Console
|
|
25
|
+
quiet: bool = False
|
|
26
|
+
|
|
27
|
+
def progress(self, message: str) -> None:
|
|
28
|
+
if self.quiet:
|
|
29
|
+
return
|
|
30
|
+
self.info_console.print(redact(message), markup=False, highlight=False)
|
|
31
|
+
|
|
32
|
+
def emit(self, result: RunResult) -> None:
|
|
33
|
+
self.info_console.print(redact(_format_result(result)), markup=False, highlight=False)
|
|
34
|
+
|
|
35
|
+
def error(self, message: str) -> None:
|
|
36
|
+
self.error_console.print(redact(message), markup=False, highlight=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
|
|
40
|
+
class JsonOutput:
|
|
41
|
+
error_console: rich.console.Console
|
|
42
|
+
quiet: bool = False
|
|
43
|
+
|
|
44
|
+
def progress(self, message: str) -> None:
|
|
45
|
+
_ = message
|
|
46
|
+
|
|
47
|
+
def emit(self, result: RunResult) -> None:
|
|
48
|
+
payload: typing.Final = json.dumps(dataclasses.asdict(result), separators=(",", ":"))
|
|
49
|
+
sys.stdout.write(payload + "\n")
|
|
50
|
+
sys.stdout.flush()
|
|
51
|
+
|
|
52
|
+
def error(self, message: str) -> None:
|
|
53
|
+
self.error_console.print(redact(message), markup=False, highlight=False)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _format_result(result: RunResult) -> str:
|
|
57
|
+
if result.status == "created":
|
|
58
|
+
short: typing.Final = (result.commit or "")[:_COMMIT_SHORT_LEN]
|
|
59
|
+
return f"Created tag {result.tag} on commit {short} (strategy: {result.strategy}, bump: {result.bump})"
|
|
60
|
+
return (
|
|
61
|
+
f"No tag created (status: {result.status}, strategy: {result.strategy}, "
|
|
62
|
+
f"bump: {result.bump}, reason: {result.reason})"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_rich_output(*, quiet: bool = False) -> RichOutput:
|
|
67
|
+
return RichOutput(
|
|
68
|
+
info_console=rich.console.Console(),
|
|
69
|
+
error_console=rich.console.Console(stderr=True),
|
|
70
|
+
quiet=quiet,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_json_output(*, quiet: bool = False) -> JsonOutput:
|
|
75
|
+
return JsonOutput(
|
|
76
|
+
error_console=rich.console.Console(stderr=True),
|
|
77
|
+
quiet=quiet,
|
|
78
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
_REDACTION: typing.Final = "***"
|
|
6
|
+
_TOKEN_PATTERN: typing.Final = re.compile(
|
|
7
|
+
r"glpat-[A-Za-z0-9_\-]{20,}"
|
|
8
|
+
r"|github_pat_[A-Za-z0-9_]{20,}"
|
|
9
|
+
r"|ghp_[A-Za-z0-9]{20,}"
|
|
10
|
+
r"|gho_[A-Za-z0-9]{20,}"
|
|
11
|
+
r"|ghu_[A-Za-z0-9]{20,}"
|
|
12
|
+
r"|ghs_[A-Za-z0-9]{20,}"
|
|
13
|
+
r"|ghr_[A-Za-z0-9]{20,}"
|
|
14
|
+
r"|ATBB[A-Za-z0-9]{20,}"
|
|
15
|
+
r"|\b[0-9a-fA-F]{32,}\b",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def redact(text: str) -> str:
|
|
20
|
+
return _TOKEN_PATTERN.sub(_REDACTION, text)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
import pydantic
|
|
5
|
+
import pydantic_settings
|
|
6
|
+
|
|
7
|
+
from semvertag.strategies.branch_prefix import BranchPrefixConfig
|
|
8
|
+
from semvertag.strategies.conventional_commits import ConventionalCommitsConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_logger: typing.Final = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_REQUEST_TIMEOUT_CEILING: typing.Final = 10.0
|
|
14
|
+
_ENV_PREFIX: typing.Final = "SEMVERTAG_"
|
|
15
|
+
_ENV_NESTED_DELIMITER: typing.Final = "__"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GitLabConfig(pydantic_settings.BaseSettings):
|
|
19
|
+
model_config = pydantic_settings.SettingsConfigDict(
|
|
20
|
+
env_prefix="SEMVERTAG_GITLAB__",
|
|
21
|
+
case_sensitive=False,
|
|
22
|
+
extra="ignore",
|
|
23
|
+
populate_by_name=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
endpoint: str = "https://gitlab.com"
|
|
27
|
+
token: pydantic.SecretStr = pydantic.Field(
|
|
28
|
+
default=pydantic.SecretStr(""),
|
|
29
|
+
validation_alias=pydantic.AliasChoices(
|
|
30
|
+
"SEMVERTAG_GITLAB__TOKEN",
|
|
31
|
+
"SEMVERTAG_TOKEN",
|
|
32
|
+
"CI_JOB_TOKEN",
|
|
33
|
+
"GITLAB_TOKEN",
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GitHubConfig(pydantic_settings.BaseSettings):
|
|
39
|
+
model_config = pydantic_settings.SettingsConfigDict(
|
|
40
|
+
env_prefix="SEMVERTAG_GITHUB__",
|
|
41
|
+
case_sensitive=False,
|
|
42
|
+
extra="ignore",
|
|
43
|
+
populate_by_name=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
token: pydantic.SecretStr = pydantic.Field(
|
|
47
|
+
default=pydantic.SecretStr(""),
|
|
48
|
+
validation_alias=pydantic.AliasChoices(
|
|
49
|
+
"SEMVERTAG_GITHUB__TOKEN",
|
|
50
|
+
"SEMVERTAG_TOKEN",
|
|
51
|
+
"GITHUB_TOKEN",
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Settings(pydantic_settings.BaseSettings):
|
|
57
|
+
model_config = pydantic_settings.SettingsConfigDict(
|
|
58
|
+
env_prefix=_ENV_PREFIX,
|
|
59
|
+
env_nested_delimiter=_ENV_NESTED_DELIMITER,
|
|
60
|
+
case_sensitive=False,
|
|
61
|
+
extra="ignore",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
strategy: typing.Literal["branch-prefix", "conventional-commits"] = "branch-prefix"
|
|
65
|
+
default_branch: str | None = None
|
|
66
|
+
request_timeout: float = pydantic.Field(default=8.0, gt=0)
|
|
67
|
+
project_id: int | None = pydantic.Field(
|
|
68
|
+
default=None,
|
|
69
|
+
validation_alias=pydantic.AliasChoices("SEMVERTAG_PROJECT_ID", "CI_PROJECT_ID"),
|
|
70
|
+
)
|
|
71
|
+
gitlab: GitLabConfig = pydantic.Field(default_factory=GitLabConfig)
|
|
72
|
+
github: GitHubConfig = pydantic.Field(default_factory=GitHubConfig)
|
|
73
|
+
branch_prefix: BranchPrefixConfig = pydantic.Field(default_factory=BranchPrefixConfig)
|
|
74
|
+
conventional_commits: ConventionalCommitsConfig = pydantic.Field(default_factory=ConventionalCommitsConfig)
|
|
75
|
+
|
|
76
|
+
@pydantic.field_validator("request_timeout")
|
|
77
|
+
@classmethod
|
|
78
|
+
def _clamp_request_timeout(cls, value: float) -> float:
|
|
79
|
+
if value > _REQUEST_TIMEOUT_CEILING:
|
|
80
|
+
_logger.warning(
|
|
81
|
+
"request_timeout=%.3f exceeds ceiling %.1f; clamping to %.1f",
|
|
82
|
+
value,
|
|
83
|
+
_REQUEST_TIMEOUT_CEILING,
|
|
84
|
+
_REQUEST_TIMEOUT_CEILING,
|
|
85
|
+
)
|
|
86
|
+
return _REQUEST_TIMEOUT_CEILING
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def apply_cli_overlay(settings: Settings, overrides: dict[str, typing.Any]) -> Settings:
|
|
91
|
+
top_updates: dict[str, typing.Any] = {}
|
|
92
|
+
nested_updates: dict[str, dict[str, typing.Any]] = {}
|
|
93
|
+
for dotted_key, value in overrides.items():
|
|
94
|
+
head, _, leaf = dotted_key.partition(".")
|
|
95
|
+
if "." in leaf:
|
|
96
|
+
msg = f"CLI overlay key '{dotted_key}' exceeds nesting depth 2."
|
|
97
|
+
raise ValueError(msg)
|
|
98
|
+
if leaf:
|
|
99
|
+
nested_updates.setdefault(head, {})[leaf] = value
|
|
100
|
+
else:
|
|
101
|
+
top_updates[head] = value
|
|
102
|
+
for head, leaves in nested_updates.items():
|
|
103
|
+
top_updates[head] = getattr(settings, head).model_copy(update=leaves)
|
|
104
|
+
copied = settings.model_copy(update=top_updates)
|
|
105
|
+
# Re-validate to trigger field validators (e.g. _clamp_request_timeout).
|
|
106
|
+
# getattr (not model_dump) preserves live SecretStr values.
|
|
107
|
+
return type(settings).model_validate({name: getattr(copied, name) for name in type(copied).model_fields})
|