Skip to content

Instantly share code, notes, and snippets.

@Sh-ui
Last active March 5, 2025 21:52
Show Gist options
  • Select an option

  • Save Sh-ui/77bcf28301719ab4a18d0d180e607eab to your computer and use it in GitHub Desktop.

Select an option

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
#!/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