Last active
March 5, 2025 21:52
-
-
Save Sh-ui/77bcf28301719ab4a18d0d180e607eab to your computer and use it in GitHub Desktop.
PackWeasel: Python Application CLU for Keeping Work Easily Assembled & Swiftly Exported Later
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| PackWeasel: Python Application CLU for Keeping Work Easily Assembled & Swiftly Exported Later | |
| Usage: | |
| packweasel.py package [options] | |
| packweasel.py install [--global] | |
| packweasel.py --help[=TOPIC] | |
| packweasel.py --version | |
| Options: | |
| -h --help[=TOPIC] Show help message or pull up help on a specific topic: features, examples, install | |
| -d --dir=DIR Input directory containing the application [default: .] | |
| -n --name=NAME Name of the project [default: .] | |
| -o --output=DIR Output directory for the packaged files [default: .] | |
| -t --target=TARGET Target platforms: windows,linux,mac [default: all] | |
| -p --prefix=PREFIX Prefix for directories created by PackWeasel [default: pw_] | |
| -i --icon=PATH Path to icon or icons directory | |
| -m --main=FILE Main application file to run | |
| -s --skip=ITEMS Skip steps (comma-separated): venv,docs,shortcuts,icons,zip [default: ] | |
| -v --verbose Print detailed output | |
| -g --git[=BRANCH] Enable git integration with optional branch name [default: pw-packaging] | |
| If used without a branch name, uses the default branch | |
| If not used, git integration is disabled | |
| --python=VERSION Python version to use (e.g., 3.9) [default: current] | |
| --global When using 'install', install for all users (requires admin/sudo) | |
| --version Show version | |
| """ | |
| __features__ = """ | |
| PackWeasel Features: | |
| 1. Virtual Environment Setup | |
| - Creates isolated virtual environment inside the package | |
| - Includes proper activation scripts for all platforms | |
| - Copies all dependencies including those installed with pip | |
| 2. Embedded Python (Windows) | |
| - Includes a portable Python interpreter on Windows | |
| - No need for Python to be installed on the target system | |
| - Automatically configured to run your application | |
| 3. Launch Scripts | |
| - Creates OS-specific launch scripts (batch, shell, etc.) | |
| - Sets up proper environment and Python path | |
| - Handles platform-specific differences automatically | |
| 4. Desktop Shortcuts | |
| - Creates desktop shortcuts for launching applications | |
| - Includes application icons on supported platforms | |
| - Platform-native implementation (.lnk, .desktop, etc.) | |
| 5. Icon Conversion | |
| - Converts icons between formats as needed | |
| - Supports ICO, PNG, SVG, and ICNS formats | |
| - Automatically handles platform requirements | |
| 6. Dependency Management | |
| - Analyzes and packages all required dependencies | |
| - Supports requirements.txt and installed packages | |
| - Handles platform-specific dependencies | |
| 7. Git Integration (Optional) | |
| - Creates packaging branch for clean releases | |
| - Excludes unnecessary development files | |
| - Maintains clean separation between dev and release | |
| 8. Documentation | |
| - Generates helpful "How to Run" documentation | |
| - Includes platform-specific instructions | |
| - Automatically adjusted based on package contents | |
| """ | |
| __examples__ = """ | |
| Example Usage: | |
| Basic packaging (all platforms): | |
| packweasel.py package --name MyApp --main app.py | |
| Windows-only packaging with custom icon: | |
| packweasel.py package --target windows --name MyApp --icon app_icon.ico --main app.py | |
| Linux packaging with custom output: | |
| packweasel.py package --target linux --name MyApp --output dist --main app.py | |
| Packaging with Git integration enabled: | |
| packweasel.py package --name MyApp --main app.py --git | |
| Packaging with custom Git branch: | |
| packweasel.py package --name MyApp --main app.py --git=release-package | |
| Skip certain packaging steps: | |
| packweasel.py package --name MyApp --main app.py --skip venv,docs | |
| Package from specific directory with verbose output: | |
| packweasel.py package --dir src --name MyApp --main app.py --verbose | |
| """ | |
| __install__ = """ | |
| PackWeasel Installation Guide: | |
| There are two ways to use PackWeasel: | |
| 1. STANDALONE MODE (Quick Start) | |
| - Simply download the packweasel.py file | |
| - Run directly: python packweasel.py [command] [options] | |
| - No installation required! | |
| 2. INSTALL FOR EASY ACCESS (Recommended) | |
| - Run the built-in installer: python packweasel.py install | |
| - After installation, run from anywhere: packweasel [command] [options] | |
| The install command automatically: | |
| • Makes the script executable (on Linux/macOS) | |
| • Adds PackWeasel to your PATH | |
| • Creates proper shortcuts or symlinks | |
| • Sets up everything needed to run from any directory | |
| Installation Commands: | |
| Standard installation (current user): | |
| packweasel.py install | |
| System-wide installation (requires admin/sudo): | |
| packweasel.py install --global | |
| Platform-Specific Notes: | |
| Windows: | |
| The install command creates a proper batch wrapper and adds it to your PATH. | |
| No manual configuration required! | |
| macOS/Linux: | |
| The install command automatically creates an executable symlink in the appropriate | |
| bin directory and ensures your shell can find it. | |
| No chmod or manual PATH editing needed! | |
| """ | |
| # Standard library imports | |
| import os | |
| import sys | |
| import shutil | |
| import platform | |
| import logging | |
| import zipfile | |
| import subprocess | |
| import tempfile | |
| import urllib.request | |
| import importlib.util | |
| import site | |
| from pathlib import Path | |
| from datetime import datetime | |
| # Third-party imports (installed if missing) | |
| try: | |
| from docopt import docopt, DocoptExit | |
| except ImportError: | |
| try: | |
| subprocess.check_call([sys.executable, "-m", "pip", "install", "docopt-ng"]) | |
| from docopt import docopt, DocoptExit | |
| except Exception as e: | |
| print(f"Error installing docopt-ng: {e}") | |
| print("Please install manually: pip install docopt-ng") | |
| sys.exit(1) | |
| try: | |
| import PIL.Image | |
| except ImportError: | |
| try: | |
| subprocess.check_call([sys.executable, "-m", "pip", "install", "pillow"]) | |
| import PIL.Image | |
| except Exception as e: | |
| print(f"Error installing pillow: {e}") | |
| print("Please install manually: pip install pillow") | |
| sys.exit(1) | |
| # Configure logging | |
| logging.basicConfig(level=logging.INFO, format="%(message)s") | |
| logger = logging.getLogger("packweasel") | |
| # --- Constants --- | |
| # Version | |
| VERSION = "0.9.0" # Beta version - working functionality with refined interface | |
| # Directory size thresholds (in bytes) for warning | |
| WARNING_SIZE_THRESHOLD = 1_000_000_000 # 1GB | |
| HARD_LIMIT_SIZE_THRESHOLD = 10_000_000_000 # 10GB | |
| # Reserved directory names that suggest non-project directories | |
| RESERVED_DIRS = [ | |
| 'Windows', 'Program Files', 'Program Files (x86)', 'Users', 'Documents', | |
| 'Downloads', 'Desktop', 'Pictures', 'Music', 'Videos', 'Applications', | |
| 'Library', 'System', 'System32', 'bin', 'etc', 'home', 'usr', 'var' | |
| ] | |
| # --- Exception classes --- | |
| class PackWeaselError(Exception): | |
| """Base exception for all PackWeasel errors.""" | |
| pass | |
| class ConfigurationError(PackWeaselError): | |
| """Raised for configuration errors.""" | |
| pass | |
| class EnvironmentError(PackWeaselError): | |
| """Raised for environment-related errors.""" | |
| pass | |
| class PackagingError(PackWeaselError): | |
| """Raised for packaging errors.""" | |
| pass | |
| class DirectoryValidationError(PackWeaselError): | |
| """Raised when a directory is not valid for packaging.""" | |
| pass | |
| # --- Utility functions --- | |
| def dicto(ref, name='dictionary_object'): | |
| """Helper to convert nested optparse references to dot notation. | |
| Example: | |
| args = {'--verbose': True, '--name': 'myapp'} | |
| args_obj = dicto(args) | |
| print(args_obj.verbose) # True | |
| print(args_obj.name) # 'myapp' | |
| """ | |
| class DictObject(dict): | |
| def __getattr__(self, attr): | |
| if attr in self: | |
| val = self[attr] | |
| if isinstance(val, dict): | |
| return dicto(val, attr) | |
| return val | |
| raise AttributeError(f"{name} has no attribute '{attr}'") | |
| def __setattr__(self, attr, value): | |
| self[attr] = value | |
| obj = DictObject(ref) | |
| # Add simplified aliases for CLU args | |
| if isinstance(ref, dict): | |
| for key in list(ref.keys()): | |
| if key.startswith('--'): | |
| simple_key = key[2:] | |
| if simple_key not in obj: | |
| obj[simple_key] = ref[key] | |
| elif key.startswith('-'): | |
| simple_key = key[1:] | |
| if simple_key not in obj and len(simple_key) == 1: | |
| obj[simple_key] = ref[key] | |
| return obj | |
| def is_valid_project_directory(directory): | |
| """ | |
| Checks if a directory is valid for packaging by: | |
| 1. Verifying it's not a system directory | |
| 2. Checking if it contains reasonable amounts of data | |
| 3. Looking for project-like files (*.py, package.json, etc.) | |
| Returns (is_valid, reason) tuple | |
| """ | |
| # Normalize and get absolute path | |
| directory = os.path.abspath(directory) | |
| # Check against reserved directory names | |
| dir_name = os.path.basename(directory) | |
| if dir_name in RESERVED_DIRS: | |
| return False, f"Directory name '{dir_name}' appears to be a system directory" | |
| # Check parent directories for reserved names (to catch cases like /Users/username) | |
| path_parts = directory.split(os.path.sep) | |
| for part in path_parts: | |
| if part in RESERVED_DIRS: | |
| return False, f"Path contains system directory name '{part}'" | |
| # Check directory size | |
| total_size = 0 | |
| file_count = 0 | |
| python_files = 0 | |
| project_indicators = 0 | |
| for root, dirs, files in os.walk(directory): | |
| # Skip hidden directories and typical non-project directories | |
| dirs[:] = [d for d in dirs if not d.startswith('.') and | |
| d not in ['__pycache__', 'venv', '.venv', 'env', 'node_modules']] | |
| for file in files: | |
| file_path = os.path.join(root, file) | |
| # Count project indicators | |
| if file.endswith('.py'): | |
| python_files += 1 | |
| project_indicators += 1 | |
| elif file in ['requirements.txt', 'setup.py', 'pyproject.toml', 'package.json', | |
| 'Pipfile', 'Makefile', 'CMakeLists.txt', '.gitignore']: | |
| project_indicators += 2 # These are stronger indicators | |
| # Check file size | |
| try: | |
| file_size = os.path.getsize(file_path) | |
| total_size += file_size | |
| file_count += 1 | |
| except (FileNotFoundError, PermissionError): | |
| pass | |
| # Size checks | |
| if total_size > HARD_LIMIT_SIZE_THRESHOLD: | |
| return False, f"Directory is too large ({total_size/1e9:.1f} GB)" | |
| # Project indicators check | |
| if project_indicators == 0: | |
| if total_size > WARNING_SIZE_THRESHOLD: | |
| return False, "Directory is large and contains no Python or project files" | |
| return False, "Directory contains no Python or project files" | |
| # If we have Python files or strong project indicators, it's probably valid | |
| if python_files > 0 or project_indicators >= 2: | |
| # Just a warning for very large directories | |
| if total_size > WARNING_SIZE_THRESHOLD: | |
| logger.warning(f"Warning: Directory is large ({total_size/1e9:.1f} GB)") | |
| return True, None | |
| return False, "Directory does not appear to be a Python project" | |
| def get_directory_size(directory): | |
| """Calculate the total size of a directory in bytes.""" | |
| total_size = 0 | |
| for root, _, files in os.walk(directory): | |
| for file in files: | |
| file_path = os.path.join(root, file) | |
| try: | |
| total_size += os.path.getsize(file_path) | |
| except (FileNotFoundError, PermissionError): | |
| pass | |
| return total_size | |
| # --- Core classes --- | |
| class IconManager: | |
| """Handles icon processing for different platforms.""" | |
| def __init__(self, icon_path, package_dir, project_name): | |
| """Initialize with the icon path and target directory.""" | |
| self.icon_path = icon_path | |
| self.package_dir = package_dir | |
| self.project_name = project_name | |
| self.icons_dir = os.path.join(package_dir, "icons") | |
| # Create icons directory | |
| os.makedirs(self.icons_dir, exist_ok=True) | |
| # Platform-specific icon paths | |
| self.windows_icon = None | |
| self.linux_icon = None | |
| self.mac_icon = None | |
| def process_icons(self): | |
| """Process icons for all platforms.""" | |
| if not self.icon_path: | |
| logger.debug("No icon specified, using defaults") | |
| return | |
| if os.path.isdir(self.icon_path): | |
| # Icon path is a directory, look for platform-specific icons | |
| self._process_icon_directory() | |
| else: | |
| # Icon path is a single file, use for all platforms | |
| self._process_single_icon() | |
| def _process_icon_directory(self): | |
| """Process a directory of icons, looking for platform-specific ones.""" | |
| # Look for Windows icon (prefer .ico or largest .png) | |
| ico_files = list(Path(self.icon_path).glob("*.ico")) | |
| if ico_files: | |
| # Use first .ico file found | |
| self.windows_icon = self._copy_icon(ico_files[0], "windows.ico") | |
| else: | |
| # Convert largest PNG to ICO | |
| png_files = sorted( | |
| Path(self.icon_path).glob("*.png"), | |
| key=lambda p: os.path.getsize(p), | |
| reverse=True | |
| ) | |
| if png_files: | |
| self.windows_icon = self._convert_to_ico(png_files[0]) | |
| # Look for Linux icon (prefer largest .png or .svg) | |
| png_files = sorted( | |
| Path(self.icon_path).glob("*.png"), | |
| key=lambda p: os.path.getsize(p), | |
| reverse=True | |
| ) | |
| svg_files = list(Path(self.icon_path).glob("*.svg")) | |
| if svg_files: | |
| # SVG is preferred for Linux | |
| self.linux_icon = self._copy_icon(svg_files[0], "linux.svg") | |
| elif png_files: | |
| # Use largest PNG | |
| self.linux_icon = self._copy_icon(png_files[0], "linux.png") | |
| # Look for Mac icon (prefer .icns or largest .png) | |
| icns_files = list(Path(self.icon_path).glob("*.icns")) | |
| if icns_files: | |
| self.mac_icon = self._copy_icon(icns_files[0], "mac.icns") | |
| elif png_files: | |
| # Use largest PNG | |
| self.mac_icon = self._copy_icon(png_files[0], "mac.png") | |
| def _process_single_icon(self): | |
| """Process a single icon file for all platforms.""" | |
| icon_ext = os.path.splitext(self.icon_path)[1].lower() | |
| if icon_ext == '.ico': | |
| # Already an ICO file, use directly for Windows | |
| self.windows_icon = self._copy_icon(self.icon_path, "windows.ico") | |
| # Convert to PNG for Linux and Mac | |
| png_path = self._convert_ico_to_png(self.icon_path) | |
| self.linux_icon = png_path | |
| self.mac_icon = png_path | |
| elif icon_ext == '.icns': | |
| # Already an ICNS file, use directly for Mac | |
| self.mac_icon = self._copy_icon(self.icon_path, "mac.icns") | |
| # Convert to ICO for Windows and PNG for Linux | |
| png_path = self._convert_to_png(self.icon_path) | |
| self.windows_icon = self._convert_to_ico(png_path) | |
| self.linux_icon = png_path | |
| elif icon_ext in ['.png', '.jpg', '.jpeg']: | |
| # Copy PNG/JPG for Linux and Mac | |
| self.linux_icon = self._copy_icon(self.icon_path, f"linux{icon_ext}") | |
| self.mac_icon = self._copy_icon(self.icon_path, f"mac{icon_ext}") | |
| # Convert to ICO for Windows | |
| self.windows_icon = self._convert_to_ico(self.icon_path) | |
| elif icon_ext == '.svg': | |
| # SVG is good for Linux | |
| self.linux_icon = self._copy_icon(self.icon_path, "linux.svg") | |
| # Try to convert to PNG for Windows and Mac | |
| try: | |
| # This requires additional libraries like CairoSVG | |
| # Fallback to a simple copy if conversion fails | |
| png_path = self._convert_svg_to_png(self.icon_path) | |
| self.windows_icon = self._convert_to_ico(png_path) | |
| self.mac_icon = png_path | |
| except Exception as e: | |
| logger.warning(f"Failed to convert SVG to PNG: {e}") | |
| # Just copy the SVG | |
| self.windows_icon = self._copy_icon(self.icon_path, "windows.svg") | |
| self.mac_icon = self._copy_icon(self.icon_path, "mac.svg") | |
| def _copy_icon(self, src_path, dest_name): | |
| """Copy icon to the icons directory.""" | |
| dest_path = os.path.join(self.icons_dir, dest_name) | |
| shutil.copy2(src_path, dest_path) | |
| logger.debug(f"Copied icon: {src_path} -> {dest_path}") | |
| return dest_path | |
| def _convert_to_ico(self, image_path): | |
| """Convert an image to ICO format for Windows.""" | |
| ico_path = os.path.join(self.icons_dir, "windows.ico") | |
| try: | |
| img = PIL.Image.open(image_path) | |
| # Create a set of standard sizes for the ICO | |
| sizes = [(16, 16), (32, 32), (48, 48), (64, 64), (128, 128)] | |
| icon_images = [] | |
| for size in sizes: | |
| # Resize image preserving aspect ratio | |
| resized_img = img.copy() | |
| resized_img.thumbnail(size, PIL.Image.LANCZOS) | |
| # Convert to RGBA if needed | |
| if resized_img.mode != 'RGBA': | |
| resized_img = resized_img.convert('RGBA') | |
| icon_images.append(resized_img) | |
| # Save as ICO | |
| icon_images[0].save( | |
| ico_path, | |
| format='ICO', | |
| sizes=[(img.width, img.height) for img in icon_images] | |
| ) | |
| logger.debug(f"Converted to ICO: {image_path} -> {ico_path}") | |
| return ico_path | |
| except Exception as e: | |
| logger.warning(f"Failed to convert to ICO: {e}") | |
| # Fallback: just copy the original | |
| return self._copy_icon(image_path, "windows.png") | |
| def _convert_ico_to_png(self, ico_path): | |
| """Convert ICO to PNG for Linux/Mac.""" | |
| png_path = os.path.join(self.icons_dir, "icon.png") | |
| try: | |
| img = PIL.Image.open(ico_path) | |
| # Get the largest size available | |
| if hasattr(img, 'ico') and len(img.ico.entries) > 0: | |
| img = img.ico.getimage(max(range(len(img.ico.entries)), | |
| key=lambda i: img.ico.entries[i].width)) | |
| img.save(png_path, format='PNG') | |
| logger.debug(f"Converted ICO to PNG: {ico_path} -> {png_path}") | |
| return png_path | |
| except Exception as e: | |
| logger.warning(f"Failed to convert ICO to PNG: {e}") | |
| # Fallback: just copy the original | |
| return self._copy_icon(ico_path, "icon.ico") | |
| def _convert_to_png(self, image_path): | |
| """Convert an image to PNG format.""" | |
| png_path = os.path.join(self.icons_dir, "icon.png") | |
| try: | |
| img = PIL.Image.open(image_path) | |
| img.save(png_path, format='PNG') | |
| logger.debug(f"Converted to PNG: {image_path} -> {png_path}") | |
| return png_path | |
| except Exception as e: | |
| logger.warning(f"Failed to convert to PNG: {e}") | |
| # Fallback: just copy the original | |
| return self._copy_icon(image_path, os.path.basename(image_path)) | |
| def _convert_svg_to_png(self, svg_path): | |
| """Convert SVG to PNG format using CairoSVG if available.""" | |
| png_path = os.path.join(self.icons_dir, "icon.png") | |
| try: | |
| # Try using CairoSVG if available | |
| import cairosvg | |
| cairosvg.svg2png(url=svg_path, write_to=png_path, output_width=256, output_height=256) | |
| logger.debug(f"Converted SVG to PNG: {svg_path} -> {png_path}") | |
| return png_path | |
| except ImportError: | |
| # CairoSVG not available, try Inkscape if installed | |
| try: | |
| subprocess.run([ | |
| 'inkscape', | |
| '--export-filename=' + png_path, | |
| '--export-width=256', | |
| '--export-height=256', | |
| svg_path | |
| ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | |
| logger.debug(f"Converted SVG to PNG using Inkscape: {svg_path} -> {png_path}") | |
| return png_path | |
| except (subprocess.SubprocessError, FileNotFoundError): | |
| # Both methods failed, raise exception | |
| raise ValueError("SVG conversion requires CairoSVG library or Inkscape") | |
| class ReleasePackager: | |
| """Main class for packaging applications.""" | |
| def __init__(self, args): | |
| """Initialize with command line arguments.""" | |
| self.input_dir = os.path.abspath(args.dir) | |
| self.output_dir = os.path.abspath(args.output) | |
| # Project name - use directory name if set to '.' | |
| self.project_name = args.name | |
| if not self.project_name or self.project_name == '.': | |
| self.project_name = os.path.basename(self.input_dir) | |
| # Prefix for directories | |
| self.packaging_prefix = args.prefix | |
| # Apply prefix to output directory | |
| self.package_dir = os.path.join(self.output_dir, f"{self.packaging_prefix}{self.project_name}") | |
| # Set icon path if provided | |
| self.icon_path = args.icon if hasattr(args, 'icon') and args.icon else None | |
| # Set main file if provided | |
| self.main_file = args.main if hasattr(args, 'main') and args.main else None | |
| # Determine target platforms | |
| self.target_platforms = self._parse_target_platforms(args.target) | |
| # Skip items | |
| self.skip_items = self._parse_skip_items(args.skip) | |
| # Git integration | |
| self.use_git = False | |
| self.packaging_branch = "pw-packaging" | |
| if hasattr(args, 'git') and args.git is not False: | |
| self.use_git = True | |
| # If git is True (flag used without value) or a string value, set the branch | |
| if args.git is True: | |
| # Default branch name | |
| pass | |
| elif isinstance(args.git, str): | |
| # Custom branch name provided | |
| self.packaging_branch = args.git | |
| # Verbose mode | |
| self.verbose = args.verbose | |
| # Python version | |
| self.python_version = None | |
| if hasattr(args, 'python') and args.python != "current": | |
| self.python_version = args.python | |
| # Git variables | |
| self.original_branch = None | |
| self.has_git = False | |
| # Always create a zip package unless explicitly skipped | |
| self.create_zip = 'zip' not in self.skip_items | |
| def run(self): | |
| """Run the packaging process.""" | |
| try: | |
| logger.info(f"Packaging {self.project_name}") | |
| # Validate the environment | |
| self._validate_environment() | |
| # Create packaging branch if using Git | |
| if self.use_git: | |
| self._create_packaging_branch() | |
| # Setup package directory | |
| self._setup_package_directory() | |
| # Process icons | |
| if 'icons' not in self.skip_items and self.icon_path: | |
| self._process_icons() | |
| # Create virtual environment | |
| if 'venv' not in self.skip_items: | |
| self._create_virtual_environment() | |
| # Create portable python for Windows | |
| if 'windows' in self.target_platforms: | |
| self._create_windows_portable_python() | |
| # Install dependencies | |
| self._install_dependencies() | |
| # Create launch scripts | |
| self._create_launch_scripts() | |
| # Create shortcuts | |
| if 'shortcuts' not in self.skip_items: | |
| self._create_shortcuts() | |
| # Create documentation | |
| if 'docs' not in self.skip_items: | |
| self._create_documentation() | |
| # Create zip package | |
| if self.create_zip: | |
| self._create_zip_package() | |
| # Cleanup | |
| self._cleanup() | |
| # Success message | |
| pkg_size = get_directory_size(self.package_dir) | |
| logger.info(f"Packaging complete! ({pkg_size/1e6:.1f} MB)") | |
| logger.info(f"Package location: {self.package_dir}") | |
| if self.create_zip: | |
| zip_path = os.path.join(self.output_dir, f"{self.project_name}Portable.zip") | |
| zip_size = os.path.getsize(zip_path) | |
| logger.info(f"ZIP package created: {zip_path} ({zip_size/1e6:.1f} MB)") | |
| return True | |
| except Exception as e: | |
| logger.error(f"Packaging failed: {e}") | |
| if self.verbose: | |
| import traceback | |
| traceback.print_exc() | |
| # Try to switch back to original branch if using Git | |
| self._cleanup() | |
| return False | |
| def _validate_environment(self): | |
| """Validate the environment for packaging.""" | |
| logger.debug("Validating environment") | |
| # Check if input directory exists | |
| if not os.path.isdir(self.input_dir): | |
| raise DirectoryValidationError(f"Input directory does not exist: {self.input_dir}") | |
| # Check git if requested | |
| if self.use_git: | |
| # Check if git is installed | |
| try: | |
| subprocess.run(["git", "--version"], check=True, capture_output=True) | |
| # Check if the directory is a git repository | |
| try: | |
| subprocess.run(["git", "-C", self.input_dir, "status"], | |
| check=True, capture_output=True) | |
| self.has_git = True | |
| # Get current branch | |
| result = subprocess.run(["git", "-C", self.input_dir, "branch", "--show-current"], | |
| check=True, capture_output=True, text=True) | |
| self.original_branch = result.stdout.strip() | |
| except subprocess.CalledProcessError: | |
| logger.warning("Directory is not a git repository. Git integration disabled.") | |
| self.use_git = False | |
| except (subprocess.CalledProcessError, FileNotFoundError): | |
| logger.warning("Git is not installed. Git integration disabled.") | |
| self.use_git = False | |
| # Check Python version | |
| if self.python_version: | |
| self._check_python_version() | |
| # Create output directory if it doesn't exist | |
| os.makedirs(self.output_dir, exist_ok=True) | |
| def _create_packaging_branch(self): | |
| """Create and switch to a packaging branch.""" | |
| # Skip if not a git repository or git integration is disabled | |
| if not self.has_git or not self.use_git: | |
| logger.debug("Skipping git branch creation (not a git repository or git integration disabled)") | |
| return | |
| logger.info(f"Creating packaging branch: {self.packaging_branch}") | |
| try: | |
| # Get current branch | |
| result = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], | |
| capture_output=True, text=True, cwd=self.input_dir) | |
| if result.returncode != 0: | |
| logger.warning(f"Failed to get current branch: {result.stderr.strip()}") | |
| logger.warning("Git features will be disabled") | |
| self.has_git = False | |
| return | |
| self.original_branch = result.stdout.strip() | |
| logger.debug(f"Current branch: {self.original_branch}") | |
| # Check if the branch already exists | |
| result = subprocess.run(['git', 'branch', '--list', self.packaging_branch], | |
| capture_output=True, text=True, cwd=self.input_dir) | |
| if result.returncode != 0: | |
| logger.warning(f"Failed to list branches: {result.stderr.strip()}") | |
| logger.warning("Git features will be disabled") | |
| self.has_git = False | |
| return | |
| if self.packaging_branch in result.stdout: | |
| # Branch exists, check it out | |
| logger.debug(f"Branch {self.packaging_branch} exists, checking it out") | |
| result = subprocess.run(['git', 'checkout', self.packaging_branch], | |
| capture_output=True, text=True, cwd=self.input_dir) | |
| if result.returncode != 0: | |
| logger.warning(f"Failed to checkout existing branch: {result.stderr.strip()}") | |
| logger.warning("Git features will be disabled") | |
| self.has_git = False | |
| return | |
| logger.debug(f"Checked out existing branch: {self.packaging_branch}") | |
| else: | |
| # Create new branch | |
| logger.debug(f"Creating new branch: {self.packaging_branch}") | |
| result = subprocess.run(['git', 'checkout', '-b', self.packaging_branch], | |
| capture_output=True, text=True, cwd=self.input_dir) | |
| if result.returncode != 0: | |
| logger.warning(f"Failed to create new branch: {result.stderr.strip()}") | |
| logger.warning("Git features will be disabled") | |
| self.has_git = False | |
| return | |
| logger.debug(f"Created and checked out new branch: {self.packaging_branch}") | |
| # Update .gitignore to exclude venv | |
| self._update_gitignore() | |
| except Exception as e: | |
| logger.warning(f"Unexpected error during git branch creation: {str(e)}") | |
| logger.warning("Git features will be disabled") | |
| self.has_git = False | |
| def _update_gitignore(self): | |
| """Update .gitignore to exclude virtual environment.""" | |
| # Skip if not a git repository or git integration is disabled | |
| if not self.has_git or not self.use_git: | |
| logger.debug("Skipping .gitignore update (not a git repository or git integration disabled)") | |
| return | |
| gitignore_path = os.path.join(self.input_dir, '.gitignore') | |
| venv_exclusion = 'venv/' | |
| # Create .gitignore if it doesn't exist | |
| if not os.path.exists(gitignore_path): | |
| with open(gitignore_path, 'w') as f: | |
| f.write(f"{venv_exclusion}\n") | |
| logger.debug("Created .gitignore file") | |
| else: | |
| # Check if venv exclusion already exists | |
| with open(gitignore_path, 'r') as f: | |
| content = f.read() | |
| if venv_exclusion not in content: | |
| with open(gitignore_path, 'a') as f: | |
| f.write(f"\n{venv_exclusion}\n") | |
| logger.debug("Updated .gitignore to exclude venv") | |
| def _setup_package_directory(self): | |
| """Set up the package directory structure.""" | |
| logger.info(f"Setting up package directory: {self.package_dir}") | |
| # Create or clean package directory | |
| if os.path.exists(self.package_dir): | |
| if self.verbose: | |
| logger.debug(f"Cleaning existing package directory: {self.package_dir}") | |
| shutil.rmtree(self.package_dir) | |
| else: | |
| logger.debug(f"Using existing package directory: {self.package_dir}") | |
| else: | |
| os.makedirs(self.package_dir) | |
| logger.debug(f"Created package directory: {self.package_dir}") | |
| # Copy project files (excluding .git and other unnecessary files) | |
| for item in os.listdir(self.input_dir): | |
| if item not in ['.git', '__pycache__', 'venv', self.package_dir]: | |
| src = os.path.join(self.input_dir, item) | |
| dst = os.path.join(self.package_dir, item) | |
| if os.path.isdir(src): | |
| # Check if directory already exists | |
| if os.path.exists(dst): | |
| logger.debug(f"Directory already exists, merging contents: {dst}") | |
| # Copy files from source to destination, skipping existing files | |
| for root, dirs, files in os.walk(src): | |
| # Get relative path from source directory | |
| rel_path = os.path.relpath(root, src) | |
| # Create corresponding directory in destination if it doesn't exist | |
| dst_dir = os.path.join(dst, rel_path) if rel_path != '.' else dst | |
| os.makedirs(dst_dir, exist_ok=True) | |
| # Copy files | |
| for file in files: | |
| src_file = os.path.join(root, file) | |
| dst_file = os.path.join(dst_dir, file) | |
| if not os.path.exists(dst_file): | |
| shutil.copy2(src_file, dst_file) | |
| logger.debug(f"Copied file: {src_file} -> {dst_file}") | |
| else: | |
| # Directory doesn't exist, use copytree | |
| shutil.copytree(src, dst, ignore=shutil.ignore_patterns('__pycache__', '*.pyc')) | |
| logger.debug(f"Copied directory: {src} -> {dst}") | |
| else: | |
| # Copy file if it doesn't exist or overwrite if --clean is specified | |
| if not os.path.exists(dst) or self.verbose: | |
| shutil.copy2(src, dst) | |
| logger.debug(f"Copied file: {src} -> {dst}") | |
| logger.debug("Project files copied to package directory") | |
| def _process_icons(self): | |
| """Process icons for all platforms.""" | |
| logger.info("Processing icons") | |
| # Initialize icon manager with prefixed icons directory | |
| icons_dir = os.path.join(self.package_dir, f"{self.packaging_prefix}icons") | |
| self.icon_manager = IconManager(self.icon_path, self.package_dir, self.project_name) | |
| # Update the icons_dir in the IconManager | |
| self.icon_manager.icons_dir = icons_dir | |
| # Create the prefixed icons directory | |
| os.makedirs(icons_dir, exist_ok=True) | |
| self.icon_manager.process_icons() | |
| def _create_virtual_environment(self): | |
| """Create a virtual environment in the package directory.""" | |
| logger.info("Creating virtual environment") | |
| # Create virtual environment | |
| try: | |
| if platform.system() == 'Windows': | |
| # On Windows, we'll use embedded Python for portability | |
| self._create_windows_portable_python() | |
| else: | |
| # On Unix systems, create a standard venv | |
| subprocess.run([sys.executable, '-m', 'venv', self.venv_dir], check=True) | |
| # Verify that the Python executable exists | |
| if platform.system() == 'Windows': | |
| python_exe = os.path.join(self.venv_dir, "Scripts", "python.exe") | |
| else: | |
| python_exe = os.path.join(self.venv_dir, "bin", "python") | |
| if not os.path.exists(python_exe): | |
| logger.warning(f"Python executable not found at {python_exe} after venv creation") | |
| # Try to create a standard venv as fallback | |
| logger.info("Attempting to create a standard venv as fallback") | |
| if os.path.exists(self.venv_dir): | |
| shutil.rmtree(self.venv_dir) | |
| subprocess.run([sys.executable, '-m', 'venv', self.venv_dir], check=True) | |
| # Verify again | |
| if not os.path.exists(python_exe): | |
| raise RuntimeError(f"Failed to create a working virtual environment. Python executable not found at {python_exe}") | |
| logger.debug(f"Virtual environment created at: {self.venv_dir}") | |
| logger.debug(f"Python executable verified at: {python_exe}") | |
| except subprocess.SubprocessError as e: | |
| raise RuntimeError(f"Failed to create virtual environment: {str(e)}") | |
| def _create_windows_portable_python(self): | |
| """Create a portable Python environment for Windows.""" | |
| import tempfile | |
| import urllib.request | |
| # Define Python embedded package URL (adjust version as needed) | |
| version = self.python_version.replace('.', '') | |
| python_embed_url = f"https://www.python.org/ftp/python/{self.python_version}/python-{self.python_version}-embed-amd64.zip" | |
| with tempfile.TemporaryDirectory() as temp_dir: | |
| # Download embedded Python | |
| embed_zip = os.path.join(temp_dir, "python-embed.zip") | |
| logger.debug(f"Downloading embedded Python from: {python_embed_url}") | |
| try: | |
| # Download with progress indication | |
| logger.info(f"Downloading Python {self.python_version} embedded package...") | |
| urllib.request.urlretrieve(python_embed_url, embed_zip) | |
| # Verify the download was successful | |
| if not os.path.exists(embed_zip) or os.path.getsize(embed_zip) < 1000: | |
| raise RuntimeError(f"Failed to download Python embedded package from {python_embed_url}") | |
| logger.debug(f"Download complete: {embed_zip} ({os.path.getsize(embed_zip)} bytes)") | |
| # Extract embedded Python to venv directory | |
| os.makedirs(self.venv_dir, exist_ok=True) | |
| logger.debug(f"Extracting embedded Python to {self.venv_dir}") | |
| with zipfile.ZipFile(embed_zip, 'r') as zip_ref: | |
| zip_ref.extractall(self.venv_dir) | |
| # Verify extraction was successful by checking for python.exe | |
| python_exe = os.path.join(self.venv_dir, "python.exe") | |
| if not os.path.exists(python_exe): | |
| logger.error(f"Failed to find python.exe in {self.venv_dir}") | |
| logger.error(f"Directory contents of {self.venv_dir}:") | |
| for item in os.listdir(self.venv_dir): | |
| logger.error(f" - {item}") | |
| raise RuntimeError(f"Python embedded package extraction failed - missing python.exe") | |
| # Find and modify the ._pth file to enable site-packages | |
| pth_files = [f for f in os.listdir(self.venv_dir) if f.endswith('._pth')] | |
| if not pth_files: | |
| logger.error(f"Failed to find any Python ._pth file in {self.venv_dir}") | |
| raise RuntimeError(f"Python embedded package extraction failed - missing ._pth file") | |
| # Use the first found pth file | |
| python_pth = os.path.join(self.venv_dir, pth_files[0]) | |
| logger.debug(f"Found Python path file: {python_pth}") | |
| # Enable site-packages | |
| with open(python_pth, 'r') as f: | |
| content = f.read() | |
| # Uncomment import site | |
| content = content.replace("#import site", "import site") | |
| with open(python_pth, 'w') as f: | |
| f.write(content) | |
| # Create Scripts directory if it doesn't exist | |
| scripts_dir = os.path.join(self.venv_dir, "Scripts") | |
| os.makedirs(scripts_dir, exist_ok=True) | |
| # Copy python.exe to Scripts dir to ensure compatibility with both direct and Scripts path references | |
| scripts_python_exe = os.path.join(scripts_dir, "python.exe") | |
| shutil.copy2(python_exe, scripts_python_exe) | |
| logger.debug(f"Copied python.exe to {scripts_python_exe}") | |
| # Also copy pythonw.exe if it exists | |
| pythonw_exe = os.path.join(self.venv_dir, "pythonw.exe") | |
| if os.path.exists(pythonw_exe): | |
| scripts_pythonw_exe = os.path.join(scripts_dir, "pythonw.exe") | |
| shutil.copy2(pythonw_exe, scripts_pythonw_exe) | |
| logger.debug(f"Copied pythonw.exe to {scripts_pythonw_exe}") | |
| # Create a verification script to test the Python installation | |
| test_script_path = os.path.join(self.venv_dir, "test_python.py") | |
| with open(test_script_path, 'w') as f: | |
| f.write('import sys\n') | |
| f.write('print("Python version:", sys.version)\n') | |
| f.write('print("Python executable:", sys.executable)\n') | |
| f.write('print("Python path:", sys.path)\n') | |
| logger.debug(f"Created Python verification script: {test_script_path}") | |
| logger.info(f"Windows portable Python setup complete in {self.venv_dir}") | |
| except Exception as e: | |
| logger.error(f"Error setting up Windows portable Python: {e}") | |
| raise RuntimeError(f"Failed to create Windows portable Python: {e}") | |
| def _install_dependencies(self): | |
| """Install dependencies into the virtual environment.""" | |
| logger.info("Installing dependencies") | |
| # Determine pip and Python paths based on platform | |
| if platform.system() == 'Windows': | |
| pip_path = os.path.join(self.venv_dir, "Scripts", "pip.exe") | |
| python_path = os.path.join(self.venv_dir, "python.exe") | |
| else: | |
| pip_path = os.path.join(self.venv_dir, "bin", "pip") | |
| python_path = os.path.join(self.venv_dir, "bin", "python") | |
| # Install requirements | |
| try: | |
| subprocess.run([pip_path, "install", "-r", self.requirements_file], check=True) | |
| logger.debug("Dependencies installed successfully") | |
| except subprocess.SubprocessError as e: | |
| raise RuntimeError(f"Failed to install dependencies: {str(e)}") | |
| def _create_launch_scripts(self): | |
| """Create platform-specific launch scripts.""" | |
| logger.info("Creating launch scripts") | |
| main_file_rel_path = os.path.relpath(self.main_file, self.current_dir) | |
| main_file_package_path = os.path.join(self.package_dir, main_file_rel_path) | |
| # Create Windows batch file if needed | |
| if 'windows' in self.target_platforms: | |
| bat_path = os.path.join(self.package_dir, "run.bat") | |
| with open(bat_path, 'w') as f: | |
| f.write('@echo off\r\n') | |
| f.write('setlocal enabledelayedexpansion\r\n') | |
| f.write('cd /d "%~dp0"\r\n') # Change to script directory | |
| # Look for Python in multiple possible locations within the package | |
| f.write(':: Find Python executable in the package\r\n') | |
| f.write('set "PYTHON_EXE=not_found"\r\n\r\n') | |
| # Check in embedded Python directory first | |
| f.write('if exist "venv\\python.exe" (\r\n') | |
| f.write(' set "PYTHON_EXE=venv\\python.exe"\r\n') | |
| f.write(' echo Found Python in venv directory\r\n') | |
| f.write(') else if exist "venv\\Scripts\\python.exe" (\r\n') | |
| f.write(' set "PYTHON_EXE=venv\\Scripts\\python.exe"\r\n') | |
| f.write(' echo Found Python in venv\\Scripts directory\r\n') | |
| f.write(') else (\r\n') | |
| f.write(' echo ERROR: Python executable not found!\r\n') | |
| f.write(' echo Looking in:\r\n') | |
| f.write(' echo - %~dp0venv\\python.exe\r\n') | |
| f.write(' echo - %~dp0venv\\Scripts\\python.exe\r\n') | |
| f.write(' echo.\r\n') | |
| f.write(' echo Directory contents of venv:\r\n') | |
| f.write(' dir /b venv\r\n') | |
| f.write(' echo.\r\n') | |
| f.write(' if exist "venv\\Scripts" (\r\n') | |
| f.write(' echo Directory contents of venv\\Scripts:\r\n') | |
| f.write(' dir /b venv\\Scripts\r\n') | |
| f.write(' )\r\n') | |
| f.write(' echo.\r\n') | |
| f.write(' pause\r\n') | |
| f.write(' exit /b 1\r\n') | |
| f.write(')\r\n\r\n') | |
| # Add verification for the main file | |
| f.write(f'if not exist "{main_file_rel_path}" (\r\n') | |
| f.write(f' echo Error: Main file not found: {main_file_rel_path}\r\n') | |
| f.write(' pause\r\n') | |
| f.write(' exit /b 1\r\n') | |
| f.write(')\r\n\r\n') | |
| # Run the application | |
| f.write(f'echo Running {self.project_name}...\r\n') | |
| # Add the current directory to PYTHONPATH to fix module imports | |
| f.write('set "PYTHONPATH=%~dp0"\r\n') | |
| f.write('"%PYTHON_EXE%" "{}"\r\n'.format(main_file_rel_path)) | |
| f.write('if errorlevel 1 (\r\n') | |
| f.write(' echo Application exited with an error (Code: %errorlevel%).\r\n') | |
| f.write(' pause\r\n') | |
| f.write(')\r\n') | |
| f.write('endlocal\r\n') | |
| logger.debug(f"Created Windows batch file: {bat_path}") | |
| # Create Unix shell script if needed | |
| if 'linux' in self.target_platforms or 'mac' in self.target_platforms: | |
| sh_path = os.path.join(self.package_dir, "run.sh") | |
| with open(sh_path, 'w') as f: | |
| f.write('#!/bin/bash\n') | |
| f.write('cd "$(dirname "$0")"\n') # Change to script directory | |
| f.write('./venv/bin/python {}\n'.format(main_file_rel_path)) | |
| # Make the shell script executable | |
| os.chmod(sh_path, 0o755) | |
| logger.debug(f"Created Unix shell script: {sh_path}") | |
| def _create_shortcuts(self): | |
| """Create platform-specific shortcuts.""" | |
| logger.info("Creating shortcuts") | |
| shortcuts_dir = os.path.join(self.package_dir, f"{self.packaging_prefix}shortcuts") | |
| os.makedirs(shortcuts_dir, exist_ok=True) | |
| # Windows .lnk shortcut | |
| if 'windows' in self.target_platforms: | |
| self._create_windows_shortcut(shortcuts_dir) | |
| # Linux .desktop file | |
| if 'linux' in self.target_platforms: | |
| self._create_linux_shortcut(shortcuts_dir) | |
| # macOS .alias file (simplified as a shell script) | |
| if 'mac' in self.target_platforms: | |
| self._create_mac_shortcut(shortcuts_dir) | |
| def _create_windows_shortcut(self, shortcuts_dir): | |
| """Create Windows .lnk shortcut.""" | |
| try: | |
| import win32com.client | |
| shortcut_path = os.path.join(shortcuts_dir, f"{self.project_name}.lnk") | |
| shell = win32com.client.Dispatch("WScript.Shell") | |
| shortcut = shell.CreateShortCut(shortcut_path) | |
| # Use relative paths with %~dp0 for portability | |
| # Path will be resolved relative to the shortcuts directory | |
| run_bat_path = os.path.join("%~dp0..", "run.bat") | |
| shortcut.TargetPath = run_bat_path | |
| shortcut.WorkingDirectory = "%~dp0.." | |
| if self.icon_path: | |
| # Copy the icon to the icons directory | |
| icons_dir = os.path.join(self.package_dir, f"{self.packaging_prefix}icons") | |
| os.makedirs(icons_dir, exist_ok=True) | |
| icon_dest = os.path.join(icons_dir, "windows.ico") | |
| # Make sure the icon exists | |
| if os.path.exists(self.icon_path): | |
| shutil.copy2(self.icon_path, icon_dest) | |
| # Use relative path for the icon | |
| shortcut.IconLocation = os.path.join("%~dp0..", f"{self.packaging_prefix}icons", "windows.ico") | |
| logger.debug(f"Set shortcut icon: {icon_dest}") | |
| shortcut.save() | |
| logger.debug(f"Created Windows shortcut: {shortcut_path}") | |
| except ImportError: | |
| # If win32com is not available, create a batch file that launches the application | |
| shortcut_path = os.path.join(shortcuts_dir, f"{self.project_name}.bat") | |
| with open(shortcut_path, 'w') as f: | |
| f.write('@echo off\r\n') | |
| f.write('cd /d "%~dp0\\.."\r\n') | |
| f.write('call run.bat\r\n') | |
| logger.debug(f"Created Windows batch launcher: {shortcut_path}") | |
| def _create_linux_shortcut(self, shortcuts_dir): | |
| """Create Linux .desktop file.""" | |
| desktop_path = os.path.join(shortcuts_dir, f"{self.project_name}.desktop") | |
| with open(desktop_path, 'w') as f: | |
| f.write("[Desktop Entry]\n") | |
| f.write(f"Name={self.project_name}\n") | |
| f.write(f"Comment={self.project_name} - Portable Application\n") | |
| f.write("Exec=bash -c 'cd \"$(dirname \"%k\")/..\" && ./run.sh'\n") | |
| f.write("Terminal=false\n") | |
| f.write("Type=Application\n") | |
| if self.icon_path: | |
| icon_dest = os.path.join(self.package_dir, f"{self.packaging_prefix}icon.png") | |
| shutil.copy2(self.icon_path, icon_dest) | |
| f.write(f"Icon=../{self.packaging_prefix}icon.png\n") | |
| # Make the desktop file executable | |
| os.chmod(desktop_path, 0o755) | |
| logger.debug(f"Created Linux .desktop file: {desktop_path}") | |
| def _create_mac_shortcut(self, shortcuts_dir): | |
| """Create macOS launcher script (as a substitute for .alias).""" | |
| alias_path = os.path.join(shortcuts_dir, f"{self.project_name}.command") | |
| with open(alias_path, 'w') as f: | |
| f.write('#!/bin/bash\n') | |
| f.write('DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"\n') | |
| f.write('cd "$DIR/.."\n') | |
| f.write('./run.sh\n') | |
| # Make the command file executable | |
| os.chmod(alias_path, 0o755) | |
| logger.debug(f"Created macOS command file: {alias_path}") | |
| def _create_documentation(self): | |
| """Create documentation on how to run the application.""" | |
| logger.info("Creating documentation") | |
| docs_path = os.path.join(self.package_dir, f"{self.packaging_prefix}how_to_run.txt") | |
| with open(docs_path, 'w') as f: | |
| f.write(f"{self.project_name} - Portable Application\n") | |
| f.write("=" * (len(self.project_name) + 22) + "\n\n") | |
| f.write("RUNNING THE APPLICATION\n") | |
| f.write("----------------------\n\n") | |
| if 'windows' in self.target_platforms: | |
| f.write("Windows:\n") | |
| f.write(" 1. Double-click on 'run.bat' in the main folder.\n") | |
| if 'shortcuts' in self.skip_items: | |
| f.write(f" 2. Alternatively, use the shortcut in the '{self.packaging_prefix}shortcuts' folder.\n") | |
| f.write("\n") | |
| if 'linux' in self.target_platforms: | |
| f.write("Linux:\n") | |
| f.write(" 1. Make sure the 'run.sh' file is executable (chmod +x run.sh).\n") | |
| f.write(" 2. Run the script with './run.sh' in a terminal.\n") | |
| if 'shortcuts' in self.skip_items: | |
| f.write(f" 3. Alternatively, use the .desktop file in the '{self.packaging_prefix}shortcuts' folder.\n") | |
| f.write(" You may need to right-click and select 'Allow Executing' first.\n") | |
| f.write("\n") | |
| if 'mac' in self.target_platforms: | |
| f.write("macOS:\n") | |
| f.write(" 1. Make sure the 'run.sh' file is executable (chmod +x run.sh).\n") | |
| f.write(" 2. Run the script with './run.sh' in a terminal.\n") | |
| if 'shortcuts' in self.skip_items: | |
| f.write(f" 3. Alternatively, use the .command file in the '{self.packaging_prefix}shortcuts' folder.\n") | |
| f.write(" You may need to right-click and select 'Open' the first time.\n") | |
| f.write("\n") | |
| f.write("INSTALLATION\n") | |
| f.write("------------\n\n") | |
| f.write("This is a portable application and does not require installation.\n") | |
| f.write("Simply move the extracted files to a directory of your choice and run as described above\n\n") | |
| if 'shortcuts' in self.skip_items: | |
| f.write("SHORTCUTS\n") | |
| f.write("---------\n\n") | |
| f.write(f"Shortcut files are provided in the '{self.packaging_prefix}shortcuts' folder.\n") | |
| f.write("You can copy these to your desktop or other locations for easy access.\n") | |
| logger.debug(f"Created documentation file: {docs_path}") | |
| def _create_zip_package(self): | |
| """Create a ZIP package of the portable application.""" | |
| logger.info("Creating ZIP package") | |
| zip_path = os.path.join(self.output_dir, f"{self.project_name}Portable.zip") | |
| # Remove existing zip if it exists | |
| if os.path.exists(zip_path): | |
| os.remove(zip_path) | |
| # Create zip file | |
| with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf: | |
| for root, _, files in os.walk(self.package_dir): | |
| for file in files: | |
| file_path = os.path.join(root, file) | |
| arcname = os.path.relpath(file_path, self.output_dir) | |
| zipf.write(file_path, arcname) | |
| logger.debug(f"Created ZIP package: {zip_path}") | |
| def _cleanup(self): | |
| """Clean up after packaging process.""" | |
| logger.info("Cleaning up") | |
| # Switch back to original branch if we have one and this is a git repo with git integration enabled | |
| if self.has_git and self.use_git and self.original_branch: | |
| try: | |
| subprocess.run(['git', 'checkout', self.original_branch], check=True, cwd=self.input_dir) | |
| logger.debug(f"Switched back to branch: {self.original_branch}") | |
| except subprocess.SubprocessError: | |
| logger.warning(f"Failed to switch back to branch: {self.original_branch}") | |
| def _parse_skip_items(self, skip_str): | |
| """Parse the skip items from a comma-separated string.""" | |
| if not skip_str: | |
| return [] | |
| valid_items = {'venv', 'docs', 'shortcuts', 'icons', 'zip'} | |
| items = [item.strip().lower() for item in skip_str.split(',')] | |
| # Validate items | |
| invalid_items = [item for item in items if item not in valid_items and item] | |
| if invalid_items: | |
| raise ConfigurationError( | |
| f"Invalid skip items: {', '.join(invalid_items)}. " | |
| f"Valid options are: {', '.join(valid_items)}" | |
| ) | |
| return [item for item in items if item] | |
| def _parse_target_platforms(self, target): | |
| """Parse the target platform argument.""" | |
| if target.lower() == 'all': | |
| return ['windows', 'linux', 'mac'] | |
| else: | |
| platforms = [p.strip().lower() for p in target.split(',')] | |
| valid_platforms = {'windows', 'linux', 'mac'} | |
| invalid_platforms = [p for p in platforms if p not in valid_platforms] | |
| if invalid_platforms: | |
| raise ConfigurationError( | |
| f"Invalid target platforms: {', '.join(invalid_platforms)}. " | |
| f"Valid options are: {', '.join(valid_platforms)} or 'all'" | |
| ) | |
| return platforms | |
| def _check_python_version(self): | |
| """Check if the specified Python version is compatible.""" | |
| # If using a specific Python version, validate it | |
| try: | |
| if self.python_version: | |
| version_parts = self.python_version.split('.') | |
| major = int(version_parts[0]) | |
| minor = int(version_parts[1]) if len(version_parts) > 1 else 0 | |
| if major < 3 or (major == 3 and minor < 6): | |
| logger.warning(f"Python version {self.python_version} may not be compatible. Python 3.6+ recommended.") | |
| except (ValueError, IndexError): | |
| logger.warning(f"Could not parse Python version: {self.python_version}. Using current version.") | |
| self.python_version = None | |
| def handle_install_command(args): | |
| """Install PackWeasel globally or for the current user.""" | |
| # Check if already installed via pip | |
| is_pip_installed = False | |
| script_path = os.path.abspath(__file__) | |
| try: | |
| import pkg_resources | |
| pkg_resources.get_distribution("packweasel") | |
| is_pip_installed = True | |
| except (ImportError, pkg_resources.DistributionNotFound): | |
| pass | |
| # If already installed via pip, show a message | |
| if is_pip_installed: | |
| logger.info("\n✓ PackWeasel is already installed via pip!") | |
| logger.info("\nYou can run it from anywhere with the command:") | |
| logger.info(" packweasel [command] [options]") | |
| # If the current script is not in the pip-installed location, show a note | |
| pip_executable = None | |
| try: | |
| # Try to find the pip-installed executable | |
| for path_dir in site.getsitepackages() + [site.getusersitepackages()]: | |
| possible_path = os.path.join(path_dir, "packweasel") | |
| if os.path.exists(possible_path) or os.path.exists(possible_path + ".exe"): | |
| pip_executable = possible_path | |
| break | |
| except: | |
| pass | |
| if pip_executable and os.path.normpath(pip_executable) != os.path.normpath(script_path): | |
| logger.info("\nNote: This copy of packweasel.py is not the pip-installed version.") | |
| logger.info("You can safely delete this local copy if you prefer using the pip version.") | |
| return 0 | |
| # Check if already manually installed | |
| if platform.system() == 'Windows': | |
| cmd_path = shutil.which('packweasel') or shutil.which('packweasel.bat') | |
| if cmd_path: | |
| logger.info("\n✓ PackWeasel is already installed at:") | |
| logger.info(f" {cmd_path}") | |
| logger.info("\nYou can run it from anywhere with:") | |
| logger.info(" packweasel [command] [options]") | |
| # If the current script is not the same as the installed version, show a note | |
| if os.path.normpath(os.path.dirname(cmd_path)) != os.path.normpath(os.path.dirname(script_path)): | |
| logger.info("\nNote: This copy of packweasel.py is different from the installed version.") | |
| return 0 | |
| else: # macOS/Linux | |
| cmd_path = shutil.which('packweasel') | |
| if cmd_path: | |
| logger.info("\n✓ PackWeasel is already installed at:") | |
| logger.info(f" {cmd_path}") | |
| logger.info("\nYou can run it from anywhere with:") | |
| logger.info(" packweasel [command] [options]") | |
| # Check if it's a symlink to the current file | |
| if os.path.islink(cmd_path): | |
| target = os.path.realpath(cmd_path) | |
| if os.path.normpath(target) != os.path.normpath(script_path): | |
| logger.info("\nNote: This copy of packweasel.py is different from the installed version.") | |
| logger.info(f"The installed version points to: {target}") | |
| return 0 | |
| # Not installed via pip or manually, so install it manually | |
| install_global = args.get('--global', False) | |
| if platform.system() == 'Windows': | |
| return _install_windows(script_path, install_global) | |
| elif platform.system() == 'Darwin': # macOS | |
| return _install_macos(script_path, install_global) | |
| else: # Linux/Unix | |
| return _install_linux(script_path, install_global) | |
| def _install_windows(script_path, install_global=False): | |
| """Install on Windows.""" | |
| if install_global and not _is_admin(): | |
| logger.error("Error: Global installation requires administrator privileges.") | |
| logger.error("Please run the command prompt as administrator and try again.") | |
| return 1 | |
| try: | |
| if install_global: | |
| # Global installation (Program Files) | |
| install_dir = os.path.join(os.environ.get('ProgramFiles', 'C:\\Program Files'), 'PackWeasel') | |
| scripts_dir = os.path.join(install_dir, 'bin') | |
| else: | |
| # User installation (AppData) | |
| install_dir = os.path.join(os.environ.get('APPDATA', ''), 'PackWeasel') | |
| scripts_dir = os.path.join(install_dir, 'bin') | |
| # Create directories | |
| os.makedirs(scripts_dir, exist_ok=True) | |
| # Copy the script | |
| dest_path = os.path.join(scripts_dir, 'packweasel.py') | |
| shutil.copy2(script_path, dest_path) | |
| # Create a batch file wrapper | |
| bat_path = os.path.join(scripts_dir, 'packweasel.bat') | |
| with open(bat_path, 'w') as f: | |
| f.write('@echo off\n') | |
| f.write(f'python "{dest_path}" %*') | |
| # Add to PATH if needed | |
| if not _check_path(scripts_dir): | |
| if install_global: | |
| _add_to_system_path(scripts_dir) | |
| else: | |
| _add_to_user_path(scripts_dir) | |
| logger.info(f"\n✓ PackWeasel installed successfully to {install_dir}") | |
| if _check_path(scripts_dir): | |
| logger.info("\nYou can now run PackWeasel from anywhere with:") | |
| logger.info(" packweasel [command] [options]") | |
| else: | |
| logger.info(f"\nPlease add {scripts_dir} to your PATH to run PackWeasel from anywhere.") | |
| logger.info("Until then, you can run it with:") | |
| logger.info(f" {bat_path} [command] [options]") | |
| return 0 | |
| except Exception as e: | |
| logger.error(f"Error during installation: {e}") | |
| return 1 | |
| def _install_macos(script_path, install_global=False): | |
| """Install on macOS.""" | |
| if install_global and os.geteuid() != 0: | |
| logger.error("Error: Global installation requires sudo privileges.") | |
| logger.error("Please run with sudo and try again.") | |
| return 1 | |
| try: | |
| if install_global: | |
| # Global installation | |
| bin_dir = '/usr/local/bin' | |
| else: | |
| # User installation | |
| home_dir = os.path.expanduser('~') | |
| bin_dir = os.path.join(home_dir, '.local', 'bin') | |
| os.makedirs(bin_dir, exist_ok=True) | |
| # Create symlink | |
| dest_path = os.path.join(bin_dir, 'packweasel') | |
| # Make script executable | |
| os.chmod(script_path, 0o755) | |
| # If symlink already exists, remove it | |
| if os.path.exists(dest_path): | |
| os.remove(dest_path) | |
| # Create symlink | |
| os.symlink(script_path, dest_path) | |
| logger.info(f"\n✓ PackWeasel installed successfully to {bin_dir}") | |
| # Check if bin_dir is in PATH | |
| if bin_dir not in os.environ.get('PATH', '').split(':'): | |
| shell = os.environ.get('SHELL', '').split('/')[-1] | |
| if shell in ('bash', 'zsh'): | |
| rc_file = '.bashrc' if shell == 'bash' else '.zshrc' | |
| logger.info(f"\nPlease add the following line to your ~/{rc_file}:") | |
| logger.info(f" export PATH=\"{bin_dir}:$PATH\"") | |
| logger.info("\nThen restart your terminal or run:") | |
| logger.info(f" source ~/{rc_file}") | |
| else: | |
| logger.info(f"\nPlease add {bin_dir} to your PATH to run PackWeasel from anywhere.") | |
| else: | |
| logger.info("\nYou can now run PackWeasel from anywhere with:") | |
| logger.info(" packweasel [command] [options]") | |
| return 0 | |
| except Exception as e: | |
| logger.error(f"Error during installation: {e}") | |
| return 1 | |
| def _install_linux(script_path, install_global=False): | |
| """Install on Linux.""" | |
| if install_global and os.geteuid() != 0: | |
| logger.error("Error: Global installation requires sudo privileges.") | |
| logger.error("Please run with sudo and try again.") | |
| return 1 | |
| try: | |
| if install_global: | |
| # Global installation | |
| bin_dir = '/usr/local/bin' | |
| else: | |
| # User installation | |
| home_dir = os.path.expanduser('~') | |
| bin_dir = os.path.join(home_dir, '.local', 'bin') | |
| os.makedirs(bin_dir, exist_ok=True) | |
| # Create symlink | |
| dest_path = os.path.join(bin_dir, 'packweasel') | |
| # Make script executable | |
| os.chmod(script_path, 0o755) | |
| # If symlink already exists, remove it | |
| if os.path.exists(dest_path): | |
| os.remove(dest_path) | |
| # Create symlink | |
| os.symlink(script_path, dest_path) | |
| logger.info(f"\n✓ PackWeasel installed successfully to {bin_dir}") | |
| # Check if bin_dir is in PATH | |
| if bin_dir not in os.environ.get('PATH', '').split(':'): | |
| shell = os.environ.get('SHELL', '').split('/')[-1] | |
| if shell in ('bash', 'zsh'): | |
| rc_file = '.bashrc' if shell == 'bash' else '.zshrc' | |
| logger.info(f"\nPlease add the following line to your ~/{rc_file}:") | |
| logger.info(f" export PATH=\"{bin_dir}:$PATH\"") | |
| logger.info("\nThen restart your terminal or run:") | |
| logger.info(f" source ~/{rc_file}") | |
| else: | |
| logger.info(f"\nPlease add {bin_dir} to your PATH to run PackWeasel from anywhere.") | |
| else: | |
| logger.info("\nYou can now run PackWeasel from anywhere with:") | |
| logger.info(" packweasel [command] [options]") | |
| return 0 | |
| except Exception as e: | |
| logger.error(f"Error during installation: {e}") | |
| return 1 | |
| def _is_admin(): | |
| """Check if the current process has administrator privileges on Windows.""" | |
| if platform.system() != 'Windows': | |
| return False | |
| try: | |
| import ctypes | |
| return ctypes.windll.shell32.IsUserAnAdmin() != 0 | |
| except: | |
| return False | |
| def _check_path(directory): | |
| """Check if a directory is in PATH.""" | |
| path_dirs = os.environ.get('PATH', '').split(os.pathsep) | |
| return os.path.normpath(directory) in [os.path.normpath(p) for p in path_dirs] | |
| def _add_to_user_path(directory): | |
| """Add a directory to the user PATH on Windows.""" | |
| try: | |
| import winreg | |
| with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r'Environment', 0, winreg.KEY_ALL_ACCESS) as key: | |
| try: | |
| path, _ = winreg.QueryValueEx(key, 'PATH') | |
| except WindowsError: | |
| path = '' | |
| if not path.endswith(';'): | |
| path += ';' | |
| if directory not in path: | |
| path += directory + ';' | |
| winreg.SetValueEx(key, 'PATH', 0, winreg.REG_EXPAND_SZ, path) | |
| # Notify other processes of the change | |
| import ctypes | |
| HWND_BROADCAST = 0xFFFF | |
| WM_SETTINGCHANGE = 0x001A | |
| SMTO_ABORTIFHUNG = 0x0002 | |
| result = ctypes.c_long() | |
| ctypes.windll.user32.SendMessageTimeoutW(HWND_BROADCAST, WM_SETTINGCHANGE, 0, | |
| "Environment", SMTO_ABORTIFHUNG, 5000, ctypes.byref(result)) | |
| return True | |
| except: | |
| return False | |
| def _add_to_system_path(directory): | |
| """Add a directory to the system PATH on Windows.""" | |
| try: | |
| import winreg | |
| with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', 0, winreg.KEY_ALL_ACCESS) as key: | |
| try: | |
| path, _ = winreg.QueryValueEx(key, 'PATH') | |
| except WindowsError: | |
| path = '' | |
| if not path.endswith(';'): | |
| path += ';' | |
| if directory not in path: | |
| path += directory + ';' | |
| winreg.SetValueEx(key, 'PATH', 0, winreg.REG_EXPAND_SZ, path) | |
| # Notify other processes of the change | |
| import ctypes | |
| HWND_BROADCAST = 0xFFFF | |
| WM_SETTINGCHANGE = 0x001A | |
| SMTO_ABORTIFHUNG = 0x0002 | |
| result = ctypes.c_long() | |
| ctypes.windll.user32.SendMessageTimeoutW(HWND_BROADCAST, WM_SETTINGCHANGE, 0, | |
| "Environment", SMTO_ABORTIFHUNG, 5000, ctypes.byref(result)) | |
| return True | |
| except: | |
| return False | |
| def main(): | |
| """Main entry point""" | |
| try: | |
| # Parse command line arguments with help=False to handle help manually | |
| args = docopt(__doc__, version=f"PackWeasel {VERSION}", help=False) | |
| # Handle help topics | |
| # Check if -h or --help was used | |
| if args.get('--help') is not False: # Will be True or a string if present | |
| help_value = args['--help'] | |
| # If it's True (flag used without value) or '-h' was used | |
| if help_value is True or args.get('-h'): | |
| # Display general help | |
| print(__doc__) | |
| return 0 | |
| # If it has a value (topic specified) | |
| elif isinstance(help_value, str): | |
| # Topic-specific help | |
| if help_value == 'features': | |
| print(__features__) | |
| return 0 | |
| elif help_value == 'examples': | |
| print(__examples__) | |
| return 0 | |
| elif help_value == 'install': | |
| print(__install__) | |
| return 0 | |
| else: | |
| print(f"Unknown help topic: {help_value}") | |
| print("Available topics: features, examples, install") | |
| return 1 | |
| # Set logging level | |
| if args['--verbose']: | |
| logger.setLevel(logging.DEBUG) | |
| # Handle commands | |
| if args['install']: | |
| return handle_install_command(args) | |
| # Only handle the 'package' command for now | |
| if args['package']: | |
| # Directory validation when using default directory or not explicitly set | |
| input_dir = os.path.abspath(args['--dir']) | |
| # Auto-detect name if using '.' as the default | |
| if args['--name'] == '.': | |
| args['--name'] = os.path.basename(input_dir) | |
| logger.debug(f"Auto-detected project name as '{args['--name']}'") | |
| # Only validate if using current directory or not explicitly specified | |
| if input_dir == os.path.abspath('.') and args['--dir'] == '.': | |
| logger.debug("Validating default directory...") | |
| is_valid, reason = is_valid_project_directory(input_dir) | |
| if not is_valid: | |
| logger.error(f"Directory validation failed: {reason}") | |
| logger.error("To package this directory anyway, explicitly specify it with --dir") | |
| return 1 | |
| # Create and run the packager | |
| packager = ReleasePackager(dicto(args)) | |
| packager.run() | |
| return 0 | |
| # If we get here, no valid command was provided | |
| print("Error: Please specify a valid command.") | |
| print(__doc__) | |
| return 1 | |
| except DocoptExit: | |
| print(__doc__) | |
| return 1 | |
| except PackWeaselError as e: | |
| logger.error(f"Error: {e}") | |
| return 1 | |
| except Exception as e: | |
| logger.error(f"Unexpected error: {e}") | |
| if logger.level == logging.DEBUG: | |
| import traceback | |
| traceback.print_exc() | |
| return 1 | |
| # Entry point for pip installation | |
| def run_clu(): | |
| """Entry point for console_scripts in setup.py""" | |
| sys.exit(main()) | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment