Coverage for src / gitversioned / versioning.py: 0%

271 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-14 20:57 +0000

1""" 

2GitVersioned core module for resolving versions from Git state. 

3 

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""" 

9 

10from __future__ import annotations 

11 

12import datetime 

13import importlib 

14import math 

15import re 

16import sys 

17from pathlib import Path 

18from typing import Any, Literal, cast 

19 

20from loguru import logger 

21from packaging.version import InvalidVersion, Version 

22from tstr import generate_template, render 

23 

24from gitversioned.settings import Settings 

25from gitversioned.utils import BuildEnvironment, GitReference, GitRepository 

26 

27__all__ = [ 

28 "generate_version_py", 

29 "resolve_and_generate_version", 

30 "resolve_version", 

31] 

32 

33 

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") 

43 

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 

50 

51 versions.append(Version(f"{major}.{minor}.{micro}")) 

52 

53 if not versions: 

54 raise ValueError(f"No version found for patterns {patterns} and text {text}") 

55 

56 return versions 

57 

58 

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 

68 

69 

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.") 

75 

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}") 

79 

80 if not target.exists(): 

81 logger.info(f"Version file not found: {target}") 

82 raise ValueError("Version file not found.") 

83 

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 

91 

92 

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.") 

100 

101 logger.debug( 

102 f"Attempting to resolve version from function: " 

103 f"{settings.version_source_function}" 

104 ) 

105 

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) 

112 

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) 

143 

144 

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_}") 

152 

153 if not repository.is_available: 

154 logger.warning("No git repository available.") 

155 raise ValueError("No git repository available.") 

156 

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_}") 

178 

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 

190 

191 if not matches: 

192 raise ValueError(f"No version found for git {type_} using patterns {patterns}") 

193 

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 

204 

205 

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 ) 

214 

215 ref_kwargs: dict[str, Any] = {} 

216 

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 ) 

228 

229 ref = GitReference(**ref_kwargs) 

230 versions: list[Version] = [] 

231 

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 

248 

249 return versions, ref 

250 

251 

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.") 

258 

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.") 

263 

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) 

267 

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 ) 

273 

274 version = max(versions) 

275 logger.info(f"Resolved version from archive; version={version}, ref={ref}") 

276 return version, ref 

277 

278 

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 ) 

289 

290 

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 

298 

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}") 

303 

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 

320 

321 if version: 

322 logger.info( 

323 f"Successfully resolved version from source '{source}': {version}" 

324 ) 

325 return version, reference 

326 return None, None 

327 

328 

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 

337 

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 ) 

346 

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}") 

356 

357 logger.info(f"Resolving version sources in order: {sources}") 

358 version, reference = _iterate_version_sources(sources, settings, repository, env) 

359 

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}") 

370 

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 ) 

376 

377 if not reference: 

378 reference = _get_current_commit(repository) 

379 logger.info(f"Resolved reference to fallback/current commit: {reference}") 

380 

381 return version, reference 

382 

383 

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 [] 

388 

389 dirty_files = repository.dirty_files 

390 if not dirty_files: 

391 return [] 

392 

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()) 

403 

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 ] 

409 

410 

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. 

416 

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). 

420 

421 Example: 

422 >>> version, reference = resolve_version(settings, repo, env) 

423 

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 """ 

430 

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}") 

438 

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 

450 

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 ) 

456 

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) 

469 

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 

475 

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 

483 

484 context = { 

485 "version": version, 

486 "repo": repository, 

487 "config": settings, 

488 "env": environment, 

489 "ref": reference, 

490 } 

491 

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 ) 

508 

509 final_version = Version(f"{main_version}.{segment}".rstrip("+.")) 

510 logger.info(f"Resolved final version: {final_version}") 

511 

512 return final_version, reference 

513 

514 

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. 

524 

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. 

528 

529 Example: 

530 >>> path = generate_version_py(version, ref, settings, repo, env) 

531 

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 ) 

543 

544 if not settings.output_file: 

545 logger.debug("No output file configured, skipping generation of version file.") 

546 return None 

547 

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))) 

559 

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 

572 

573 return output_path 

574 

575 

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. 

581 

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. 

585 

586 Example: 

587 >>> version, path = resolve_and_generate_version(settings, repo, env) 

588 

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 ) 

602 

603 return version, output_path