263 lines
13 KiB
Python
263 lines
13 KiB
Python
# pyinstallerguiwrapper/build/version_manager.py
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Manages the generation of the target project's _version.py file
|
|
by scraping Git information.
|
|
"""
|
|
|
|
import subprocess
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import datetime
|
|
import traceback
|
|
from typing import Dict, Optional, Tuple, Callable
|
|
|
|
# Default values used if Git information cannot be retrieved or for the generated file
|
|
DEFAULT_VERSION_FALLBACK = "0.0.0+unknown"
|
|
DEFAULT_COMMIT_FALLBACK = "Unknown"
|
|
DEFAULT_BRANCH_FALLBACK = "Unknown"
|
|
DEFAULT_TIMESTAMP_FALLBACK = "Unknown"
|
|
|
|
# Default format string that will be written into the target's _version.py's get_version_string function
|
|
# This string will be used by the generated get_version_string's .format() method,
|
|
# so placeholders should have single braces.
|
|
DEFAULT_TARGET_GET_VERSION_FORMAT_STRING = "{version} ({branch}/{commit_short})"
|
|
|
|
|
|
class VersionManager:
|
|
"""
|
|
Handles scraping Git information and generating a _version.py file
|
|
for the target project.
|
|
"""
|
|
|
|
def __init__(self, logger_func: Optional[Callable[..., None]] = None):
|
|
"""
|
|
Initializes the VersionManager.
|
|
|
|
Args:
|
|
logger_func: A function to use for logging messages.
|
|
"""
|
|
self._logger = logger_func if logger_func else self._default_logger
|
|
self._log_message("VersionManager initialized.", level="DEBUG")
|
|
|
|
def _default_logger(self, message: str, level: str = "INFO") -> None:
|
|
"""Default logger if none is provided."""
|
|
print(f"[{level}][VersionManager] {message}")
|
|
|
|
def _log_message(self, message: str, level: str = "INFO") -> None:
|
|
"""Helper for logging messages."""
|
|
try:
|
|
self._logger(message, level=level)
|
|
except TypeError: # logger_func might not accept 'level'
|
|
self._logger(f"[{level}] {message}")
|
|
|
|
def _run_git_command_in_dir(self, command: list, working_dir: str) -> Optional[str]:
|
|
"""
|
|
Executes a Git command in a specified directory and returns its output.
|
|
Returns None if the command fails or Git is not found.
|
|
"""
|
|
git_exec = shutil.which("git")
|
|
if not git_exec:
|
|
self._log_message("'git' command not found in system PATH. Cannot scrape Git version info.", level="WARNING")
|
|
return None
|
|
|
|
full_command = [git_exec] + command
|
|
try:
|
|
process = subprocess.Popen(
|
|
full_command,
|
|
cwd=working_dir,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding='utf-8',
|
|
errors='replace'
|
|
)
|
|
stdout, stderr = process.communicate(timeout=10)
|
|
if process.returncode == 0:
|
|
return stdout.strip()
|
|
else:
|
|
self._log_message(f"Git command '{' '.join(full_command)}' failed (RC: {process.returncode}): {stderr.strip()}", level="WARNING")
|
|
return None
|
|
except FileNotFoundError:
|
|
self._log_message(f"'git' command not found when trying to run: {' '.join(full_command)}", level="ERROR")
|
|
return None
|
|
except subprocess.TimeoutExpired:
|
|
self._log_message(f"Git command '{' '.join(full_command)}' timed out.", level="WARNING")
|
|
process.kill()
|
|
return None
|
|
except Exception as e:
|
|
self._log_message(f"Error running Git command '{' '.join(full_command)}': {e}", level="ERROR")
|
|
self._log_message(traceback.format_exc(), level="DEBUG")
|
|
return None
|
|
|
|
def get_git_version_info_for_project(self, project_root_dir: str) -> Dict[str, any]:
|
|
"""
|
|
Scrapes Git version information for the project at project_root_dir.
|
|
"""
|
|
self._log_message(f"Attempting to get Git version info from: {project_root_dir}", level="INFO")
|
|
git_data: Dict[str, any] = {
|
|
"version": DEFAULT_VERSION_FALLBACK,
|
|
"commit_hash": DEFAULT_COMMIT_FALLBACK,
|
|
"branch": DEFAULT_BRANCH_FALLBACK,
|
|
"build_timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
"is_git_repo": False,
|
|
}
|
|
|
|
if not pathlib.Path(project_root_dir, ".git").is_dir():
|
|
self._log_message(f"'{project_root_dir}' does not appear to be a Git repository (no .git directory). Using default version info.", level="WARNING")
|
|
git_data["build_timestamp"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
return git_data
|
|
|
|
git_data["is_git_repo"] = True # Mark as Git repo if .git folder exists
|
|
|
|
# Get version from 'git describe'
|
|
# Try to get the most recent tag, allowing for dirty state and commit hash
|
|
version_str = self._run_git_command_in_dir(["describe", "--tags", "--dirty", "--always", "--long"], project_root_dir)
|
|
if version_str:
|
|
git_data["version"] = version_str
|
|
self._log_message(f" Git describe (version): {version_str}", level="DEBUG")
|
|
else: # Fallback if describe fails but it's a git repo
|
|
git_data["version"] = f"0.0.0+git.{self._run_git_command_in_dir(['rev-parse', '--short', 'HEAD'], project_root_dir) or 'unknown'}"
|
|
self._log_message(f" Git describe failed, fallback version: {git_data['version']}", level="WARNING")
|
|
|
|
|
|
commit_hash_full = self._run_git_command_in_dir(["rev-parse", "HEAD"], project_root_dir)
|
|
if commit_hash_full:
|
|
git_data["commit_hash"] = commit_hash_full
|
|
self._log_message(f" Git commit hash: {commit_hash_full}", level="DEBUG")
|
|
|
|
branch_name = self._run_git_command_in_dir(["rev-parse", "--abbrev-ref", "HEAD"], project_root_dir)
|
|
if branch_name and branch_name != "HEAD": # "HEAD" means detached state
|
|
git_data["branch"] = branch_name
|
|
self._log_message(f" Git branch: {branch_name}", level="DEBUG")
|
|
elif branch_name == "HEAD":
|
|
# In detached HEAD, try to get a more descriptive name (e.g., tag or commit)
|
|
detached_info = self._run_git_command_in_dir(["describe", "--all", "--exact-match"], project_root_dir)
|
|
if detached_info:
|
|
git_data["branch"] = detached_info.replace("heads/", "").replace("tags/", "")
|
|
else: # Fallback to short commit if still can't describe detached HEAD
|
|
git_data["branch"] = f"detached@{git_data['commit_hash'][:7]}"
|
|
self._log_message(f" Git branch (detached HEAD): {git_data['branch']}", level="DEBUG")
|
|
else:
|
|
self._log_message(f" Could not determine Git branch.", level="WARNING")
|
|
|
|
|
|
self._log_message(f"Final Git data for _version.py: {git_data}", level="INFO")
|
|
return git_data
|
|
|
|
def generate_target_version_file(self, project_root_dir: str, target_version_file_path_str: str) -> bool:
|
|
"""
|
|
Generates and writes the _version.py file for the target project.
|
|
|
|
Args:
|
|
project_root_dir: The root directory of the target project.
|
|
target_version_file_path_str: The full path where the _version.py file should be written.
|
|
|
|
Returns:
|
|
True if the file was written successfully, False otherwise.
|
|
"""
|
|
self._log_message(f"Generating target _version.py content for: {target_version_file_path_str}", level="INFO")
|
|
git_info = self.get_git_version_info_for_project(project_root_dir)
|
|
|
|
# Content for the _version.py file
|
|
file_content_lines = [
|
|
"# -*- coding: utf-8 -*-",
|
|
"# File generated by PyInstaller GUI Wrapper. DO NOT EDIT MANUALLY.",
|
|
"# Contains build-time information scraped from Git (if available)",
|
|
"# and a helper function to format version strings.",
|
|
"",
|
|
"import re",
|
|
"",
|
|
"# --- Version Data (Generated) ---",
|
|
f"__version__ = \"{git_info['version']}\"",
|
|
f"GIT_COMMIT_HASH = \"{git_info['commit_hash']}\"",
|
|
f"GIT_BRANCH = \"{git_info['branch']}\"",
|
|
f"BUILD_TIMESTAMP = \"{git_info['build_timestamp']}\"",
|
|
f"IS_GIT_REPO = {git_info['is_git_repo']}",
|
|
"",
|
|
"# --- Default Values (for comparison or fallback) ---",
|
|
f"DEFAULT_VERSION = \"{DEFAULT_VERSION_FALLBACK}\"",
|
|
f"DEFAULT_COMMIT = \"{DEFAULT_COMMIT_FALLBACK}\"",
|
|
f"DEFAULT_BRANCH = \"{DEFAULT_BRANCH_FALLBACK}\"",
|
|
"",
|
|
"# --- Helper Function ---",
|
|
"def get_version_string(format_string=None):",
|
|
" \"\"\"",
|
|
" Returns a formatted string based on the build version information.",
|
|
"",
|
|
" Args:",
|
|
" format_string (str, optional): A format string using placeholders.",
|
|
f" Defaults to \"{DEFAULT_TARGET_GET_VERSION_FORMAT_STRING}\" if None.",
|
|
" Placeholders:",
|
|
" {{version}}: Full version string (e.g., 'v1.0.0-5-gabcdef-dirty')",
|
|
" {{tag}}: Clean tag part if exists (e.g., 'v1.0.0'), else DEFAULT_VERSION.",
|
|
" {{commit}}: Full Git commit hash.",
|
|
" {{commit_short}}: Short Git commit hash (7 chars).",
|
|
" {{branch}}: Git branch name.",
|
|
" {{dirty}}: '-dirty' if the repo was dirty, empty otherwise.",
|
|
" {{timestamp}}: Full build timestamp (ISO 8601 UTC).",
|
|
" {{timestamp_short}}: Build date only (YYYY-MM-DD).",
|
|
" {{is_git}}: 'Git' if IS_GIT_REPO is True, 'Unknown' otherwise.",
|
|
"",
|
|
" Returns:",
|
|
" str: The formatted version string, or an error message if formatting fails.",
|
|
" \"\"\"",
|
|
" if format_string is None:",
|
|
f" format_string = \"{DEFAULT_TARGET_GET_VERSION_FORMAT_STRING}\" # Default format", # Questa è la linea cruciale
|
|
"",
|
|
" replacements = {}",
|
|
" try:",
|
|
" replacements['version'] = __version__ if __version__ else DEFAULT_VERSION",
|
|
" replacements['commit'] = GIT_COMMIT_HASH if GIT_COMMIT_HASH else DEFAULT_COMMIT",
|
|
" replacements['commit_short'] = GIT_COMMIT_HASH[:7] if GIT_COMMIT_HASH and len(GIT_COMMIT_HASH) >= 7 else DEFAULT_COMMIT",
|
|
" replacements['branch'] = GIT_BRANCH if GIT_BRANCH else DEFAULT_BRANCH",
|
|
" replacements['timestamp'] = BUILD_TIMESTAMP if BUILD_TIMESTAMP else \"Unknown\"",
|
|
" replacements['timestamp_short'] = BUILD_TIMESTAMP.split('T')[0] if BUILD_TIMESTAMP and 'T' in BUILD_TIMESTAMP else \"Unknown\"",
|
|
" replacements['is_git'] = \"Git\" if IS_GIT_REPO else \"Unknown\"",
|
|
" replacements['dirty'] = \"-dirty\" if __version__ and __version__.endswith('-dirty') else \"\"",
|
|
"",
|
|
" tag = DEFAULT_VERSION",
|
|
" if __version__ and IS_GIT_REPO:",
|
|
# Adjusted regex to be more robust for different tag formats (e.g., v1.2.3, 1.2.3)
|
|
" match = re.match(r'^(v?([0-9]+(?:\\.[0-9]+)*))', __version__)",
|
|
" if match:",
|
|
" tag = match.group(1)",
|
|
" replacements['tag'] = tag",
|
|
"",
|
|
" output_string = format_string",
|
|
" for placeholder, value in replacements.items():",
|
|
# Use curly braces directly in re.compile for placeholders like {placeholder_name}
|
|
" pattern = re.compile(r'{{\s*' + re.escape(placeholder) + r'\s*}}')", # Corrected for {{placeholder}}
|
|
" output_string = pattern.sub(str(value), output_string)",
|
|
"",
|
|
# Check for any remaining unreplaced placeholders (like {some_other_var})
|
|
" if re.search(r'{\s*\\w+\s*}', output_string):",
|
|
" pass # Or log a warning: print(f\"Warning: Unreplaced placeholders found: {output_string}\")",
|
|
"",
|
|
" return output_string",
|
|
"",
|
|
" except Exception as e:",
|
|
" return f\"[Formatting Error: {e}]\"",
|
|
""
|
|
]
|
|
|
|
try:
|
|
target_file = pathlib.Path(target_version_file_path_str)
|
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(target_file, 'w', encoding='utf-8') as f:
|
|
f.write("\n".join(file_content_lines))
|
|
self._log_message(f"Successfully wrote target _version.py to: {target_file}", level="INFO")
|
|
return True
|
|
except IOError as e:
|
|
self._log_message(f"IOError writing _version.py to '{target_version_file_path_str}': {e}", level="CRITICAL")
|
|
except Exception as e:
|
|
self._log_message(f"Unexpected error writing _version.py to '{target_version_file_path_str}': {e}", level="CRITICAL")
|
|
self._log_message(traceback.format_exc(), level="DEBUG")
|
|
|
|
# Se si arriva qui, c'è stato un errore nella scrittura
|
|
# Il messaggio di errore dovrebbe essere gestito dal chiamante (BuildOrchestrator) se necessario
|
|
return False
|
|
|
|
# Necessario per _run_git_command_in_dir se git non è nel PATH o per altri motivi
|
|
import shutil |