diff --git a/.github/workflows/test-pyton.yml b/.github/workflows/test-pyton.yml new file mode 100644 index 0000000..b49af45 --- /dev/null +++ b/.github/workflows/test-pyton.yml @@ -0,0 +1,91 @@ +# Copyright (C) 2025 Serghei Iakovlev +# +# This file is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This file is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this file. If not, see . + +--- +name: Test Python Code + +on: + push: + paths: + - 'bin/pip-query' + pull_request: + paths: + - 'bin/pip-query' + +env: + PYTHONUNBUFFERED: '1' + +defaults: + run: + shell: bash + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + + # The maximum number of minutes to let a workflow run + # before GitHub automatically cancels it. Default: 360 + timeout-minutes: 30 + + strategy: + # When set to true, GitHub cancels + # all in-progress jobs if any matrix job fails. + fail-fast: false + + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 5 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Restore pip-query cache + uses: actions/cache@v4 + with: + path: ~/.cache/pip-query + key: ${{ runner.os }}-pip-query-${{ hashFiles('**/pip-query') }} + restore-keys: | + ${{ runner.os }}-pip-query- + + - name: Run tests + run: | + chmod +x bin/pip-query + python3 bin/pip-query --test + + - name: Test script execution + run: | + # Test basic functionality + python3 bin/pip-query requests + + # Test with max packages option + python3 bin/pip-query --max-packages 5 flask + + # Test version output + python3 bin/pip-query --version + + # Test help output + python3 bin/pip-query --help + + - name: Success Reporting + if: success() + run: git log --format=fuller -5 diff --git a/bin/mkllmproj b/bin/mkllmproj index ea096a9..7a1dc9d 100755 --- a/bin/mkllmproj +++ b/bin/mkllmproj @@ -46,7 +46,6 @@ warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. EOF } -# Print usage information usage() { cat << EOF ${SCRIPT_NAME} - LLM project scaffolding tool [version ${VERSION}] @@ -79,7 +78,7 @@ Examples: Positional parameters: name Project name (optional) - If not provided, will prompt interactively; + If not provided, will prompt interactively Command options: -h, --help Show this help message -V, --version Show program's version number @@ -147,16 +146,20 @@ while [[ $# -gt 0 ]]; do break ;; -*) - echo "${SCRIPT_NAME}: invalid option: $1" - usage + { + echo "${SCRIPT_NAME}: invalid option: $1" + echo "Try '${SCRIPT_NAME} --help' for more information." + } 1>&2 exit 1 ;; *) if [[ -z "$project_name" ]]; then project_name="$1" else - echo "${SCRIPT_NAME}: unexpected argument: $1" - usage + { + echo "${SCRIPT_NAME}: unexpected argument: $1" + echo "Try '${SCRIPT_NAME} --help' for more information." + } 1>&2 exit 1 fi shift @@ -168,12 +171,12 @@ done if [[ -n "$project_path" ]]; then if [[ ! -d "$project_path" ]]; then mkdir -p "$project_path" || { - echo "${SCRIPT_NAME}: failed to create directory $project_path" + echo "${SCRIPT_NAME}: failed to create directory $project_path" >&2 exit 1 } fi cd "$project_path" || { - echo "${SCRIPT_NAME}: failed to change to directory $project_path" + echo "${SCRIPT_NAME}: failed to change to directory $project_path" >&2 exit 1 } fi @@ -181,7 +184,7 @@ fi # Validate project name if provided as argument if [[ -n "$project_name" ]]; then if [[ -z "${project_name// }" ]]; then - echo "${SCRIPT_NAME}: project name cannot be empty" + echo "${SCRIPT_NAME}: project name cannot be empty" >&2 exit 1 fi else @@ -189,7 +192,7 @@ else while [[ -z "${project_name// }" ]]; do read -rp "Enter project name: " project_name if [[ -z "${project_name// }" ]]; then - echo "${SCRIPT_NAME}: project name cannot be empty" + echo "${SCRIPT_NAME}: project name cannot be empty" >&2 fi done fi diff --git a/bin/pip-query b/bin/pip-query index c923afa..e06101c 100755 --- a/bin/pip-query +++ b/bin/pip-query @@ -1,32 +1,952 @@ #!/usr/bin/env python3 +# +# pip-query - search and display Python package information. +# +# Copyright (C) 2021, 2022, 2023, 2024, 2025 Serghei Iakovlev . +# +# This file is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# This file is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this file. If not, see . + +""" +PyPI Package Query Tool. + +This module provides functionality to search and display information about Python +packages available on PyPI. It supports fuzzy package name matching, caching of +package lists, and formatted output of package details. + +Features: + - Fuzzy package name search + - Cached package list for faster subsequent searches + - Colored terminal output (when supported) + - Version comparison following PEP 440 + - Graceful error handling and signal management + - Configurable number of displayed packages + +Example: + # Show default number of packages (30) + $ pip-query requests + + # Search for 'flask' packages and show 50 results: + $ pip-query --max-packages 50 flask + + # Search for 'django' packages and show all matches: + $ pip-query --max-packages 0 django + +Note: + This tool uses the PyPI JSON API and piwheels.org for package information. + Internet connectivity is required for the initial package list fetch. + +Requirements: + Python >= 3.6 (f-strings and other modern features are used) +""" import json +import os +import re +import signal import sys +import time +from difflib import SequenceMatcher +from urllib.request import urlopen +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime\ + +# Version information +VERSION = "2.0.0" + +def show_help(): + """Display help information.""" + help_text = f"""pip-query - search and display Python package information [version {VERSION}] + +Usage: pip-query [options] + +Description: + pip-query is a tool to search and display information about Python packages + available on PyPI. It supports fuzzy package name matching and provides + detailed package information including version history, release dates, + and dependencies. + +Examples: + # Search for the 'requests' package (shows default 30 packages): + $ pip-query requests + + # Search for 'flask' packages and show 50 results: + $ pip-query --max-packages 50 flask + + # Search for 'django' packages and show all matches: + $ pip-query --max-packages 0 django + + # Run doctests: + $ pip-query --test + +Positional parameters: + package-name Name of the package to search for. Can be a partial + name - the tool will find similar matches. + +Command options: + -h, --help Show this help message + -V, --version Show program's version number + -m, --max-packages NUM Maximum number of packages to display (default: 30, + 0 means show all matches) + -t, --test Run doctests + +Notes: + This tool requires an internet connection for the initial package list fetch + and for retrieving package information not in the cache. + + Package information is cached for 24 hours to improve performance and + reduce load on PyPI servers. + +For bug reporting instructions, please see: + +""" + print(help_text) + +def show_version(): + """Display version information.""" + current_year = datetime.now().year + version_text = f"""pip-query version {VERSION} +Copyright (C) 2021-{current_year} Serghei Iakovlev . +This is free software; see the source for copying conditions. There is NO +warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.""" + print(version_text) + +# Check Python version +if sys.version_info < (3, 6): + sys.stderr.write(""" +pip-query: Unsupported Python version. +This script requires Python 3.6 or newer. +You are using Python {}.{}.{}. + +Please upgrade your Python installation or use a compatible version. +Visit https://www.python.org/downloads/ for more information. +""".format(*sys.version_info[:3])) + sys.exit(1) + + +class Version: + """ + Version string parser and comparison following PEP 440. + + This class implements version string parsing and comparison logic that follows + Python's version specification (PEP 440). It handles standard version numbers, + pre-releases (alpha, beta, rc), and development versions. + + Attributes: + version_str (str): The original version string. + parts (list): Parsed version components for comparison. + + Example: + >>> v1 = Version('1.2.3') + >>> v2 = Version('1.2.3b1') + >>> v1 > v2 + True + """ + + def __init__(self, version_str): + """ + Initialize a Version instance. + + Args: + version_str (str): The version string to parse. + """ + self.version_str = str(version_str) + self.parts = self._parse_version(version_str) + + def _parse_version(self, version_str): + """ + Parse version string into comparable components. + + Args: + version_str (str): The version string to parse. + + Returns: + list: List of version components (integers and strings). + """ + version_str = version_str.lower().strip() + + # Split into release and pre-release parts + parts = version_str.split('.') + result = [] + pre_release = None + + for part in parts: + # Check for pre-release markers + if any(marker in part for marker in ['rc', 'a', 'b']): + # Store pre-release info separately + match = re.match(r'(\d+)?(rc|a|b)(\d*)', part) + if match: + num, type_part, pre_num = match.groups() + if num: # If there's a version number before the pre-release + result.append(int(num)) + # Pre-release type: rc > b > a + type_val = {'a': -3, 'b': -2, 'rc': -1}.get(type_part, 0) + pre_num = int(pre_num) if pre_num else 0 + pre_release = (type_val, pre_num) + continue + + # Handle post-release + if 'post' in part: + match = re.match(r'(\d+)?post(\d+)', part) + if match: + num, post_num = match.groups() + if num: + result.append(int(num)) + result.extend([4, int(post_num)]) # 4 is higher than rc/b/a + continue + + # Convert to integer if possible + try: + result.append(int(part)) + except ValueError: + result.append(part) + + # Append pre-release info at the end if it exists + if pre_release: + result.extend(pre_release) + else: + # Add a high value for release versions to rank them above pre-releases + result.extend([0, 0]) # Regular releases rank higher than pre-releases + + return result + + def __lt__(self, other): + """Compare versions following PEP 440 ordering.""" + if not isinstance(other, Version): + other = Version(str(other)) + + # Pad shorter version with zeros + v1 = self.parts + [0] * (len(other.parts) - len(self.parts)) + v2 = other.parts + [0] * (len(self.parts) - len(other.parts)) + + # Compare parts + for p1, p2 in zip(v1, v2): + if isinstance(p1, int) and isinstance(p2, int): + if p1 != p2: + return p1 < p2 + elif isinstance(p1, int): + return False # Release versions come after pre-releases + elif isinstance(p2, int): + return True # Pre-releases come before release versions + else: + if p1 != p2: + return p1 < p2 + return False + + def __eq__(self, other): + """Check version equality.""" + if not isinstance(other, Version): + other = Version(str(other)) + return self.parts == other.parts + + def __str__(self): + """Return the original version string.""" + return self.version_str -import urllib3 -from pkg_resources import parse_version +def normalize_version(version_str): + """ + Normalize version string for comparison. + + Args: + version_str (str): Version string to normalize. + + Returns: + Version: Normalized version object for comparison. + + Examples: + >>> str(normalize_version('1.0.0')) + '1.0.0' + >>> normalize_version('2.1.0') > normalize_version('2.0.9') + True + >>> normalize_version('1.0.0rc1') < normalize_version('1.0.0') + True + >>> normalize_version('2.0.0b1') < normalize_version('2.0.0') + True + >>> normalize_version('1.0.0a1') < normalize_version('1.0.0b1') + True + """ + try: + return Version(version_str) + except Exception: + return version_str + + +class ParseError(Exception): + """Raised when command-line arguments cannot be parsed correctly.""" + + def __init__(self, message): + self.message = message + super().__init__(self.message) + + +class SignalInterrupt(KeyboardInterrupt): + """Raised when a signal interrupts the program execution.""" + + def __init__(self, signum): + self.signum = signum + + +def signal_interrupt(signum, _frame): + """Signal handler that raises SignalInterrupt.""" + raise SignalInterrupt(signum) + + +# Set up signal handlers +signal.signal(signal.SIGTERM, signal_interrupt) +signal.signal(signal.SIGINT, signal_interrupt) +# Prevent "[Errno 32] Broken pipe" exceptions when writing to a pipe +signal.signal(signal.SIGPIPE, signal.SIG_DFL) + + +def supports_color(): + """ + Check if the terminal supports color output. + + This function checks various environment variables and terminal + capabilities to determine if color output is supported and desired. + + Returns: + bool: True if color output is supported, False otherwise. + """ + if 'NO_COLOR' in os.environ: + return False + if not hasattr(sys.stdout, 'isatty'): + return False + if not sys.stdout.isatty(): + return False + if 'COLORTERM' in os.environ: + return True + term = os.environ.get('TERM', '') + if term == 'dumb': + return False + return 'xterm' in term or 'vt100' in term or '256color' in term + + +class Colors: + """ANSI color codes for terminal output.""" + GREEN = '\033[32m' if supports_color() else '' + BOLD_GREEN = '\033[1;32m' if supports_color() else '' + BOLD_WHITE = '\033[1;37m' if supports_color() else '' + RESET = '\033[0m' if supports_color() else '' + + +def colorize(text, color): + """ + Add color to text if terminal supports it. + + Args: + text (str): Text to colorize. + color (str): ANSI color code to use. + + Returns: + str: Colorized text if supported, original text otherwise. + """ + return f"{color}{text}{Colors.RESET}" if color else text + + +def get_cache_dir(): + """ + Get the appropriate cache directory based on OS. + + Returns: + str: Path to the cache directory. + """ + if os.name == 'nt': # Windows + base_dir = os.environ.get('LOCALAPPDATA', os.path.expanduser('~')) + cache_dir = os.path.join(base_dir, 'pip-query', 'cache') + else: # Unix/Linux/macOS + cache_dir = os.path.join( + os.environ.get('XDG_CACHE_HOME', os.path.expanduser('~/.cache')), + 'pip-query' + ) + os.makedirs(cache_dir, exist_ok=True) + return cache_dir + + +def load_cached_packages(): + """ + Load package list from cache if it exists and is not expired. + + Returns: + set: Set of package names from cache, or None if cache is invalid/expired. + """ + cache_file = os.path.join(get_cache_dir(), 'packages.json') + + try: + if not os.path.exists(cache_file): + return None + + # Check if cache is older than 24 hours + if time.time() - os.path.getmtime(cache_file) > 86400: # 24 hours in seconds + return None + + with open(cache_file, 'r', encoding='utf-8') as f: + cached_data = json.load(f) + return set(cached_data.get('packages', [])) + except (IOError, json.JSONDecodeError): + return None + + +def save_to_cache(packages): + """ + Save package list to cache. + + Args: + packages (set): Set of package names to cache. + """ + cache_file = os.path.join(get_cache_dir(), 'packages.json') + + try: + with open(cache_file, 'w', encoding='utf-8') as f: + json.dump({ + 'packages': list(packages), + 'timestamp': time.time() + }, f) + except IOError as e: + print(f"Warning: Could not save package list to cache: {e}", file=sys.stderr) + + +def get_available_packages(): + """ + Fetch list of available packages from piwheels with caching. + + This function attempts to load the package list from cache first. + If the cache is missing or expired, it fetches the list from piwheels.org. + On network errors, it falls back to expired cache if available. + + Returns: + set: Set of available package names. + """ + # Try to load from cache first + cached_packages = load_cached_packages() + if cached_packages is not None: + return cached_packages + + # If not in cache or expired, fetch from piwheels + url = "https://www.piwheels.org/packages.json" + try: + with urlopen(url, timeout=10.0) as response: + data = json.loads(response.read().decode("utf-8")) + packages = {pkg[0] for pkg in data} + save_to_cache(packages) + return packages + except Exception as e: + print(f"Error fetching package list: {e}", file=sys.stderr) + # If fetching fails, try to use expired cache as fallback + cached_packages = load_cached_packages() + if cached_packages is not None: + print("Warning: Using expired cache as fallback", file=sys.stderr) + return cached_packages + return set() -def versions(package_name): - http = urllib3.PoolManager() + +def calculate_similarity(query, package): + """ + Calculate similarity ratio between query and package name. + + Args: + query (str): Search query. + package (str): Package name to compare against. + + Returns: + float: Similarity score between 0 and 1. + + Examples: + >>> calculate_similarity('requests', 'requests') + 1.0 + >>> calculate_similarity('flask', 'flask-login') > 0.8 + True + >>> calculate_similarity('django', 'flask') < 0.5 + True + >>> calculate_similarity('test', 'pytest-test') > 0.7 + True + >>> calculate_similarity('a', 'django') < 0.3 + True + """ + query = query.lower() + package = package.lower() + + # Special handling for single-character queries + if len(query) == 1: + return 0.1 if query in package else 0.0 + + query_parts = set(re.split(r'[-_]', query)) + package_parts = set(re.split(r'[-_]', package)) + + # Exact match + if query == package: + return 1.0 + + # Complete part match + if query in package_parts: + return 0.95 + + # All query parts in package + if all(qpart in package for qpart in query_parts): + return 0.9 + + # Package starts with query + if package.startswith(query): + return 0.85 + + # Short query handling (2-3 characters) + if len(query) <= 3: + # For very short queries, be more strict + if query not in package: + return 0.0 + # If it's in the package name but not at start, give lower score + return 0.2 + + # Fuzzy matching for partial matches + if any(qpart in package for qpart in query_parts): + return SequenceMatcher(None, query, package).ratio() + + return 0.0 + + +def normalize_license(license_text): + """ + Normalize and format license information. + + Args: + license_text (str): Raw license text from package metadata. + + Returns: + str: Normalized license information. + + Examples: + >>> normalize_license('MIT') + 'MIT' + >>> normalize_license('Apache License 2.0') + 'Apache-2.0' + >>> normalize_license('GNU GPL') + 'GPL' + >>> normalize_license('') + 'Not specified' + >>> normalize_license('BSD-3-Clause') + 'BSD 3-Clause' + """ + if not license_text: + return "Not specified" + + LICENSE_MAPPINGS = { + 'MIT': 'MIT', + 'BSD': 'BSD', + 'BSD License': 'BSD', + 'BSD-3-Clause': 'BSD 3-Clause', + 'BSD-2-Clause': 'BSD 2-Clause', + 'Apache 2.0': 'Apache-2.0', + 'Apache License 2.0': 'Apache-2.0', + 'Apache License, Version 2.0': 'Apache-2.0', + 'Apache Software License': 'Apache-2.0', + 'GNU GPL': 'GPL', + 'GNU General Public License': 'GPL', + 'GPL': 'GPL', + 'LGPL': 'LGPL', + 'Python Software Foundation License': 'PSF', + 'PSF': 'PSF', + } + + if license_text in LICENSE_MAPPINGS: + return LICENSE_MAPPINGS[license_text] + + if len(license_text) > 100: + lower_text = license_text.lower() + if 'apache license' in lower_text and '2.0' in lower_text: + return 'Apache-2.0' + if 'mit license' in lower_text: + return 'MIT' + if 'bsd license' in lower_text: + return 'BSD' + if 'gnu general public license' in lower_text or 'gpl' in lower_text: + return 'GPL' + + return f"{license_text[:97]}..." + + return license_text + + +def format_package_info(package_name, data): + """ + Format package information for display. + + This function takes package metadata and formats it for terminal display, + including version information, release dates, and other package details. + It handles missing data gracefully and applies color formatting when supported. + + Args: + package_name (str): Name of the package. + data (dict): Package metadata from PyPI. + + Returns: + str: Formatted package information ready for display. + """ + if not data: + return "" + + info = data.get("info", {}) + releases = data.get("releases", {}) + + try: + all_versions = sorted(releases.keys(), + key=normalize_version, + reverse=True) + except Exception: + all_versions = sorted(releases.keys(), reverse=True) + + latest_version = info.get("version", "") + + # Get latest release date + latest_release_date = "" + if latest_version and latest_version in releases: + release_info = releases[latest_version] + if release_info and isinstance(release_info, list) and release_info[0]: + upload_time = release_info[0].get('upload_time', '') + if upload_time: + try: + timestamp = time.strptime(upload_time[:19], "%Y-%m-%dT%H:%M:%S") + latest_release_date = time.strftime("%Y-%m-%d", timestamp) + except (ValueError, TypeError): + latest_release_date = upload_time + + recent_versions = all_versions[:10] if len(all_versions) > 10 else all_versions + recent_versions_str = ", ".join(recent_versions) if recent_versions else "" + + homepage = info.get("home_page") or info.get("project_url") or "Not specified" + description = info.get("summary") or "No description available" + author = info.get("author") or "Not specified" + license_info = normalize_license(info.get("license", "")) + python_requires = info.get("requires_python") or "Not specified" + + # Format labels with color + latest_version_label = colorize("Latest version available:", Colors.GREEN) + latest_release_label = colorize("Latest release date:", Colors.GREEN) + version_history_label = colorize( + f"Version history{' (last 10)' if len(all_versions) > 10 else ''}:", + Colors.GREEN + ) + release_history_label = colorize("Release history:", Colors.GREEN) + homepage_label = colorize("Homepage:", Colors.GREEN) + description_label = colorize("Description:", Colors.GREEN) + author_label = colorize("Author:", Colors.GREEN) + requires_label = colorize("Requires:", Colors.GREEN) + license_label = colorize("License:", Colors.GREEN) + + return (f"{colorize('*', Colors.BOLD_GREEN)} {colorize(package_name, Colors.BOLD_WHITE)}\n" + f" {latest_version_label} {latest_version}\n" + f" {latest_release_label} {latest_release_date or 'Not specified'}\n" + f" {version_history_label} {recent_versions_str}\n" + f" {release_history_label} https://pypi.org/project/{package_name}/#history\n" + f" {homepage_label} {homepage}\n" + f" {description_label} {description}\n" + f" {author_label} {author}\n" + f" {requires_label} Python {python_requires}\n" + f" {license_label} {license_info}") + + +def search_similar_packages(query, similarity_threshold=0.8, max_packages=30): + """ + Search for packages with names similar to the query. + + This function implements a fuzzy search algorithm that finds packages + with names similar to the search query. It uses multiple similarity + metrics and returns the most relevant matches. + + Args: + query (str): Package name to search for. + similarity_threshold (float, optional): Minimum similarity score (0-1). + Defaults to 0.8. + max_packages (int, optional): Maximum number of packages to return. + Defaults to 30. If 0, returns all matches. + + Returns: + tuple: (dict of package data, total number of matches found) + """ + try: + all_packages = get_available_packages() + if not all_packages: + print("Could not fetch package list", file=sys.stderr) + return {}, 0 + + # Calculate similarities for all packages + package_similarities = [ + (package, calculate_similarity(query, package)) + for package in all_packages + ] + + # Get all packages above threshold + matching_packages = [ + (pkg, score) for pkg, score in package_similarities + if score >= similarity_threshold + ] + + # Sort by similarity score and take top N packages + most_similar = sorted( + matching_packages, + key=lambda x: x[1], + reverse=True + ) + if max_packages > 0: + most_similar = most_similar[:max_packages] + + # First try to load all packages from cache + similar_packages = {} + packages_to_fetch = [] + + for package, _similarity in most_similar: + # Try to load from cache first + cached_data = load_cached_package_info(package) + if cached_data is not None: + similar_packages[package] = cached_data + else: + packages_to_fetch.append(package) + + # Now fetch packages that weren't in cache + if packages_to_fetch: + print(f"Fetching data for {len(packages_to_fetch)} packages from PyPI...", file=sys.stderr) + # Use concurrent.futures to fetch packages in parallel + with ThreadPoolExecutor(max_workers=min(10, len(packages_to_fetch))) as executor: + # Submit all package fetch tasks + future_to_package = { + executor.submit(get_package_info, package): package + for package in packages_to_fetch + } + + # Process completed tasks as they finish + for future in as_completed(future_to_package): + package = future_to_package[future] + try: + data = future.result() + if data: + similar_packages[package] = data + except Exception as e: + print(f"Error fetching {package}: {e}", file=sys.stderr) + + # Try variations if no matches found + if not similar_packages: + variations = [ + query.lower(), + query.replace("-", "_"), + query.replace("_", "-"), + ] + + for variant in variations: + if variant not in similar_packages: + data = get_package_info(variant) + if data: + similar_packages[variant] = data + + return similar_packages, len(matching_packages) + + except Exception as e: + print(f"Error searching for packages: {e}", file=sys.stderr) + return {}, 0 + + +def load_cached_package_info(package_name): + """ + Load package information from cache if it exists and is not expired. + + Args: + package_name (str): Name of the package to load. + + Returns: + dict: Package information from cache, or None if cache is invalid/expired. + """ + cache_dir = os.path.join(get_cache_dir(), 'packages') + cache_file = os.path.join(cache_dir, f"{package_name}.json") + + try: + if not os.path.exists(cache_file): + return None + + # Check if cache is older than 24 hours + if time.time() - os.path.getmtime(cache_file) > 86400: # 24 hours in seconds + return None + + with open(cache_file, 'r', encoding='utf-8') as f: + data = json.load(f) + return data + except (IOError, json.JSONDecodeError): + return None + + +def save_package_info_to_cache(package_name, data): + """ + Save package information to cache. + + Args: + package_name (str): Name of the package. + data (dict): Package information to cache. + """ + cache_dir = os.path.join(get_cache_dir(), 'packages') + os.makedirs(cache_dir, exist_ok=True) + cache_file = os.path.join(cache_dir, f"{package_name}.json") + + try: + with open(cache_file, 'w', encoding='utf-8') as f: + json.dump(data, f) + except IOError as e: + print(f"Warning: Could not save package info to cache: {e}", file=sys.stderr) + + +def get_package_info(package_name): + """ + Fetch package information from PyPI with caching. + + This function attempts to load package information from cache first. + If the cache is missing or expired, it fetches the data from PyPI. + + Args: + package_name (str): Name of the package to fetch information for. + + Returns: + dict: Package information from PyPI or None if fetch fails. + """ + # Try to load from cache first + cached_data = load_cached_package_info(package_name) + if cached_data is not None: + return cached_data + + # If not in cache or expired, fetch from PyPI url = f"https://pypi.org/pypi/{package_name}/json" + try: + with urlopen(url, timeout=10.0) as response: + data = json.loads(response.read().decode("utf-8")) + save_package_info_to_cache(package_name, data) + return data + except Exception as e: + # Ignore network errors + if "Connection refused" in str(e): + return None + # Ignore 404 errors + if "404" in str(e): + return None + print(f"Warning: Could not fetch package info for {package_name}: {e}", file=sys.stderr) + return None + + +def main(): + """ + Main entry point for the pip-query tool. - response = http.request("GET", url) - data = json.loads(response.data.decode("utf-8")) + This function handles the command-line interface, executes the package + search, and displays the results. It includes comprehensive error handling + and proper exit code management. + Returns: + int: Exit code (0 for success, non-zero for errors) + """ try: - releases = data["releases"].keys() - except KeyError: - releases = [] + # Parse command line arguments + max_packages = 30 # Default value + args = sys.argv[1:] + + if not args: + raise ParseError("Missing package name argument") + + # Handle command-line options + if args[0] in ['-h', '--help']: + show_help() + return 0 - sorted(releases, key=parse_version, reverse=True) + if args[0] in ['-V', '--version']: + show_version() + return 0 - return releases + if args[0] in ['-t', '--test']: + import doctest + print("Running doctests...") + doctest.testmod(verbose=True) + return 0 + + # Parse max-packages option + if args[0] in ['-m', '--max-packages']: + if len(args) < 3: + raise ParseError("Missing value for --max-packages") + try: + max_packages = int(args[1]) + if max_packages < 0: + raise ValueError("Number must be non-negative") + args = args[2:] + except ValueError: + raise ParseError("Invalid value for --max-packages") + elif len(args) >= 2 and args[1] in ['-m', '--max-packages']: + raise ParseError("--max-packages option must come before package name") + + if not args: + raise ParseError("Missing package name argument") + + query = args[0] + print(f"\n[ Results for search key: {colorize(query, Colors.BOLD_WHITE)} ]") + print("Searching...\n") + + results, total_matches = search_similar_packages(query, max_packages=max_packages) + + if not results: + print(f"No packages found matching '{colorize(query, Colors.BOLD_WHITE)}'") + print("\n[ Packages found: 0 ]\n") + return 0 + + # Sort results by package name and print + for package_name, package_data in sorted(results.items(), key=lambda x: x[0].lower()): + info = format_package_info(package_name, package_data) + if info: + print(info) + print() # Empty line between packages + + # Print summary + if max_packages > 0 and total_matches > max_packages: + print(f"[ Packages found: {colorize(str(total_matches), Colors.BOLD_WHITE)} ]") + print(f"[ Shown most relevant {max_packages} packages ]\n") + else: + print(f"[ Packages found: {colorize(str(len(results)), Colors.BOLD_WHITE)} ]\n") + + return 0 + + except ParseError as e: + sys.stderr.write(f"pip-query: {str(e)}\n") + sys.stderr.write("Usage: pip-query [--max-packages NUM] package-name\n") + sys.stderr.write("Try 'pip-query --help' for more information.\n") + return 1 + except KeyboardInterrupt as e: + # Handle Ctrl+C gracefully + signum = getattr(e, 'signum', signal.SIGINT) + sys.stderr.write(f"\nInterrupted by user (signal {signum})\n") + return 130 + except Exception as e: + # Handle unexpected exceptions + sys.stderr.write("\nAn unexpected error occurred:\n") + import traceback + sys.stderr.write(traceback.format_exc()) + return 1 if __name__ == "__main__": - if len(sys.argv) < 2: - print(f"Usage: {sys.argv[0]} package-name", file=sys.stderr) + try: + sys.exit(main()) + except KeyboardInterrupt as e: + # Handle keyboard interrupts at the top level + signum = getattr(e, 'signum', signal.SIGINT) + signal.signal(signum, signal.SIG_DFL) + sys.stderr.write(f"\n\nExiting on signal {signum}\n") + sys.stderr.flush() + sys.exit(130) + except SystemExit as e: + raise + except Exception as e: + sys.stderr.write("\nFatal error:\n") + import traceback + sys.stderr.write(traceback.format_exc()) sys.exit(1) - - print(*versions(sys.argv[1]), sep='\n')