Coverage for src / gitversioned / versioning.py: 69%
271 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"""
2GitVersioned core module for resolving versions from Git state.
4This module provides the primary logic for computing PEP 440 compliant version
5strings. It evaluates multiple configured sources (such as tags, branches, files,
6and functions) and applies appropriate templates based on the current build
7environment and repository state.
8"""
10from __future__ import annotations
12import datetime
13import importlib
14import math
15import re
16import sys
17from pathlib import Path
18from typing import Any, Literal, cast
20from loguru import logger
21from packaging.version import InvalidVersion, Version
22from tstr import generate_template, render
24from gitversioned.settings import Settings
25from gitversioned.utils import BuildEnvironment, GitReference, GitRepository
27__all__ = [
28 "generate_version_py",
29 "resolve_and_generate_version",
30 "resolve_version",
31]
34def _extract_versions(patterns: list[str], text: str) -> list[Version]:
35 """Extracts a Version object from a regex match group dictionary or groups."""
36 versions: list[Version] = []
37 for pattern in patterns:
38 for match in re.finditer(str(pattern), text):
39 groups = match.groupdict()
40 major = groups.get("major")
41 minor = groups.get("minor")
42 micro = groups.get("micro") or groups.get("patch") or groups.get("bug")
44 if major is None or minor is None or micro is None:
45 logger.warning(
46 f"Invalid version string found for pattern {pattern} and "
47 f"text {text} with groups {groups}"
48 )
49 continue
51 versions.append(Version(f"{major}.{minor}.{micro}"))
53 if not versions:
54 raise ValueError(f"No version found for patterns {patterns} and text {text}")
56 return versions
59def _resolve_explicit(settings: Settings) -> Version:
60 """Checks if a hardcoded version is provided in settings."""
61 version_str = str(settings.version).strip().lower()
62 logger.debug(f"Evaluating explicit version setting: '{version_str}'")
63 if version_str in ("auto", "dynamic", "0.0.0", ""):
64 raise ValueError("Explicit version is not set.")
65 version = _extract_versions(settings.regex_version, version_str)[0]
66 logger.info(f"Resolved explicit version: {version}")
67 return version
70def _resolve_file(settings: Settings) -> Version:
71 """Resolves version from the configured version file."""
72 if not settings.version_source_file:
73 logger.debug("No version_source_file configured.")
74 raise ValueError("No version_source_file configured.")
76 file_path = Path(settings.version_source_file)
77 target = file_path if file_path.is_absolute() else settings.src_root / file_path
78 logger.debug(f"Attempting to resolve version from file: {target}")
80 if not target.exists():
81 logger.info(f"Version file not found: {target}")
82 raise ValueError("Version file not found.")
84 content = target.read_text(encoding="utf-8")
85 version = _extract_versions(settings.regex_file, content)[0]
86 logger.info(
87 f"Resolved version from file '{target}' using pattern "
88 f"{settings.regex_file}: {version}"
89 )
90 return version
93def _resolve_function(
94 settings: Settings, repo: GitRepository, env: BuildEnvironment
95) -> tuple[Version, GitReference | None]:
96 """Resolves version by executing a configured python function."""
97 if not settings.version_source_function:
98 logger.debug("No version_source_function configured.")
99 raise ValueError("No version_source_function configured.")
101 logger.debug(
102 f"Attempting to resolve version from function: "
103 f"{settings.version_source_function}"
104 )
106 # Insert into PATH to allow general importing within the package
107 added_paths = []
108 for path in [str(settings.project_root), str(settings.src_root)]:
109 if path not in sys.path:
110 sys.path.insert(0, path)
111 added_paths.append(path)
113 try:
114 module_name, function_name = str(settings.version_source_function).split(":", 1)
115 module = importlib.import_module(module_name)
116 version, ref = getattr(module, function_name)(
117 settings=settings, repo=repo, env=env
118 )
119 if not version or not isinstance(version, Version):
120 raise ValueError(
121 f"Version function '{settings.version_source_function}' did not "
122 f"return a valid version. Got: {version}"
123 )
124 if ref and not isinstance(ref, GitReference):
125 raise ValueError(
126 f"Version function '{settings.version_source_function}' did not "
127 f"return a valid reference. Got: {ref}"
128 )
129 logger.info(
130 f"Resolved version and reference from function "
131 f"{settings.version_source_function}: {version} ({ref})"
132 )
133 return version, ref
134 except Exception as error:
135 logger.exception(
136 f"Version function '{settings.version_source_function}' failed: {error}"
137 )
138 raise
139 finally:
140 for path in added_paths:
141 if path in sys.path:
142 sys.path.remove(path)
145def _resolve_git(
146 type_: Literal["tag", "branch", "commit"],
147 settings: Settings,
148 repository: GitRepository,
149) -> tuple[Version, GitReference | None]:
150 """Generic logic for matching Git objects against regex patterns."""
151 logger.debug(f"Attempting to resolve version from git {type_}")
153 if not repository.is_available:
154 logger.warning("No git repository available.")
155 raise ValueError("No git repository available.")
157 candidates: list[tuple[str, GitReference | None]] = []
158 if type_ == "tag":
159 patterns = settings.regex_tag
160 candidates = [(tag.tag_name, tag) for tag in repository.tags]
161 elif type_ == "branch":
162 patterns = settings.regex_branch
163 candidates = [
164 (
165 (
166 repository.current_branch.branch_name
167 if repository.current_branch
168 else ""
169 ),
170 repository.current_branch,
171 )
172 ]
173 elif type_ == "commit":
174 patterns = settings.regex_commit
175 candidates = [(commit.commit_message, commit) for commit in repository.commits]
176 else:
177 raise ValueError(f"Invalid git type: {type_}")
179 matches: list[tuple[Version, GitReference | None]] = []
180 for text, reference in candidates:
181 try:
182 version = _extract_versions(patterns, text)[0]
183 matches.append((version, reference))
184 except (InvalidVersion, ValueError) as ver_err:
185 logger.warning(
186 f"Could not extract version from git {type_} '{text}' using "
187 f"patterns {patterns}: {ver_err}"
188 )
189 continue
191 if not matches:
192 raise ValueError(f"No version found for git {type_} using patterns {patterns}")
194 logger.debug(f"Found {len(matches)} matches for git {type_}.")
195 best_match = min(
196 matches,
197 key=lambda item: item[1].distance_from_head if item[1] else math.inf,
198 )
199 logger.info(
200 f"Resolved version from git {type_}; "
201 f"version={best_match[0]}, ref={best_match[1]}"
202 )
203 return best_match
206def _parse_archive_source(
207 content: str, settings: Settings, sources: list[str]
208) -> tuple[list[Version], GitReference]:
209 if not content or "$Format" in content:
210 raise ValueError(
211 "Archive file has not been formatted with 'git archive' or similar: "
212 f"{content}"
213 )
215 ref_kwargs: dict[str, Any] = {}
217 for pattern in settings.regex_archive:
218 for match in re.finditer(pattern, content):
219 groups = match.groupdict()
220 ref_kwargs.update(
221 {
222 key: value
223 for key, value in groups.items()
224 if value is not None
225 and key not in ("major", "minor", "micro", "patch", "bug")
226 }
227 )
229 ref = GitReference(**ref_kwargs)
230 versions: list[Version] = []
232 for source in sources:
233 try:
234 if source == "tag" and ref.tag_name:
235 versions = _extract_versions(settings.regex_tag, ref.tag_name)
236 break
237 if source == "branch" and ref.branch_name:
238 versions = _extract_versions(settings.regex_branch, ref.branch_name)
239 break
240 if source == "commit" and ref.commit_message:
241 versions = _extract_versions(settings.regex_commit, ref.commit_message)
242 break
243 except ValueError:
244 logger.warning(
245 f"Could not extract version from archive for {source}: {ref}"
246 )
247 continue
249 return versions, ref
252def _resolve_archive(
253 settings: Settings, sources: list[str]
254) -> tuple[Version, GitReference]:
255 """Resolves version and metadata from an archive file export."""
256 if not settings.version_source_archive:
257 raise ValueError("No version_source_archive configured.")
259 file_path = Path(settings.version_source_archive)
260 target = file_path if file_path.is_absolute() else settings.project_root / file_path
261 if not target.exists():
262 raise ValueError("Version file not found.")
264 logger.debug(f"Attempting to resolve version from archive: {target}")
265 content = target.read_text(encoding="utf-8")
266 versions, ref = _parse_archive_source(content, settings, sources)
268 if not versions:
269 raise ValueError(
270 f"No version found for archive '{target}' using patterns: "
271 f"{settings.regex_archive} and sources {sources}"
272 )
274 version = max(versions)
275 logger.info(f"Resolved version from archive; version={version}, ref={ref}")
276 return version, ref
279def _get_current_commit(repository: GitRepository) -> GitReference:
280 return (
281 repository.current_commit
282 if repository.is_available and repository.current_commit
283 else GitReference(
284 timestamp=datetime.datetime.now(datetime.timezone.utc),
285 distance_from_head=0,
286 is_head_commit=False,
287 )
288 )
291def _iterate_version_sources(
292 sources: list[str],
293 settings: Settings,
294 repository: GitRepository,
295 env: BuildEnvironment,
296) -> tuple[Version | None, GitReference | None]:
297 reference = None
299 for source in sources:
300 if source not in ("file", "function", "tag", "branch", "commit"):
301 logger.error(f"Unknown source type encountered: {source}")
302 raise ValueError(f"Unknown source type: {source}")
304 try:
305 if source == "file":
306 version = _resolve_file(settings)
307 elif source == "function":
308 version, reference = _resolve_function(settings, repository, env)
309 elif source in ("tag", "branch", "commit"):
310 version, reference = _resolve_git(
311 cast("Literal['tag', 'branch', 'commit']", source),
312 settings,
313 repository,
314 )
315 except ValueError as ver_err:
316 logger.warning(
317 f"Could not resolve version from source '{source}': {ver_err}"
318 )
319 continue
321 if version:
322 logger.info(
323 f"Successfully resolved version from source '{source}': {version}"
324 )
325 return version, reference
326 return None, None
329def _resolve_version_sources(
330 sources: list[str],
331 settings: Settings,
332 repository: GitRepository,
333 env: BuildEnvironment,
334) -> tuple[Version, GitReference]:
335 version: Version | None = None
336 reference: GitReference | None = None
338 try:
339 version = _resolve_explicit(settings)
340 logger.info(f"Resolved version from explicit config/argument: {version}")
341 return version, _get_current_commit(repository)
342 except ValueError as exp_err:
343 logger.info(
344 f"Could not resolve version from explicit config/argument: {exp_err}"
345 )
347 if "auto" in sources:
348 sources = [
349 "file",
350 "function",
351 "tag",
352 "branch",
353 "commit",
354 ]
355 logger.debug(f"Expanded 'auto' source type to: {sources}")
357 logger.info(f"Resolving version sources in order: {sources}")
358 version, reference = _iterate_version_sources(sources, settings, repository, env)
360 if not version:
361 logger.info(
362 "No version could be resolved from the configured sources, "
363 "attempting to resolve from archive."
364 )
365 try:
366 version, reference = _resolve_archive(settings, sources)
367 logger.info(f"Resolved version from archive: {version} for {reference}")
368 except ValueError as archive_err:
369 logger.info(f"Could not resolve version from archive: {archive_err}")
371 if not version:
372 version = Version("0.1.0")
373 logger.warning(
374 f"No version found from any sources, defaulting to base version: {version}"
375 )
377 if not reference:
378 reference = _get_current_commit(repository)
379 logger.info(f"Resolved reference to fallback/current commit: {reference}")
381 return version, reference
384def _get_dirty_files(repository: GitRepository, settings: Settings) -> list[str]:
385 """Returns a list of dirty files, excluding configured output files."""
386 if not repository.is_available:
387 return []
389 dirty_files = repository.dirty_files
390 if not dirty_files:
391 return []
393 ignored_paths = set()
394 for path_str in [
395 settings.output_file,
396 settings.version_source_file,
397 *settings.dirty_ignore,
398 ]:
399 if path_str:
400 path = Path(path_str)
401 target = path if path.is_absolute() else settings.src_root / path
402 ignored_paths.add(target.resolve())
404 return [
405 file_path
406 for file_path in dirty_files
407 if (repository.base_path / file_path).resolve() not in ignored_paths
408 ]
411def resolve_version(
412 settings: Settings, repository: GitRepository, environment: BuildEnvironment
413) -> tuple[Version, GitReference]:
414 """
415 Computes the PEP 440 version based on configuration and repository state.
417 This function coordinates the resolution of version sources according to the
418 provided settings, performs auto-increments if necessary, and formats the final
419 version string based on the target build type (e.g., release, dev, alpha).
421 Example:
422 >>> version, reference = resolve_version(settings, repo, env)
424 :param settings: Configuration rules for resolving the version.
425 :param repository: The current git repository state.
426 :param environment: Build environment metadata.
427 :return: A tuple containing the resolved Version and the Git reference object.
428 :raises ValueError: If an unknown source type or git type is encountered.
429 """
431 logger.debug(
432 f"resolving version for {settings} in repo={repository} env={environment}"
433 )
434 base_version, reference = _resolve_version_sources(
435 settings.source_type, settings, repository, environment
436 )
437 logger.info(f"Resolved base version: {base_version} for git reference {reference}")
439 # Determine version type to build (release, dev, alpha, post)
440 version_type = str(settings.version_type).strip().lower()
441 distance = reference.distance_from_head if repository.is_available else 0
442 if version_type == "auto":
443 if not repository.is_available and reference.commit_sha:
444 on_head = True
445 is_dirty = False
446 else:
447 on_head = repository.is_available and reference.is_head_commit
448 dirty_files = _get_dirty_files(repository, settings)
449 is_dirty = len(dirty_files) > 0
451 version_type = "release" if on_head and not is_dirty else "dev"
452 logger.info(
453 f"Auto-resolved version type to: '{version_type}' "
454 f"for ref {reference} and repo {repository}"
455 )
457 target_str = str(
458 settings.auto_increment.get(
459 cast(
460 "Literal['release', 'dev', 'pre', 'alpha', 'nightly', 'post']",
461 version_type,
462 ),
463 "",
464 )
465 if settings.auto_increment
466 else ""
467 ).lower()
468 target_idx = {"major": 0, "minor": 1, "micro": 2, "patch": 2}.get(target_str)
470 if target_idx is not None and distance > 0:
471 parts = [base_version.major, base_version.minor, base_version.micro]
472 parts[target_idx] += 1
473 for index in range(target_idx + 1, len(parts)):
474 parts[index] = 0
476 version = Version(".".join(map(str, parts)))
477 logger.info(
478 f"Auto-incremented version from {base_version} to {version} "
479 f"(target='{target_str}')"
480 )
481 else:
482 version = base_version
484 context = {
485 "version": version,
486 "repo": repository,
487 "config": settings,
488 "env": environment,
489 "ref": reference,
490 }
492 main_version = str(
493 render(generate_template(settings.format_main, context, use_eval=True))
494 )
495 segment = ""
496 if version_type == "dev":
497 segment = str(
498 render(generate_template(settings.format_dev, context, use_eval=True))
499 )
500 elif version_type in ("pre", "alpha", "nightly"):
501 segment = str(
502 render(generate_template(settings.format_pre, context, use_eval=True))
503 )
504 elif version_type == "post":
505 segment = str(
506 render(generate_template(settings.format_post, context, use_eval=True))
507 )
509 final_version = Version(f"{main_version}.{segment}".rstrip("+."))
510 logger.info(f"Resolved final version: {final_version}")
512 return final_version, reference
515def generate_version_py(
516 version: Version,
517 reference: GitReference,
518 settings: Settings,
519 repository: GitRepository,
520 environment: BuildEnvironment,
521) -> Path | None:
522 """
523 Writes the resolved version metadata to a python file using templates.
525 This function utilizes the configured release or development templates to
526 generate a python file containing version information, which can then be
527 included directly within the target package.
529 Example:
530 >>> path = generate_version_py(version, ref, settings, repo, env)
532 :param version: The resolved PEP 440 version object.
533 :param reference: The resolved Git reference object.
534 :param settings: Configuration rules for resolving the version.
535 :param repository: The current git repository state.
536 :param environment: Build environment metadata.
537 :return: The Path object pointing to the written file.
538 """
539 logger.debug(
540 f"generate_version_py called for version={version} reference={reference} "
541 f"settings={settings} repository={repository} environment={environment}"
542 )
544 if not settings.output_file:
545 logger.debug("No output file configured, skipping generation of version file.")
546 return None
548 template = (
549 settings.template_dev if version.dev is not None else settings.template_release
550 )
551 context = {
552 "version": version,
553 "repo": repository,
554 "config": settings,
555 "env": environment,
556 "ref": reference,
557 }
558 content = str(render(generate_template(template, context, use_eval=True)))
560 try:
561 output_path = Path(settings.output_file)
562 if not output_path.is_absolute():
563 output_path = settings.src_root / output_path
564 output_path.parent.mkdir(parents=True, exist_ok=True)
565 output_path.write_text(content, encoding="utf-8")
566 logger.info(f"Generated version py file successfully at: {output_path}")
567 except Exception as error:
568 logger.exception(
569 f"Failed to write version python file to {output_path}: {error}"
570 )
571 raise
573 return output_path
576def resolve_and_generate_version(
577 settings: Settings, repository: GitRepository, environment: BuildEnvironment
578) -> tuple[Version, Path | None]:
579 """
580 Main entry point to resolve the version and write the output file if configured.
582 This function wraps the core version resolution logic and subsequently triggers
583 the generation of the version python file if an output file is specified in the
584 settings. It provides a convenient single call for build hooks and integrations.
586 Example:
587 >>> version, path = resolve_and_generate_version(settings, repo, env)
589 :param settings: Configuration rules for resolving the version.
590 :param repository: The current git repository state.
591 :param environment: Build environment metadata.
592 :return: A tuple of the resolved Version and output Path (if generated).
593 """
594 logger.debug(
595 f"resolve_and_generate_version called for {settings} with "
596 f"repo={repository} env={environment}"
597 )
598 version, reference = resolve_version(settings, repository, environment)
599 output_path = generate_version_py(
600 version, reference, settings, repository, environment
601 )
603 return version, output_path