SXXXXXXX_PyInstallerGUIWrapper/pyinstallerguiwrapper/build/version_manager.py
2025-06-10 09:09:06 +02:00

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