Coverage for src / gitversioned / settings.py: 97%
72 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-14 20:55 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-14 20:55 +0000
1"""
2Settings module for GitVersioned.
4This module provides the primary configuration structure for resolving
5version information from Git using Pydantic Settings. It aggregates configuration
6from multiple sources, including config files (pyproject.toml, setup.cfg),
7environment variables, and CLI arguments, and exposes a unified interface.
9Example:
10 ::
12 settings = Settings(package_name="my_pkg")
13 print(settings.format_main)
14"""
16from __future__ import annotations
18import configparser
19from pathlib import Path
20from typing import Any, Literal
22from pydantic import Field
23from pydantic_settings import (
24 BaseSettings,
25 CliSettingsSource,
26 PydanticBaseSettingsSource,
27 PyprojectTomlConfigSettingsSource,
28 SettingsConfigDict,
29)
31from gitversioned.utils import EnsureList, EnsurePath
33__all__ = ["Settings", "SetupCfgSettingsSource"]
36class SetupCfgSettingsSource(PydanticBaseSettingsSource): # type: ignore[misc, abstract]
37 """Custom settings source to load configuration from setup.cfg."""
39 def __init__(self, settings_cls: type[BaseSettings], project_root: Path) -> None:
40 super().__init__(settings_cls)
41 self.project_root = project_root
43 def get_field_value(self, field: Any, field_name: str) -> tuple[Any, str, bool]:
44 _ = (field,) # Allow unused variable to satisfy lint/format
45 return self()[field_name], field_name, False
47 def __call__(self) -> dict[str, Any]:
48 path = self.project_root / "setup.cfg"
49 if not path.exists():
50 return {}
52 config_parser = configparser.ConfigParser()
53 config_parser.read(path)
54 base_section = "tool:gitversioned"
56 result: dict[str, Any] = {}
57 if base_section in config_parser:
58 result.update(config_parser.items(base_section))
60 prefix = f"{base_section}:"
61 for section in config_parser.sections():
62 if section.startswith(prefix):
63 key = section[len(prefix) :]
64 if key not in result:
65 result[key] = {}
66 elif not isinstance(result[key], dict):
67 result[key] = {"_": result[key]} # type: ignore[dict-item]
69 result[key].update(config_parser.items(section)) # type: ignore[union-attr]
71 return result
74class Settings(BaseSettings):
75 """
76 Configuration for GitVersioned, handling formatting, strictness, and outputs.
78 This class aggregates and prioritizes configuration from multiple sources,
79 providing a unified state for version resolution across the tool. It is built
80 on top of pydantic-settings to allow validation and type coercion.
82 Example:
83 ::
85 settings = Settings(package_name="my_pkg")
86 print(settings.format_main)
87 """
89 model_config = SettingsConfigDict(
90 arbitrary_types_allowed=True,
91 extra="ignore",
92 populate_by_name=True,
93 validate_assignment=True,
94 env_prefix="GITVERSIONED__",
95 cli_prefix="gitversioned_",
96 cli_parse_args=True,
97 pyproject_toml_table_header=("tool", "gitversioned"),
98 )
100 @classmethod
101 def settings_customise_sources(
102 cls,
103 settings_cls: type[BaseSettings],
104 init_settings: PydanticBaseSettingsSource,
105 env_settings: PydanticBaseSettingsSource,
106 dotenv_settings: PydanticBaseSettingsSource,
107 file_secret_settings: PydanticBaseSettingsSource,
108 ) -> tuple[PydanticBaseSettingsSource, ...]:
109 """Customizes the configuration sources and their priority.
111 This method overrides the default pydantic-settings source priority to
112 inject our custom `SetupCfgSettingsSource` and define the specific order
113 of resolution.
115 :param settings_cls: The settings class being instantiated.
116 :param init_settings: The initial settings provided via kwargs.
117 :param env_settings: Settings loaded from environment variables.
118 :param dotenv_settings: Settings loaded from a .env file.
119 :param file_secret_settings: Settings loaded from secret files.
120 :return: A tuple of settings sources in priority order.
121 """
122 _ = (file_secret_settings,) # Allow unused variable to satisfy lint/format
123 input_args = init_settings()
124 project_root = input_args.get("project_root") or Path.cwd()
126 return (
127 init_settings,
128 SetupCfgSettingsSource(settings_cls, project_root=project_root),
129 PyprojectTomlConfigSettingsSource(
130 settings_cls, toml_file=project_root / "pyproject.toml"
131 ),
132 dotenv_settings,
133 env_settings,
134 CliSettingsSource(
135 settings_cls,
136 cli_ignore_unknown_args=True,
137 cli_parse_args=True,
138 cli_prefix=settings_cls.model_config.get("cli_prefix", ""),
139 ),
140 )
142 # GitVersioned Configuration
143 package_name: str = Field(
144 description="The package name being versioned.",
145 )
146 version: str = Field(
147 default="auto",
148 description="Explicit version override. 'auto' enables dynamic resolution.",
149 )
150 project_root: EnsurePath = Field(
151 default_factory=Path.cwd,
152 description="The root directory of the project.",
153 )
154 src_root: EnsurePath = Field(
155 default_factory=Path.cwd,
156 description="The root directory of the project source code.",
157 )
158 build_is_editable: bool = Field(
159 default=False,
160 description="Flag indicating if the current build is an editable install.",
161 )
163 # Formatting properties
164 format_main: str = Field(
165 default="{version.major}.{version.minor}.{version.micro}",
166 description="Format for main semantic versioning.",
167 )
168 format_dev: str = Field(
169 default="dev{ref.timestamp:%Y%m%d}+{ref.short_sha}",
170 description="Format for dev builds.",
171 )
172 format_pre: str = Field(
173 default="a{ref.timestamp:%Y%m%d}",
174 description="Format for pre/alpha builds.",
175 )
176 format_post: str = Field(
177 default="post{ref.distance_from_head}",
178 description="Format for post builds.",
179 )
181 # Sourcing properties
182 regex_version: EnsureList[str] = Field(
183 default_factory=lambda: [ # type: ignore[arg-type]
184 r"^(?:releases?/)?v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)$"
185 ],
186 description="Regex used to extract version from explicit version strings.",
187 )
188 regex_tag: EnsureList[str] = Field(
189 default_factory=lambda: [ # type: ignore[arg-type]
190 r"^(?:releases?/)?v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)$"
191 ],
192 description="Regex used to find version tags and extract the version.",
193 )
194 regex_branch: EnsureList[str] = Field(
195 default_factory=lambda: [ # type: ignore[arg-type]
196 r"^(?:releases?/)?v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
197 ],
198 description="Regex used to extract version from the current branch.",
199 )
200 regex_commit: EnsureList[str] = Field(
201 default_factory=lambda: [ # type: ignore[arg-type]
202 r"(?i)^(?:release\s+|bump(?:\s+\w+)*\s+)?"
203 r"v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
204 ],
205 description="Regex used to extract version from previous commits.",
206 )
207 regex_file: EnsureList[str] = Field(
208 default_factory=lambda: [ # type: ignore[arg-type]
209 r"(?i)(?:version|__version__)\s*[:=]\s*['\"]?"
210 r"(v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:[a-zA-Z0-9.\-]+)?)"
211 r"['\"]?"
212 ],
213 description="Regex used to extract version from a file.",
214 )
215 regex_archive: EnsureList[str] = Field(
216 default_factory=lambda: [ # type: ignore[arg-type]
217 r"(?sm)"
218 r"(?=.*^commit_sha:\s*(?P<commit_sha>[^\n]*))"
219 r"(?=.*^short_sha:\s*(?P<short_sha>[^\n]*))"
220 r"(?=.*^timestamp:\s*(?P<timestamp>[^\n]*))"
221 r"(?=.*^author_name:\s*(?P<author_name>[^\n]*))"
222 r"(?=.*^author_email:\s*(?P<author_email>[^\n]*))"
223 r"(?=.*^ref_names:\s*(?P<ref_names>[^\n]*))"
224 r"(?=.*^ref_names:.*?(?:v)?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+))"
225 r"(?=.*^distance_from_head:\s*(?P<distance_from_head>[^\n]*))"
226 r"(?=.*^is_head_commit:\s*(?P<is_head_commit>[^\n]*))"
227 r"(?=.*^total_commits:\s*(?P<total_commits>[^\n]*))"
228 r"(?=.*^is_current_branch:\s*(?P<is_current_branch>[^\n]*))"
229 r"(?=.*^commit_message:\n(?P<commit_message>.*))"
230 ],
231 description=(
232 "Regex patterns used to extract versions/metadata from an archive export."
233 ),
234 )
235 version_source_file: str | None = Field(
236 default="version.txt",
237 description="File to pull version from if searching local sources.",
238 )
239 version_source_archive: str | None = Field(
240 default=".git_archival.txt",
241 description="File to pull version from if executed from a git archive.",
242 )
243 version_source_function: str | None = Field(
244 default=None,
245 description="Module and function to resolve version and git reference.",
246 )
247 source_type: EnsureList[str] = Field(
248 default_factory=lambda: ["auto"], # type: ignore[arg-type]
249 description="Priority order of source types to extract the version from.",
250 )
251 dirty_ignore: EnsureList[str] = Field(
252 default_factory=list, # type: ignore[arg-type]
253 description=(
254 "List of file paths to ignore when checking if the repository is dirty."
255 ),
256 )
258 # Creation & output properties
259 auto_increment: (
260 dict[
261 Literal["release", "dev", "pre", "alpha", "nightly", "post"],
262 Literal["major", "minor", "micro", "patch"],
263 ]
264 | None
265 ) = Field(
266 default=None,
267 description=(
268 "Target increment for specific version types when the repo is ahead of the "
269 "last tag source."
270 ),
271 )
272 version_type: Literal[
273 "auto", "release", "dev", "pre", "alpha", "nightly", "post"
274 ] = Field(
275 default="auto",
276 description="Type of version to create.",
277 )
278 output_file: str = Field(
279 default="version.py",
280 description="File path where the generated version string is written.",
281 )
282 template_release: str = Field(
283 default_factory=(
284 Path(__file__).parent / "templates" / "release.py.template"
285 ).read_text,
286 description="The ExStr template used for release builds.",
287 )
288 template_dev: str = Field(
289 default_factory=(
290 Path(__file__).parent / "templates" / "dev.py.template"
291 ).read_text,
292 description="The ExStr template used for dev builds.",
293 )
295 def __str__(self) -> str:
296 """Return a concise string representation."""
297 return (
298 f"Settings(package_name={self.package_name!r}, version={self.version!r}, "
299 f"version_type={self.version_type!r}, project_root={self.project_root!r}, "
300 f"src_root={self.src_root!r}, source_type={self.source_type!r}, "
301 f"auto_increment={self.auto_increment!r}, output_file={self.output_file!r},"
302 f" dirty_ignore={self.dirty_ignore!r})"
303 )
305 def __repr__(self) -> str:
306 """Return a detailed string representation."""
307 return (
308 f"Settings("
309 f"package_name={self.package_name!r}, version={self.version!r}, "
310 f"project_root={self.project_root!r}, src_root={self.src_root!r}, "
311 f"build_is_editable={self.build_is_editable!r}, "
312 f"format_main={self.format_main!r}, format_dev={self.format_dev!r}, "
313 f"format_pre={self.format_pre!r}, format_post={self.format_post!r}, "
314 f"regex_tag={self.regex_tag!r}, regex_branch={self.regex_branch!r}, "
315 f"regex_commit={self.regex_commit!r}, regex_file={self.regex_file!r}, "
316 f"version_source_file={self.version_source_file!r}, "
317 f"version_source_function={self.version_source_function!r}, "
318 f"source_type={self.source_type!r}, dirty_ignore={self.dirty_ignore!r}, "
319 f"auto_increment={self.auto_increment!r}, "
320 f"version_type={self.version_type!r}, output_file={self.output_file!r}, "
321 f"template_release={self.template_release!r}, "
322 f"template_dev={self.template_dev!r}"
323 f")"
324 )