commit ec391934309edf3c9f986d5382ec4aeae2412956 Author: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Tue Nov 19 01:21:25 2024 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..184ff96 --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +# pip-add + +A command-line tool that combines package installation and requirements.txt management. Install, update, or remove Python packages and their dependencies with automatic requirements.txt handling. + +## Features + +- Single command for package management and requirements.txt updates +- Smart dependency handling for installation and removal +- Dependency analysis to prevent breaking other packages +- Flexible version specifications (`>=` by default) +- Optional exact version pinning (`==`) +- Clean, informative output with version information +- Preserves requirements.txt comments and formatting +- Creates requirements.txt if it doesn't exist + +## Installation + +```bash +# Clone the repository +pip install pip-add + +## Usage + +### Installation + +```bash +# Basic package installation +pip-add requests +# Output: +# Installing requests... +# ✓ Successfully installed requests (2.32.3) +# ✓ Updated requirements.txt + +# Install with exact version +pip-add -e requests +# Adds: requests==2.32.3 to requirements.txt + +# Install with dependencies +pip-add -d requests +# Output: +# Installing requests... +# ✓ Successfully installed: +# - certifi (2024.8.30) +# - charset-normalizer (3.4.0) +# - idna (3.10) +# - requests (2.32.3) +# - urllib3 (2.2.3) +# ✓ Updated requirements.txt +``` + +### Removal + +```bash +# Remove single package +pip-add -r requests +# Output: +# Removing packages... +# ✓ Successfully uninstalled requests (2.32.3) +# ✓ Updated requirements.txt + +# Remove package and its unused dependencies +pip-add -d -r requests +# Output: +# Removing packages... +# ✓ Successfully uninstalled: +# - certifi (2024.8.30) +# - charset-normalizer (3.4.0) +# - requests (2.32.3) +# - urllib3 (2.2.3) +# +# ℹ️ Dependencies kept (required by other packages): +# - idna (needed by: email-validator, cryptography) +# +# ✓ Updated requirements.txt +``` + +## Command Line Options + +``` +pip-add [-h] [-d] [-e] [-r] package + +positional arguments: + package Package to install or remove + +options: + -h, --help show this help message and exit + -d, --dependencies Include dependencies when installing or removing + -e, --exact Use == instead of >= for version specification + -r, --remove Remove package(s) and their entries from requirements.txt +``` + +## How It Works + +### Installation Process + +1. Installs the specified package using pip +2. Retrieves installed version information +3. With `-d`: tracks and installs all dependencies +4. Updates requirements.txt with new package(s) +5. Uses `>=` by default or `==` with `-e` flag + +### Removal Process + +1. Analyzes package dependencies +2. Identifies which dependencies are safe to remove +3. Checks if any dependencies are needed by other packages +4. Safely removes unused packages +5. Updates requirements.txt +6. Reports kept dependencies and their dependents + +## Safe Dependency Handling + +The tool is designed to safely handle dependencies: + +- **Installation**: Records all dependencies when using `-d` +- **Removal**: Only removes dependencies that aren't needed by other packages +- **Analysis**: Shows which dependencies were kept and why +- **Protection**: Prevents breaking other installed packages + +## File Structure + +``` +pip_add/ +├── setup.py # Package configuration +├── pip_add/ +│ ├── __init__.py # Package initialization +│ └── cli.py # Main implementation +``` + +## Requirements + +- Python 3.6+ +- pip +- setuptools + +## Development Setup + +```bash +# Create virtual environment +python -m venv venv +source venv/bin/activate # or `venv\Scripts\activate` on Windows + +# Install in development mode +pip install -e . +``` + +## Common Scenarios + +### New Project + +```bash +# First time setup +pip-add -d flask +# Creates requirements.txt and adds Flask with dependencies +``` + +### Updating Dependencies + +```bash +# Update with newer versions +pip-add requests +# Updates to newest version with >= specification +``` + +### Clean Uninstall + +```bash +# Remove package and unused dependencies +pip-add -d -r flask +# Removes Flask and dependencies not used by other packages +``` + +## Troubleshooting + +1. **Package not found in requirements.txt** + - The file will be created automatically + - Existing comments are preserved + +2. **Dependency conflicts** + - Uses `>=` by default to minimize conflicts + - Use `-e` for exact versions when needed + +3. **Dependencies not removing** + - Check the output for dependencies kept + - Tool will show which packages need them diff --git a/pip_add/__init__.py b/pip_add/__init__.py new file mode 100644 index 0000000..6396207 --- /dev/null +++ b/pip_add/__init__.py @@ -0,0 +1,5 @@ +from .cli import main +from .utils import find_requirements, add_to_requirements + +__version__ = "0.1.0" +__all__ = ['main', 'find_requirements', 'add_to_requirements'] \ No newline at end of file diff --git a/pip_add/cli.py b/pip_add/cli.py new file mode 100644 index 0000000..a99b886 --- /dev/null +++ b/pip_add/cli.py @@ -0,0 +1,238 @@ +import os +import sys +import subprocess +import pkg_resources +import argparse +from pkg_resources import working_set + +def find_requirements(): + """Find requirements.txt in current directory or create it""" + req_file = 'requirements.txt' + + if not os.path.exists(req_file): + with open(req_file, 'w') as f: + f.write('# Python dependencies\n') + + return req_file + +def get_package_dependencies(package_name): + """Get all dependencies of a package""" + try: + dist = pkg_resources.get_distribution(package_name) + deps = {package_name: dist.version} # Start with the main package + + for req in dist.requires(): + try: + dep_dist = pkg_resources.get_distribution(req.key) + deps[req.key] = dep_dist.version + except pkg_resources.DistributionNotFound: + continue + + return deps + except Exception as e: + print(f"Warning: Could not fetch dependencies: {str(e)}", file=sys.stderr) + return {package_name: pkg_resources.get_distribution(package_name).version} + +def find_dependent_packages(package_name, excluding_package): + """ + Find all installed packages that depend on the given package, + excluding the package we're removing and its dependencies + """ + dependents = {} + for dist in working_set: + # Skip the package we're removing and its dependencies + if dist.key == excluding_package or dist.key == package_name: + continue + for req in dist.requires(): + if req.key == package_name: + dependents[package_name] = dependents.get(package_name, []) + dependents[package_name].append(dist.key) + return dependents + +def analyze_dependencies(package_name): + """Analyze which dependencies can be removed and which are still needed""" + try: + # Get all dependencies of the package we're removing + all_deps = get_package_dependencies(package_name) + safe_to_remove = set() + kept_deps = {} + + # First, mark the main package for removal + main_pkg_version = all_deps.pop(package_name) + safe_to_remove.add((package_name, main_pkg_version)) + + # Then check each dependency + for dep, version in all_deps.items(): + # Check if anything besides the package we're removing needs this dependency + dependents = find_dependent_packages(dep, package_name) + if not dependents.get(dep): + safe_to_remove.add((dep, version)) + else: + kept_deps[dep] = dependents[dep] + + return safe_to_remove, kept_deps + except Exception as e: + print(f"Warning: Could not analyze dependencies: {str(e)}", file=sys.stderr) + version = pkg_resources.get_distribution(package_name).version + return {(package_name, version)}, {} + +def remove_from_requirements(packages, req_file): + """Remove packages from requirements.txt""" + try: + with open(req_file, 'r') as f: + requirements = f.readlines() + except FileNotFoundError: + return set() + + # Keep track of removed packages + removed = set() + + # Filter out the packages while keeping comments + new_reqs = [] + for line in requirements: + if line.strip() and not line.strip().startswith('#'): + # Check if this line is one of our packages + pkg = line.split('>=')[0].split('==')[0].strip() + if pkg not in {name for name, _ in packages}: + new_reqs.append(line) + else: + removed.add(pkg) + else: + new_reqs.append(line) + + # Write back the filtered requirements + with open(req_file, 'w') as f: + f.writelines(new_reqs) + + return removed + +def add_to_requirements(packages_dict, req_file, exact=False): + """Add or update packages in requirements.txt""" + try: + with open(req_file, 'r') as f: + requirements = f.readlines() + except FileNotFoundError: + requirements = [] + + # Keep comments and empty lines + filtered_reqs = [line for line in requirements if line.strip() and not line.strip().startswith('#')] + comments = [line for line in requirements if not line.strip() or line.strip().startswith('#')] + + # Convert existing requirements to dict for easy updating + existing_pkgs = {} + for line in filtered_reqs: + if '>=' in line or '==' in line: + name = line.split('>=')[0].split('==')[0].strip() + existing_pkgs[name] = line + + # Update or add new packages + for package, version in packages_dict.items(): + operator = '==' if exact else '>=' + requirement_line = f"{package}{operator}{version}\n" + existing_pkgs[package] = requirement_line + + # Combine comments and updated requirements + final_requirements = comments + [existing_pkgs[pkg] for pkg in sorted(existing_pkgs.keys())] + + with open(req_file, 'w') as f: + f.writelines(final_requirements) + +def install_package(package_name): + """Install a package using pip""" + print(f"Installing {package_name}...") + subprocess.check_call( + [sys.executable, '-m', 'pip', 'install', package_name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + +def uninstall_packages(packages): + """Uninstall multiple packages silently""" + print("Removing packages...") + for package, version in packages: + try: + subprocess.check_call( + [sys.executable, '-m', 'pip', 'uninstall', '-y', package], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + except subprocess.CalledProcessError as e: + print(f"Warning: Error uninstalling {package}: {str(e)}", file=sys.stderr) + +def main(): + parser = argparse.ArgumentParser(description='Install/remove packages and manage requirements.txt') + parser.add_argument('package', help='Package to install or remove') + parser.add_argument('-d', '--with-dependencies', action='store_true', + help='Include dependencies when installing or removing') + parser.add_argument('-e', '--exact', action='store_true', + help='Use == instead of >= for version specification') + parser.add_argument('-r', '--remove', action='store_true', + help='Remove package(s) and their entries from requirements.txt') + + args = parser.parse_args() + package = args.package + req_file = find_requirements() + + try: + if args.remove: + if args.with_dependencies: + # Analyze dependencies + to_remove, kept_deps = analyze_dependencies(package) + + # Remove from requirements.txt and uninstall + removed = remove_from_requirements(to_remove, req_file) + uninstall_packages(to_remove) + + # Report results + if removed: + print("\n✓ Successfully uninstalled:") + for pkg, version in sorted(to_remove): + print(f" - {pkg} ({version})") + + if kept_deps: + print("\nℹ️ Dependencies kept (required by other packages):") + for dep, dependents in sorted(kept_deps.items()): + print(f" - {dep} (needed by: {', '.join(sorted(dependents))})") + + print(f"\n✓ Updated {req_file}") + else: + print(f"\n✓ Successfully uninstalled {package} (no entries found in {req_file})") + else: + # Remove single package + version = pkg_resources.get_distribution(package).version + uninstall_packages({(package, version)}) + removed = remove_from_requirements({(package, version)}, req_file) + if removed: + print(f"\n✓ Successfully uninstalled {package} ({version})") + print(f"✓ Updated {req_file}") + else: + print(f"\n✓ Successfully uninstalled {package} ({version})") + print(f"ℹ️ Note: Package was not found in {req_file}") + else: + # Install package + install_package(package) + + # Get package info and update requirements + if args.with_dependencies: + packages = get_package_dependencies(package) + print("\n✓ Successfully installed:") + for pkg, version in sorted(packages.items()): + print(f" - {pkg} ({version})") + else: + version = pkg_resources.get_distribution(package).version + packages = {package: version} + print(f"\n✓ Successfully installed {package} ({version})") + + # Update requirements.txt + add_to_requirements(packages, req_file, args.exact) + print(f"✓ Updated {req_file}") + + except subprocess.CalledProcessError as e: + print(f"Error {'removing' if args.remove else 'installing'} {package}: {str(e)}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error: {str(e)}", file=sys.stderr) + sys.exit(1) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/pip_add/utils.py b/pip_add/utils.py new file mode 100644 index 0000000..fbaa393 --- /dev/null +++ b/pip_add/utils.py @@ -0,0 +1,83 @@ +import os +from pathlib import Path +from typing import Optional, List, Tuple + +def find_requirements(start_dir: Optional[str] = None) -> Tuple[str, List[str]]: + """ + Find requirements files by searching up through parent directories. + Returns tuple of (chosen_file, all_found_files). + """ + requirements_files = [ + 'requirements.txt', + 'requirements/base.txt', + 'requirements/dev.txt', + 'requirements/development.txt', + 'requirements/prod.txt', + 'requirements/production.txt' + ] + + if start_dir is None: + start_dir = os.getcwd() + + current_dir = Path(start_dir).resolve() + found_files = [] + + # Search up through parent directories + while True: + for req_file in requirements_files: + req_path = current_dir / req_file + if req_path.exists(): + found_files.append(str(req_path)) + + # Stop if we found files or reached root + if found_files or current_dir.parent == current_dir: + break + + current_dir = current_dir.parent + + # If no files found, default to requirements.txt in current directory + if not found_files: + default_file = os.path.join(start_dir, 'requirements.txt') + # Create the requirements directory if needed + os.makedirs(os.path.dirname(default_file), exist_ok=True) + # Create an empty requirements file + if not os.path.exists(default_file): + with open(default_file, 'w') as f: + f.write('# Python dependencies\n') + return default_file, [default_file] + + # Prefer requirements.txt in the closest directory + for f in found_files: + if os.path.basename(f) == 'requirements.txt': + return f, found_files + + # Otherwise take the first found file + return found_files[0], found_files + +def add_to_requirements(package_name: str, version: str, requirements_file: str) -> None: + """Add or update package in requirements file""" + requirement_line = f"{package_name}=={version}\n" + + try: + with open(requirements_file, 'r') as f: + requirements = f.readlines() + except FileNotFoundError: + requirements = [] + + # Check if package exists and update it + package_exists = False + for i, line in enumerate(requirements): + if line.strip() and package_name == line.split('==')[0].strip(): + requirements[i] = requirement_line + package_exists = True + break + + # Add package if it doesn't exist + if not package_exists: + requirements.append(requirement_line) + + # Create directory if it doesn't exist + os.makedirs(os.path.dirname(os.path.abspath(requirements_file)), exist_ok=True) + + with open(requirements_file, 'w') as f: + f.writelines(requirements) \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..32069b7 --- /dev/null +++ b/setup.py @@ -0,0 +1,18 @@ +from setuptools import setup, find_packages + +setup( + name="pip-add", + version="0.1.0", + packages=find_packages(), + install_requires=[ + "pip", + "setuptools", + ], + entry_points={ + 'console_scripts': [ + 'pip-add=pip_add.cli:main', + ], + }, + author="PacNPal", + description="A CLI tool to install packages and add them to requirements.txt", +) \ No newline at end of file