# 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