Coverage for src / gitversioned / plugins / setuptools_plugin.py: 77%
134 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"""
2Setuptools integration for GitVersioned.
4This module provides entry points for Setuptools to automatically compute and
5inject versions resolved from Git metadata into package distribution objects.
6"""
8from __future__ import annotations
10import email
11from distutils.errors import DistutilsSetupError
12from pathlib import Path
13from typing import Any
15from loguru import logger
16from packaging.utils import canonicalize_name
17from setuptools import Distribution
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
24__all__ = ["finalize_distribution_options", "setup_keywords"]
26# Constants for internal validation
27INVALID_VERSIONS: set[str] = {"None", "0.0.0", "UNKNOWN"}
30def setup_keywords(distribution: Distribution, attribute: str, value: Any) -> None:
31 """
32 Validates and stores the GitVersioned configuration dictionary.
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}'")
41 if attribute != "gitversioned":
42 logger.error(f"Unknown keyword argument: {attribute}")
43 raise DistutilsSetupError(f"Unknown keyword argument: {attribute}")
45 if not isinstance(value, dict):
46 logger.error("gitversioned keyword argument must be a dict")
47 raise DistutilsSetupError("gitversioned must be a dict")
49 distribution.gitversioned_config = value
52def finalize_distribution_options(distribution: Distribution) -> None:
53 """
54 Computes the package version and updates the distribution metadata.
56 This is the primary entry point triggered during the Setuptools lifecycle.
57 """
59 configure_logger(LoggingSettings(enabled=True))
60 logger.debug("Finalizing distribution options for GitVersioned.")
62 project_root, source_root, package_name = _resolve_project_context(distribution)
63 if not package_name:
64 raise DistutilsSetupError("Could not determine package name.")
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", {})
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)
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)
92 # Update distribution metadata
93 if hasattr(distribution, "metadata"):
94 distribution.metadata.version = version_string
95 distribution.version = version_string
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 )
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
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 ]
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}")
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
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
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
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()
161 if name_raw and name_raw != "UNKNOWN":
162 package_name = canonicalize_name(name_raw).replace("-", "_")
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]
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)
177 return project_root, source_root, package_name
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 {}
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("_", "-"))
190 if relative_source is None:
191 relative_source = package_dir.get("", "")
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 )
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
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]
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
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
248 logger.warning(f"Version file {output_path} is outside source root {source_root}")