Coverage for src / gitversioned / plugins / setuptools_plugin.py: 0%

134 statements  

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

1""" 

2Setuptools integration for GitVersioned. 

3 

4This module provides entry points for Setuptools to automatically compute and 

5inject versions resolved from Git metadata into package distribution objects. 

6""" 

7 

8from __future__ import annotations 

9 

10import email 

11from distutils.errors import DistutilsSetupError 

12from pathlib import Path 

13from typing import Any 

14 

15from loguru import logger 

16from packaging.utils import canonicalize_name 

17from setuptools import Distribution 

18 

19from gitversioned.logging import LoggingSettings, configure_logger 

20from gitversioned.settings import Settings 

21from gitversioned.utils import BuildEnvironment, GitRepository 

22from gitversioned.versioning import resolve_and_generate_version 

23 

24__all__ = ["finalize_distribution_options", "setup_keywords"] 

25 

26# Constants for internal validation 

27INVALID_VERSIONS: set[str] = {"None", "0.0.0", "UNKNOWN"} 

28 

29 

30def setup_keywords(distribution: Distribution, attribute: str, value: Any) -> None: 

31 """ 

32 Validates and stores the GitVersioned configuration dictionary. 

33 

34 :param distribution: The Setuptools distribution object. 

35 :param attribute: The keyword attribute name. 

36 :param value: The configuration dictionary provided by the user. 

37 """ 

38 configure_logger(LoggingSettings(enabled=True)) 

39 logger.debug(f"setup_keywords called with attribute='{attribute}'") 

40 

41 if attribute != "gitversioned": 

42 logger.error(f"Unknown keyword argument: {attribute}") 

43 raise DistutilsSetupError(f"Unknown keyword argument: {attribute}") 

44 

45 if not isinstance(value, dict): 

46 logger.error("gitversioned keyword argument must be a dict") 

47 raise DistutilsSetupError("gitversioned must be a dict") 

48 

49 distribution.gitversioned_config = value 

50 

51 

52def finalize_distribution_options(distribution: Distribution) -> None: 

53 """ 

54 Computes the package version and updates the distribution metadata. 

55 

56 This is the primary entry point triggered during the Setuptools lifecycle. 

57 """ 

58 

59 configure_logger(LoggingSettings(enabled=True)) 

60 logger.debug("Finalizing distribution options for GitVersioned.") 

61 

62 project_root, source_root, package_name = _resolve_project_context(distribution) 

63 if not package_name: 

64 raise DistutilsSetupError("Could not determine package name.") 

65 

66 # Check for an established version to avoid redundant Git resolution 

67 established_version = _extract_established_version(distribution, project_root) 

68 config_overrides = getattr(distribution, "gitversioned_config", {}) 

69 

70 try: 

71 kwargs: Any = { 

72 "package_name": package_name, 

73 "project_root": project_root, 

74 "src_root": source_root, 

75 "build_is_editable": getattr(distribution, "editable", False), 

76 } 

77 kwargs.update(config_overrides) 

78 settings = Settings(**kwargs) 

79 

80 if established_version: 

81 logger.info(f"Using established version: {established_version}") 

82 version_string = established_version 

83 output_path = _find_existing_version_file(settings) 

84 else: 

85 repository = GitRepository(settings.project_root) 

86 environment = BuildEnvironment(project_root=settings.project_root) 

87 version, output_path = resolve_and_generate_version( 

88 settings=settings, repository=repository, environment=environment 

89 ) 

90 version_string = str(version) 

91 

92 # Update distribution metadata 

93 if hasattr(distribution, "metadata"): 

94 distribution.metadata.version = version_string 

95 distribution.version = version_string 

96 

97 if output_path: 

98 _inject_output_into_distribution( 

99 distribution=distribution, 

100 output_path=output_path, 

101 source_root=source_root, 

102 package_name=package_name, 

103 ) 

104 

105 except Exception as error: 

106 if isinstance(error, DistutilsSetupError): 

107 raise 

108 logger.exception("Unexpected failure during version resolution") 

109 raise DistutilsSetupError(f"Failed to resolve version: {error}") from error 

110 

111 

112def _extract_established_version( 

113 distribution: Distribution, project_root: Path 

114) -> str | None: 

115 """Check metadata, distribution, and PKG-INFO for an existing valid version.""" 

116 candidates = [ 

117 getattr(distribution.metadata, "version", None), 

118 getattr(distribution, "version", None), 

119 ] 

120 

121 pkg_info_path = project_root / "PKG-INFO" 

122 if pkg_info_path.is_file(): 

123 try: 

124 with pkg_info_path.open(encoding="utf-8") as file_handle: 

125 message = email.message_from_file(file_handle) 

126 candidates.append(message.get("Version")) 

127 except (OSError, ValueError) as error: 

128 logger.warning(f"Failed to read PKG-INFO: {error}") 

129 

130 for version in candidates: 

131 if ( 

132 isinstance(version, str) 

133 and version.strip() 

134 and version not in INVALID_VERSIONS 

135 ): 

136 return version.strip() 

137 return None 

138 

139 

140def _find_existing_version_file(settings: Settings) -> Path | None: 

141 """Locate the existing version file if resolution is skipped.""" 

142 if not settings.output_file: 

143 return None 

144 path = Path(settings.output_file) 

145 output_path = path if path.is_absolute() else settings.src_root / path 

146 return output_path if output_path.exists() else None 

147 

148 

149def _resolve_project_context( 

150 distribution: Distribution, 

151) -> tuple[Path, Path, str | None]: 

152 """Determines project root, source root, and package name via waterfall logic.""" 

153 project_root = Path(getattr(distribution, "src_root", None) or Path.cwd()) 

154 package_name = None 

155 

156 # Priority 1: Direct metadata 

157 name_raw = getattr(distribution.metadata, "name", None) 

158 if not name_raw or name_raw == "UNKNOWN": 

159 name_raw = distribution.get_name() 

160 

161 if name_raw and name_raw != "UNKNOWN": 

162 package_name = canonicalize_name(name_raw).replace("-", "_") 

163 

164 # Priority 2: Packages list 

165 if not package_name: 

166 packages = getattr(distribution, "packages", None) 

167 if packages and isinstance(packages, (list, tuple)): 

168 package_name = packages[0] 

169 

170 # Resolve source root and fallback if name is still missing 

171 if package_name: 

172 source_root = _get_source_root(project_root, distribution, package_name) 

173 else: 

174 probe = _probe_filesystem_context(project_root) 

175 source_root, package_name = probe if probe else (project_root, None) 

176 

177 return project_root, source_root, package_name 

178 

179 

180def _get_source_root( 

181 project_root: Path, distribution: Distribution, package_name: str 

182) -> Path: 

183 """Maps the package name to its source directory using package_dir configuration.""" 

184 package_dir = getattr(distribution, "package_dir", None) or {} 

185 

186 relative_source = package_dir.get(package_name) 

187 if relative_source is None and "_" in package_name: 

188 relative_source = package_dir.get(package_name.replace("_", "-")) 

189 

190 if relative_source is None: 

191 relative_source = package_dir.get("", "") 

192 

193 base_path = project_root / relative_source 

194 return ( 

195 base_path / package_name if (base_path / package_name).is_dir() else base_path 

196 ) 

197 

198 

199def _probe_filesystem_context(project_root: Path) -> tuple[Path, str] | None: 

200 """Probes the filesystem for a package directory containing an __init__.py.""" 

201 for search_path in (project_root / "src", project_root): 

202 if not search_path.is_dir(): 

203 continue 

204 for item in search_path.iterdir(): 

205 if item.is_dir() and (item / "__init__.py").exists(): 

206 return item, item.name 

207 return None 

208 

209 

210def _inject_output_into_distribution( 

211 distribution: Distribution, 

212 output_path: Path, 

213 source_root: Path, 

214 package_name: str, 

215) -> None: 

216 """Registers the generated file in the distribution's package_data or py_modules.""" 

217 # Attempt 1: Package data (internal file) 

218 package_folder = ( 

219 source_root if source_root.name == package_name else source_root / package_name 

220 ) 

221 try: 

222 relative_output = str(output_path.relative_to(package_folder)) 

223 current_packages = getattr(distribution, "packages", None) 

224 if current_packages is None: 

225 distribution.packages = [package_name] 

226 elif package_name not in current_packages: 

227 distribution.packages = list(current_packages) + [package_name] 

228 

229 if getattr(distribution, "package_data", None) is None: 

230 distribution.package_data = {} 

231 distribution.package_data.setdefault(package_name, []).append(relative_output) 

232 return 

233 except ValueError: 

234 pass 

235 

236 # Attempt 2: Flat module 

237 try: 

238 relative_module = str(output_path.relative_to(source_root)) 

239 if "/" not in relative_module and output_path.suffix == ".py": 

240 modules = getattr(distribution, "py_modules", []) or [] 

241 if output_path.stem not in modules: 

242 modules.append(output_path.stem) 

243 distribution.py_modules = modules 

244 return 

245 except ValueError: 

246 pass 

247 

248 logger.warning(f"Version file {output_path} is outside source root {source_root}")