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.
@@ -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
+ [![CI](https://github.com/modern-python/semvertag/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/semvertag/actions/workflows/ci.yml)
30
+ [![codecov](https://codecov.io/gh/modern-python/semvertag/branch/main/graph/badge.svg)](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
+ [![CI](https://github.com/modern-python/semvertag/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/semvertag/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/modern-python/semvertag/branch/main/graph/badge.svg)](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})