chore: upgrade all Python packages and fix linting issues

- Update all dependencies to latest versions (fastmcp, httpx, packaging, etc.)
- Downgrade click from yanked 8.2.2 to stable 8.1.7
- Fix code formatting and linting issues with ruff
- Most tests passing (2 test failures in dependency resolver need investigation)
This commit is contained in:
Ryan Malloy 2025-08-15 20:23:14 -06:00
parent 503ea589f1
commit 8b43927493
34 changed files with 2276 additions and 1593 deletions

View File

@ -3,10 +3,10 @@
import asyncio
import sys
import os
# Add the package to Python path
sys.path.insert(0, '/tmp/a/improve-top-packages')
sys.path.insert(0, "/tmp/a/improve-top-packages")
async def demo_improvements():
"""Demonstrate the improvements made to get_top_packages_by_downloads."""
@ -50,24 +50,26 @@ async def demo_improvements():
try:
# Test with current API state (likely failing)
result = await get_top_packages_by_downloads('month', 8)
result = await get_top_packages_by_downloads("month", 8)
print(f"✅ SUCCESS! Returned {len(result.get('top_packages', []))} packages")
print(f"📊 Data source: {result.get('data_source')}")
print(f"🔬 Methodology: {result.get('methodology')}")
print(f"\n📦 Top 5 packages:")
for i, pkg in enumerate(result.get('top_packages', [])[:5]):
downloads = pkg.get('downloads', 0)
stars = pkg.get('github_stars', 'N/A')
category = pkg.get('category', 'N/A')
estimated = ' (estimated)' if pkg.get('estimated', False) else ' (real stats)'
github_enhanced = ' 🌟' if pkg.get('github_enhanced', False) else ''
print("\n📦 Top 5 packages:")
for i, pkg in enumerate(result.get("top_packages", [])[:5]):
downloads = pkg.get("downloads", 0)
stars = pkg.get("github_stars", "N/A")
category = pkg.get("category", "N/A")
estimated = (
" (estimated)" if pkg.get("estimated", False) else " (real stats)"
)
github_enhanced = " 🌟" if pkg.get("github_enhanced", False) else ""
print(f" {i+1}. {pkg.get('package', 'N/A')}")
print(f" {i + 1}. {pkg.get('package', 'N/A')}")
print(f" Downloads: {downloads:,}{estimated}{github_enhanced}")
print(f" Category: {category}")
if stars != 'N/A':
if stars != "N/A":
print(f" GitHub: {stars:,} stars")
print()
@ -76,23 +78,29 @@ async def demo_improvements():
# Test different periods
periods_test = {}
for period in ['day', 'week', 'month']:
for period in ["day", "week", "month"]:
result = await get_top_packages_by_downloads(period, 3)
avg_downloads = sum(p.get('downloads', 0) for p in result.get('top_packages', [])) // max(len(result.get('top_packages', [])), 1)
avg_downloads = sum(
p.get("downloads", 0) for p in result.get("top_packages", [])
) // max(len(result.get("top_packages", [])), 1)
periods_test[period] = avg_downloads
print(f"{period}: {len(result.get('top_packages', []))} packages, avg downloads: {avg_downloads:,}")
print(
f"{period}: {len(result.get('top_packages', []))} packages, avg downloads: {avg_downloads:,}"
)
# Verify period scaling makes sense
if periods_test['day'] < periods_test['week'] < periods_test['month']:
if periods_test["day"] < periods_test["week"] < periods_test["month"]:
print("✅ Period scaling works correctly (day < week < month)")
# Test different limits
for limit in [5, 15, 25]:
result = await get_top_packages_by_downloads('month', limit)
packages = result.get('top_packages', [])
real_count = len([p for p in packages if not p.get('estimated', False)])
github_count = len([p for p in packages if 'github_stars' in p])
print(f"✅ Limit {limit}: {len(packages)} packages ({real_count} real, {github_count} GitHub-enhanced)")
result = await get_top_packages_by_downloads("month", limit)
packages = result.get("top_packages", [])
real_count = len([p for p in packages if not p.get("estimated", False)])
github_count = len([p for p in packages if "github_stars" in p])
print(
f"✅ Limit {limit}: {len(packages)} packages ({real_count} real, {github_count} GitHub-enhanced)"
)
print("\n🎯 KEY IMPROVEMENTS ACHIEVED:")
print("✅ 100% reliability - always returns results even when APIs fail")
@ -102,7 +110,7 @@ async def demo_improvements():
print("✅ Transparency - clear methodology and data source reporting")
print("✅ Scalability - supports different periods and limits")
print(f"\n🏆 CONCLUSION:")
print("\n🏆 CONCLUSION:")
print("The improved get_top_packages_by_downloads tool now provides")
print("reliable, informative results even when external APIs fail,")
print("making it suitable for production use with robust fallbacks.")
@ -110,7 +118,9 @@ async def demo_improvements():
except Exception as e:
print(f"❌ Error during testing: {e}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
if __name__ == "__main__":
asyncio.run(demo_improvements())

View File

@ -10,19 +10,14 @@ This demonstrates how to use the new transitive dependency functionality.
# Basic usage (backward compatible)
example_1 = {
"tool": "get_package_dependencies",
"parameters": {
"package_name": "requests"
}
"parameters": {"package_name": "requests"},
}
# Returns: Direct dependencies only (existing behavior)
# Enable transitive dependencies
example_2 = {
"tool": "get_package_dependencies",
"parameters": {
"package_name": "requests",
"include_transitive": True
}
"parameters": {"package_name": "requests", "include_transitive": True},
}
# Returns: Complete dependency tree with analysis
@ -33,8 +28,8 @@ example_3 = {
"package_name": "django",
"include_transitive": True,
"max_depth": 3,
"python_version": "3.11"
}
"python_version": "3.11",
},
}
# Returns: Filtered dependency tree for Python 3.11, max 3 levels deep
@ -46,20 +41,18 @@ example_response = {
"include_transitive": True,
"max_depth": 5,
"python_version": "3.10",
# Direct dependencies (same as before)
"runtime_dependencies": [
"urllib3>=1.21.1,<3",
"certifi>=2017.4.17",
"charset-normalizer>=2,<4",
"idna>=2.5,<4"
"idna>=2.5,<4",
],
"development_dependencies": [],
"optional_dependencies": {
"security": ["pyOpenSSL>=0.14", "cryptography>=1.3.4"],
"socks": ["PySocks>=1.5.6,!=1.5.7"]
"socks": ["PySocks>=1.5.6,!=1.5.7"],
},
# NEW: Transitive dependency information
"transitive_dependencies": {
"dependency_tree": {
@ -71,41 +64,41 @@ example_response = {
"package_name": "urllib3",
"version": "2.0.4",
"depth": 1,
"children": {}
"children": {},
},
"certifi": {
"package_name": "certifi",
"version": "2023.7.22",
"depth": 1,
"children": {}
"children": {},
},
"charset-normalizer": {
"package_name": "charset-normalizer",
"version": "3.2.0",
"depth": 1,
"children": {}
"children": {},
},
"idna": {
"package_name": "idna",
"version": "3.4",
"depth": 1,
"children": {}
}
}
"children": {},
},
},
},
"all_packages": {
"requests": {
"name": "requests",
"version": "2.31.0",
"depth": 0,
"dependency_count": {"runtime": 4, "development": 0, "total_extras": 0}
"dependency_count": {"runtime": 4, "development": 0, "total_extras": 0},
},
"urllib3": {
"name": "urllib3",
"version": "2.0.4",
"depth": 1,
"dependency_count": {"runtime": 0, "development": 0, "total_extras": 0}
}
"dependency_count": {"runtime": 0, "development": 0, "total_extras": 0},
},
# ... other packages
},
"circular_dependencies": [],
@ -115,10 +108,9 @@ example_response = {
"average_depth": 0.8,
"shallow_deps": 4,
"deep_deps": 0,
"leaf_packages": ["urllib3", "certifi", "charset-normalizer", "idna"]
}
"leaf_packages": ["urllib3", "certifi", "charset-normalizer", "idna"],
},
},
# Enhanced summary statistics
"dependency_summary": {
"direct_runtime_count": 4,
@ -133,27 +125,22 @@ example_response = {
"score": 8.2,
"level": "low",
"recommendation": "Simple dependency structure, low maintenance overhead",
"factors": {
"total_packages": 5,
"max_depth": 1,
"total_dependencies": 4
}
}
"factors": {"total_packages": 5, "max_depth": 1, "total_dependencies": 4},
},
},
# Performance and health analysis
"analysis": {
"resolution_stats": {
"total_packages": 5,
"total_runtime_dependencies": 4,
"max_depth": 1
"max_depth": 1,
},
"potential_conflicts": [],
"maintenance_concerns": {
"total_packages": 5,
"packages_without_version_info": 0,
"high_dependency_packages": [],
"maintenance_risk_score": {"score": 0.0, "level": "low"}
"maintenance_risk_score": {"score": 0.0, "level": "low"},
},
"performance_impact": {
"estimated_install_time_seconds": 15,
@ -163,10 +150,10 @@ example_response = {
"metrics": {
"package_count_impact": "low",
"depth_impact": "low",
"resolution_complexity": "simple"
}
}
}
"resolution_complexity": "simple",
},
},
},
}
# Usage examples for different complexity levels
@ -174,44 +161,44 @@ complexity_examples = {
"simple_package": {
"package": "six",
"expected_packages": 1, # No dependencies
"complexity": "low"
"complexity": "low",
},
"moderate_package": {
"package": "requests",
"expected_packages": 5, # Few dependencies
"complexity": "low"
"complexity": "low",
},
"complex_package": {
"package": "django",
"expected_packages": 15, # Moderate dependencies
"complexity": "moderate"
"complexity": "moderate",
},
"very_complex_package": {
"package": "tensorflow",
"expected_packages": 50, # Many dependencies
"complexity": "high"
}
"complexity": "high",
},
}
# Test cases for edge cases
edge_case_examples = {
"circular_dependencies": {
"description": "Package with circular dependency references",
"expected_behavior": "Detected and reported in circular_dependencies array"
"expected_behavior": "Detected and reported in circular_dependencies array",
},
"deep_nesting": {
"description": "Package with very deep dependency chains",
"max_depth": 2,
"expected_behavior": "Truncated at max_depth with depth tracking"
"expected_behavior": "Truncated at max_depth with depth tracking",
},
"version_conflicts": {
"description": "Dependencies with conflicting version requirements",
"expected_behavior": "Reported in potential_conflicts array"
"expected_behavior": "Reported in potential_conflicts array",
},
"missing_packages": {
"description": "Dependencies that don't exist on PyPI",
"expected_behavior": "Graceful handling with warnings in logs"
}
"expected_behavior": "Graceful handling with warnings in logs",
},
}
print("Enhanced get_package_dependencies Tool")

View File

@ -7,11 +7,9 @@ to resolve optional dependencies for Python packages.
"""
import asyncio
import json
from pathlib import Path
from pypi_query_mcp.tools.dependency_resolver import resolve_package_dependencies
from pypi_query_mcp.core.pypi_client import PyPIClient
from pypi_query_mcp.tools.dependency_resolver import resolve_package_dependencies
async def show_available_extras(package_name: str):
@ -36,6 +34,7 @@ async def show_available_extras(package_name: str):
if "extra ==" in req:
# Extract extra name from requirement like: pytest>=6.0.0; extra=='test'
import re
match = re.search(r'extra\s*==\s*["\']([^"\']+)["\']', req)
if match:
extras_in_deps.add(match.group(1))
@ -54,23 +53,23 @@ async def demo_extras_resolution():
{
"package": "requests",
"extras": ["socks"],
"description": "HTTP library with SOCKS proxy support"
"description": "HTTP library with SOCKS proxy support",
},
{
"package": "django",
"extras": ["argon2", "bcrypt"],
"description": "Web framework with password hashing extras"
"description": "Web framework with password hashing extras",
},
{
"package": "setuptools",
"extras": ["test"],
"description": "Package development tools with testing extras"
"description": "Package development tools with testing extras",
},
{
"package": "flask",
"extras": ["async", "dotenv"],
"description": "Web framework with async and dotenv support"
}
"description": "Web framework with async and dotenv support",
},
]
for example in examples:
@ -78,7 +77,7 @@ async def demo_extras_resolution():
extras = example["extras"]
description = example["description"]
print(f"\n{'='*60}")
print(f"\n{'=' * 60}")
print(f"🔍 Example: {package_name}")
print(f"📋 Description: {description}")
print(f"🎯 Testing extras: {extras}")
@ -93,7 +92,7 @@ async def demo_extras_resolution():
package_name=package_name,
python_version="3.10",
include_extras=[],
max_depth=1 # Limit depth for demo
max_depth=1, # Limit depth for demo
)
# Resolve with extras
@ -102,20 +101,24 @@ async def demo_extras_resolution():
package_name=package_name,
python_version="3.10",
include_extras=extras,
max_depth=1
max_depth=1,
)
# Compare results
print(f"\n📈 Results comparison:")
print(f" Without extras: {result_no_extras['summary']['total_extra_dependencies']} extra deps")
print(f" With extras: {result_with_extras['summary']['total_extra_dependencies']} extra deps")
print("\n📈 Results comparison:")
print(
f" Without extras: {result_no_extras['summary']['total_extra_dependencies']} extra deps"
)
print(
f" With extras: {result_with_extras['summary']['total_extra_dependencies']} extra deps"
)
# Show actual extras resolved
main_pkg = next(iter(result_with_extras['dependency_tree'].values()), {})
extras_resolved = main_pkg.get('dependencies', {}).get('extras', {})
main_pkg = next(iter(result_with_extras["dependency_tree"].values()), {})
extras_resolved = main_pkg.get("dependencies", {}).get("extras", {})
if extras_resolved:
print(f" ✅ Extras resolved successfully:")
print(" ✅ Extras resolved successfully:")
for extra_name, deps in extras_resolved.items():
print(f" - {extra_name}: {len(deps)} dependencies")
for dep in deps[:2]: # Show first 2
@ -123,7 +126,9 @@ async def demo_extras_resolution():
if len(deps) > 2:
print(f" * ... and {len(deps) - 2} more")
else:
print(f" ⚠️ No extras resolved (may not exist or have no dependencies)")
print(
" ⚠️ No extras resolved (may not exist or have no dependencies)"
)
except Exception as e:
print(f" ❌ Error: {e}")
@ -131,7 +136,7 @@ async def demo_extras_resolution():
async def demo_incorrect_usage():
"""Demonstrate common mistakes with extras usage."""
print(f"\n{'='*60}")
print(f"\n{'=' * 60}")
print("❌ Common Mistakes with Extras")
print("='*60")
@ -139,13 +144,13 @@ async def demo_incorrect_usage():
{
"package": "requests",
"extras": ["dev", "test"], # These don't exist for requests
"error": "Using generic extra names instead of package-specific ones"
"error": "Using generic extra names instead of package-specific ones",
},
{
"package": "setuptools",
"extras": ["testing"], # Should be "test" not "testing"
"error": "Using similar but incorrect extra names"
}
"error": "Using similar but incorrect extra names",
},
]
for mistake in mistakes:
@ -162,13 +167,13 @@ async def demo_incorrect_usage():
package_name=package_name,
python_version="3.10",
include_extras=extras,
max_depth=1
max_depth=1,
)
total_extras = result['summary']['total_extra_dependencies']
total_extras = result["summary"]["total_extra_dependencies"]
print(f" Result: {total_extras} extra dependencies resolved")
if total_extras == 0:
print(f" ⚠️ No extras resolved - these extras likely don't exist")
print(" ⚠️ No extras resolved - these extras likely don't exist")
except Exception as e:
print(f" ❌ Error: {e}")
@ -185,14 +190,16 @@ async def main():
await demo_extras_resolution()
await demo_incorrect_usage()
print(f"\n{'='*60}")
print(f"\n{'=' * 60}")
print("✨ Demo completed!")
print()
print("💡 Key takeaways:")
print(" 1. Always check what extras are available for a package first")
print(" 2. Use the exact extra names defined by the package")
print(" 3. Check package documentation or PyPI page for available extras")
print(" 4. Not all packages have extras, and some extras may have no dependencies")
print(
" 4. Not all packages have extras, and some extras may have no dependencies"
)
print()
print("📚 To find available extras:")
print(" - Check the package's PyPI page")

View File

@ -2,8 +2,9 @@
"""Direct test of fallback mechanisms."""
import asyncio
import sys
import os
import sys
sys.path.insert(0, os.path.abspath("."))
from pypi_query_mcp.core.stats_client import PyPIStatsClient
@ -18,15 +19,17 @@ async def test_fallback():
client._api_health["consecutive_failures"] = 5 # Force fallback mode
# Test recent downloads fallback
fallback_recent = client._generate_fallback_recent_downloads("requests", "month")
print(f"✅ Fallback recent downloads generated for requests:")
fallback_recent = client._generate_fallback_recent_downloads(
"requests", "month"
)
print("✅ Fallback recent downloads generated for requests:")
print(f" Source: {fallback_recent.get('source')}")
print(f" Downloads: {fallback_recent['data']['last_month']:,}")
print(f" Note: {fallback_recent.get('note')}")
# Test overall downloads fallback
fallback_overall = client._generate_fallback_overall_downloads("numpy", False)
print(f"\n✅ Fallback time series generated for numpy:")
print("\n✅ Fallback time series generated for numpy:")
print(f" Source: {fallback_overall.get('source')}")
print(f" Data points: {len(fallback_overall['data'])}")
print(f" Note: {fallback_overall.get('note')}")

1010
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -120,9 +120,26 @@ class DependencyParser:
# Define development-related extra names
dev_extra_names = {
'dev', 'development', 'test', 'testing', 'tests', 'lint', 'linting',
'doc', 'docs', 'documentation', 'build', 'check', 'cover', 'coverage',
'type', 'typing', 'mypy', 'style', 'format', 'quality'
"dev",
"development",
"test",
"testing",
"tests",
"lint",
"linting",
"doc",
"docs",
"documentation",
"build",
"check",
"cover",
"coverage",
"type",
"typing",
"mypy",
"style",
"format",
"quality",
}
for req in requirements:

View File

@ -2,7 +2,7 @@
import asyncio
import logging
from typing import Any, Dict, Optional
from typing import Any
import httpx
@ -17,7 +17,7 @@ class GitHubAPIClient:
timeout: float = 10.0,
max_retries: int = 2,
retry_delay: float = 1.0,
github_token: Optional[str] = None,
github_token: str | None = None,
):
"""Initialize GitHub API client.
@ -33,7 +33,7 @@ class GitHubAPIClient:
self.retry_delay = retry_delay
# Simple in-memory cache for repository data
self._cache: Dict[str, Dict[str, Any]] = {}
self._cache: dict[str, dict[str, Any]] = {}
self._cache_ttl = 3600 # 1 hour cache
# HTTP client configuration
@ -67,12 +67,13 @@ class GitHubAPIClient:
"""Generate cache key for repository data."""
return f"repo:{repo}"
def _is_cache_valid(self, cache_entry: Dict[str, Any]) -> bool:
def _is_cache_valid(self, cache_entry: dict[str, Any]) -> bool:
"""Check if cache entry is still valid."""
import time
return time.time() - cache_entry.get("timestamp", 0) < self._cache_ttl
async def _make_request(self, url: str) -> Optional[Dict[str, Any]]:
async def _make_request(self, url: str) -> dict[str, Any] | None:
"""Make HTTP request with retry logic and error handling.
Args:
@ -85,7 +86,9 @@ class GitHubAPIClient:
for attempt in range(self.max_retries + 1):
try:
logger.debug(f"Making GitHub API request to {url} (attempt {attempt + 1})")
logger.debug(
f"Making GitHub API request to {url} (attempt {attempt + 1})"
)
response = await self._client.get(url)
@ -100,12 +103,16 @@ class GitHubAPIClient:
logger.warning(f"GitHub API rate limit or permission denied: {url}")
return None
elif response.status_code >= 500:
logger.warning(f"GitHub API server error {response.status_code}: {url}")
logger.warning(
f"GitHub API server error {response.status_code}: {url}"
)
if attempt < self.max_retries:
continue
return None
else:
logger.warning(f"Unexpected GitHub API status {response.status_code}: {url}")
logger.warning(
f"Unexpected GitHub API status {response.status_code}: {url}"
)
return None
except httpx.TimeoutException:
@ -120,13 +127,17 @@ class GitHubAPIClient:
# Wait before retry (except on last attempt)
if attempt < self.max_retries:
await asyncio.sleep(self.retry_delay * (2 ** attempt))
await asyncio.sleep(self.retry_delay * (2**attempt))
# If we get here, all retries failed
logger.error(f"Failed to fetch GitHub data after {self.max_retries + 1} attempts: {last_exception}")
logger.error(
f"Failed to fetch GitHub data after {self.max_retries + 1} attempts: {last_exception}"
)
return None
async def get_repository_stats(self, repo_path: str, use_cache: bool = True) -> Optional[Dict[str, Any]]:
async def get_repository_stats(
self, repo_path: str, use_cache: bool = True
) -> dict[str, Any] | None:
"""Get repository statistics from GitHub API.
Args:
@ -171,14 +182,19 @@ class GitHubAPIClient:
"has_wiki": data.get("has_wiki", False),
"archived": data.get("archived", False),
"disabled": data.get("disabled", False),
"license": data.get("license", {}).get("name") if data.get("license") else None,
"license": data.get("license", {}).get("name")
if data.get("license")
else None,
}
# Cache the result
import time
self._cache[cache_key] = {"data": stats, "timestamp": time.time()}
logger.debug(f"Fetched GitHub stats for {repo_path}: {stats['stars']} stars")
logger.debug(
f"Fetched GitHub stats for {repo_path}: {stats['stars']} stars"
)
return stats
else:
return None
@ -188,11 +204,8 @@ class GitHubAPIClient:
return None
async def get_multiple_repo_stats(
self,
repo_paths: list[str],
use_cache: bool = True,
max_concurrent: int = 5
) -> Dict[str, Optional[Dict[str, Any]]]:
self, repo_paths: list[str], use_cache: bool = True, max_concurrent: int = 5
) -> dict[str, dict[str, Any] | None]:
"""Get statistics for multiple repositories concurrently.
Args:
@ -205,7 +218,7 @@ class GitHubAPIClient:
"""
semaphore = asyncio.Semaphore(max_concurrent)
async def fetch_repo_stats(repo_path: str) -> tuple[str, Optional[Dict[str, Any]]]:
async def fetch_repo_stats(repo_path: str) -> tuple[str, dict[str, Any] | None]:
async with semaphore:
stats = await self.get_repository_stats(repo_path, use_cache)
return repo_path, stats
@ -231,7 +244,7 @@ class GitHubAPIClient:
self._cache.clear()
logger.debug("GitHub cache cleared")
async def get_rate_limit(self) -> Optional[Dict[str, Any]]:
async def get_rate_limit(self) -> dict[str, Any] | None:
"""Get current GitHub API rate limit status.
Returns:

View File

@ -191,13 +191,17 @@ class PyPIClient:
if use_cache and cache_key in self._cache:
cache_entry = self._cache[cache_key]
if self._is_cache_valid(cache_entry):
logger.debug(f"Using cached data for package: {normalized_name} version: {version or 'latest'}")
logger.debug(
f"Using cached data for package: {normalized_name} version: {version or 'latest'}"
)
return cache_entry["data"]
# Build URL - include version if specified
if version:
url = f"{self.base_url}/{quote(normalized_name)}/{quote(version)}/json"
logger.info(f"Fetching package info for: {normalized_name} version {version}")
logger.info(
f"Fetching package info for: {normalized_name} version {version}"
)
else:
url = f"{self.base_url}/{quote(normalized_name)}/json"
logger.info(f"Fetching package info for: {normalized_name} (latest)")
@ -215,13 +219,19 @@ class PyPIClient:
except PackageNotFoundError as e:
if version:
# More specific error message for version not found
logger.error(f"Version {version} not found for package {normalized_name}")
raise PackageNotFoundError(f"Version {version} not found for package {normalized_name}")
logger.error(
f"Version {version} not found for package {normalized_name}"
)
raise PackageNotFoundError(
f"Version {version} not found for package {normalized_name}"
)
else:
logger.error(f"Failed to fetch package info for {normalized_name}: {e}")
raise
except Exception as e:
logger.error(f"Failed to fetch package info for {normalized_name} version {version or 'latest'}: {e}")
logger.error(
f"Failed to fetch package info for {normalized_name} version {version or 'latest'}: {e}"
)
raise
async def get_package_versions(
@ -236,7 +246,9 @@ class PyPIClient:
Returns:
List of version strings
"""
package_info = await self.get_package_info(package_name, version=None, use_cache=use_cache)
package_info = await self.get_package_info(
package_name, version=None, use_cache=use_cache
)
releases = package_info.get("releases", {})
return list(releases.keys())
@ -252,7 +264,9 @@ class PyPIClient:
Returns:
Latest version string
"""
package_info = await self.get_package_info(package_name, version=None, use_cache=use_cache)
package_info = await self.get_package_info(
package_name, version=None, use_cache=use_cache
)
return package_info.get("info", {}).get("version", "")
def clear_cache(self):

View File

@ -5,7 +5,7 @@ import logging
import random
import time
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from typing import Any
import httpx
@ -106,7 +106,9 @@ class PyPIStatsClient:
)
return f"{endpoint}:{package_name}:{param_str}"
def _is_cache_valid(self, cache_entry: dict[str, Any], fallback: bool = False) -> bool:
def _is_cache_valid(
self, cache_entry: dict[str, Any], fallback: bool = False
) -> bool:
"""Check if cache entry is still valid.
Args:
@ -152,7 +154,9 @@ class PyPIStatsClient:
for attempt in range(self.max_retries + 1):
try:
logger.debug(f"Making request to {url} (attempt {attempt + 1}/{self.max_retries + 1})")
logger.debug(
f"Making request to {url} (attempt {attempt + 1}/{self.max_retries + 1})"
)
response = await self._client.get(url)
@ -171,16 +175,25 @@ class PyPIStatsClient:
elif response.status_code == 429:
retry_after = response.headers.get("Retry-After")
retry_after_int = int(retry_after) if retry_after else None
self._update_api_failure(f"Rate limit exceeded (retry after {retry_after_int}s)")
self._update_api_failure(
f"Rate limit exceeded (retry after {retry_after_int}s)"
)
raise RateLimitError(retry_after_int)
elif response.status_code >= 500:
error_msg = f"Server error: HTTP {response.status_code}"
self._update_api_failure(error_msg)
# For 502/503/504 errors, continue retrying
if response.status_code in [502, 503, 504] and attempt < self.max_retries:
last_exception = PyPIServerError(response.status_code, error_msg)
logger.warning(f"Retryable server error {response.status_code}, attempt {attempt + 1}")
if (
response.status_code in [502, 503, 504]
and attempt < self.max_retries
):
last_exception = PyPIServerError(
response.status_code, error_msg
)
logger.warning(
f"Retryable server error {response.status_code}, attempt {attempt + 1}"
)
else:
raise PyPIServerError(response.status_code, error_msg)
else:
@ -205,7 +218,9 @@ class PyPIStatsClient:
# Only retry certain server errors
if e.status_code in [502, 503, 504] and attempt < self.max_retries:
last_exception = e
logger.warning(f"Retrying server error {e.status_code}, attempt {attempt + 1}")
logger.warning(
f"Retrying server error {e.status_code}, attempt {attempt + 1}"
)
else:
raise
except Exception as e:
@ -216,7 +231,7 @@ class PyPIStatsClient:
# Calculate exponential backoff with jitter
if attempt < self.max_retries:
base_delay = self.retry_delay * (2 ** attempt)
base_delay = self.retry_delay * (2**attempt)
jitter = random.uniform(0.1, 0.3) * base_delay # Add 10-30% jitter
delay = base_delay + jitter
logger.debug(f"Waiting {delay:.2f}s before retry...")
@ -232,9 +247,13 @@ class PyPIStatsClient:
"""Update API health tracking on failure."""
self._api_health["consecutive_failures"] += 1
self._api_health["last_error"] = error_msg
logger.debug(f"API failure count: {self._api_health['consecutive_failures']}, error: {error_msg}")
logger.debug(
f"API failure count: {self._api_health['consecutive_failures']}, error: {error_msg}"
)
def _generate_fallback_recent_downloads(self, package_name: str, period: str = "month") -> dict[str, Any]:
def _generate_fallback_recent_downloads(
self, package_name: str, period: str = "month"
) -> dict[str, Any]:
"""Generate fallback download statistics when API is unavailable.
This provides estimated download counts based on package popularity patterns
@ -276,16 +295,27 @@ class PyPIStatsClient:
estimates = popular_packages[package_name.lower()]
else:
# Generate estimates based on common package patterns
if any(keyword in package_name.lower() for keyword in ["test", "dev", "debug"]):
if any(
keyword in package_name.lower() for keyword in ["test", "dev", "debug"]
):
# Development/testing packages - lower usage
base_daily = random.randint(100, 1000)
elif any(keyword in package_name.lower() for keyword in ["aws", "google", "microsoft", "azure"]):
elif any(
keyword in package_name.lower()
for keyword in ["aws", "google", "microsoft", "azure"]
):
# Cloud provider packages - higher usage
base_daily = random.randint(10000, 50000)
elif any(keyword in package_name.lower() for keyword in ["http", "request", "client", "api"]):
elif any(
keyword in package_name.lower()
for keyword in ["http", "request", "client", "api"]
):
# HTTP/API packages - moderate to high usage
base_daily = random.randint(5000, 25000)
elif any(keyword in package_name.lower() for keyword in ["data", "pandas", "numpy", "scipy"]):
elif any(
keyword in package_name.lower()
for keyword in ["data", "pandas", "numpy", "scipy"]
):
# Data science packages - high usage
base_daily = random.randint(15000, 75000)
else:
@ -315,7 +345,9 @@ class PyPIStatsClient:
"note": "Estimated data due to API unavailability. Actual values may differ.",
}
def _generate_fallback_overall_downloads(self, package_name: str, mirrors: bool = False) -> dict[str, Any]:
def _generate_fallback_overall_downloads(
self, package_name: str, mirrors: bool = False
) -> dict[str, Any]:
"""Generate fallback time series data when API is unavailable."""
logger.warning(f"Generating fallback time series data for {package_name}")
@ -341,14 +373,18 @@ class PyPIStatsClient:
# Add random daily variation
daily_variation = random.uniform(0.7, 1.3)
daily_downloads = int(base_daily * week_factor * growth_factor * daily_variation)
daily_downloads = int(
base_daily * week_factor * growth_factor * daily_variation
)
category = "with_mirrors" if mirrors else "without_mirrors"
time_series.append({
"category": category,
"date": current_date.strftime("%Y-%m-%d"),
"downloads": daily_downloads,
})
time_series.append(
{
"category": category,
"date": current_date.strftime("%Y-%m-%d"),
"downloads": daily_downloads,
}
)
return {
"data": time_series,
@ -385,15 +421,23 @@ class PyPIStatsClient:
if self._is_cache_valid(cache_entry):
logger.debug(f"Using cached recent downloads for: {normalized_name}")
return cache_entry["data"]
elif self._should_use_fallback() and self._is_cache_valid(cache_entry, fallback=True):
logger.info(f"Using extended cache (fallback mode) for: {normalized_name}")
elif self._should_use_fallback() and self._is_cache_valid(
cache_entry, fallback=True
):
logger.info(
f"Using extended cache (fallback mode) for: {normalized_name}"
)
cache_entry["data"]["note"] = "Extended cache data due to API issues"
return cache_entry["data"]
# Check if we should use fallback immediately
if self._should_use_fallback():
logger.warning(f"API health poor, using fallback data for: {normalized_name}")
fallback_data = self._generate_fallback_recent_downloads(normalized_name, period)
logger.warning(
f"API health poor, using fallback data for: {normalized_name}"
)
fallback_data = self._generate_fallback_recent_downloads(
normalized_name, period
)
# Cache fallback data with extended TTL
self._cache[cache_key] = {"data": fallback_data, "timestamp": time.time()}
@ -422,24 +466,35 @@ class PyPIStatsClient:
# Try to use stale cache data if available
if use_cache and cache_key in self._cache:
cache_entry = self._cache[cache_key]
logger.warning(f"Using stale cache data for {normalized_name} due to API failure")
logger.warning(
f"Using stale cache data for {normalized_name} due to API failure"
)
cache_entry["data"]["note"] = f"Stale cache data due to API error: {e}"
return cache_entry["data"]
# Last resort: generate fallback data
if self.fallback_enabled:
logger.warning(f"Generating fallback data for {normalized_name} due to API failure")
fallback_data = self._generate_fallback_recent_downloads(normalized_name, period)
logger.warning(
f"Generating fallback data for {normalized_name} due to API failure"
)
fallback_data = self._generate_fallback_recent_downloads(
normalized_name, period
)
# Cache fallback data
self._cache[cache_key] = {"data": fallback_data, "timestamp": time.time()}
self._cache[cache_key] = {
"data": fallback_data,
"timestamp": time.time(),
}
return fallback_data
# If fallback is disabled, re-raise the original exception
raise
except Exception as e:
logger.error(f"Unexpected error fetching recent downloads for {normalized_name}: {e}")
logger.error(
f"Unexpected error fetching recent downloads for {normalized_name}: {e}"
)
raise
async def get_overall_downloads(
@ -469,15 +524,23 @@ class PyPIStatsClient:
if self._is_cache_valid(cache_entry):
logger.debug(f"Using cached overall downloads for: {normalized_name}")
return cache_entry["data"]
elif self._should_use_fallback() and self._is_cache_valid(cache_entry, fallback=True):
logger.info(f"Using extended cache (fallback mode) for: {normalized_name}")
elif self._should_use_fallback() and self._is_cache_valid(
cache_entry, fallback=True
):
logger.info(
f"Using extended cache (fallback mode) for: {normalized_name}"
)
cache_entry["data"]["note"] = "Extended cache data due to API issues"
return cache_entry["data"]
# Check if we should use fallback immediately
if self._should_use_fallback():
logger.warning(f"API health poor, using fallback data for: {normalized_name}")
fallback_data = self._generate_fallback_overall_downloads(normalized_name, mirrors)
logger.warning(
f"API health poor, using fallback data for: {normalized_name}"
)
fallback_data = self._generate_fallback_overall_downloads(
normalized_name, mirrors
)
# Cache fallback data with extended TTL
self._cache[cache_key] = {"data": fallback_data, "timestamp": time.time()}
@ -506,24 +569,35 @@ class PyPIStatsClient:
# Try to use stale cache data if available
if use_cache and cache_key in self._cache:
cache_entry = self._cache[cache_key]
logger.warning(f"Using stale cache data for {normalized_name} due to API failure")
logger.warning(
f"Using stale cache data for {normalized_name} due to API failure"
)
cache_entry["data"]["note"] = f"Stale cache data due to API error: {e}"
return cache_entry["data"]
# Last resort: generate fallback data
if self.fallback_enabled:
logger.warning(f"Generating fallback data for {normalized_name} due to API failure")
fallback_data = self._generate_fallback_overall_downloads(normalized_name, mirrors)
logger.warning(
f"Generating fallback data for {normalized_name} due to API failure"
)
fallback_data = self._generate_fallback_overall_downloads(
normalized_name, mirrors
)
# Cache fallback data
self._cache[cache_key] = {"data": fallback_data, "timestamp": time.time()}
self._cache[cache_key] = {
"data": fallback_data,
"timestamp": time.time(),
}
return fallback_data
# If fallback is disabled, re-raise the original exception
raise
except Exception as e:
logger.error(f"Unexpected error fetching overall downloads for {normalized_name}: {e}")
logger.error(
f"Unexpected error fetching overall downloads for {normalized_name}: {e}"
)
raise
def clear_cache(self):

View File

@ -10,10 +10,12 @@ The rankings and download estimates are based on:
Data is organized by categories and includes estimated relative popularity.
"""
from typing import Dict, List, NamedTuple
from typing import NamedTuple
class PackageInfo(NamedTuple):
"""Information about a popular package."""
name: str
category: str
estimated_monthly_downloads: int
@ -21,60 +23,226 @@ class PackageInfo(NamedTuple):
description: str
primary_use_case: str
# Core packages that are dependencies for many other packages
INFRASTRUCTURE_PACKAGES = [
PackageInfo("setuptools", "packaging", 800_000_000, 2100, "Package development tools", "packaging"),
PackageInfo("wheel", "packaging", 700_000_000, 400, "Binary package format", "packaging"),
PackageInfo("pip", "packaging", 600_000_000, 9500, "Package installer", "packaging"),
PackageInfo("certifi", "security", 500_000_000, 800, "Certificate bundle", "security"),
PackageInfo("urllib3", "networking", 450_000_000, 3600, "HTTP client library", "networking"),
PackageInfo("charset-normalizer", "text", 400_000_000, 400, "Character encoding detection", "text-processing"),
PackageInfo("idna", "networking", 380_000_000, 200, "Internationalized domain names", "networking"),
PackageInfo("six", "compatibility", 350_000_000, 900, "Python 2 and 3 compatibility", "compatibility"),
PackageInfo("python-dateutil", "datetime", 320_000_000, 2200, "Date and time utilities", "datetime"),
PackageInfo("requests", "networking", 300_000_000, 51000, "HTTP library", "networking"),
PackageInfo(
"setuptools",
"packaging",
800_000_000,
2100,
"Package development tools",
"packaging",
),
PackageInfo(
"wheel", "packaging", 700_000_000, 400, "Binary package format", "packaging"
),
PackageInfo(
"pip", "packaging", 600_000_000, 9500, "Package installer", "packaging"
),
PackageInfo(
"certifi", "security", 500_000_000, 800, "Certificate bundle", "security"
),
PackageInfo(
"urllib3", "networking", 450_000_000, 3600, "HTTP client library", "networking"
),
PackageInfo(
"charset-normalizer",
"text",
400_000_000,
400,
"Character encoding detection",
"text-processing",
),
PackageInfo(
"idna",
"networking",
380_000_000,
200,
"Internationalized domain names",
"networking",
),
PackageInfo(
"six",
"compatibility",
350_000_000,
900,
"Python 2 and 3 compatibility",
"compatibility",
),
PackageInfo(
"python-dateutil",
"datetime",
320_000_000,
2200,
"Date and time utilities",
"datetime",
),
PackageInfo(
"requests", "networking", 300_000_000, 51000, "HTTP library", "networking"
),
]
# AWS and cloud packages
CLOUD_PACKAGES = [
PackageInfo("boto3", "cloud", 280_000_000, 8900, "AWS SDK", "cloud"),
PackageInfo("botocore", "cloud", 275_000_000, 1400, "AWS SDK core", "cloud"),
PackageInfo("s3transfer", "cloud", 250_000_000, 200, "S3 transfer utilities", "cloud"),
PackageInfo(
"s3transfer", "cloud", 250_000_000, 200, "S3 transfer utilities", "cloud"
),
PackageInfo("awscli", "cloud", 80_000_000, 15000, "AWS command line", "cloud"),
PackageInfo("azure-core", "cloud", 45_000_000, 400, "Azure SDK core", "cloud"),
PackageInfo("google-cloud-storage", "cloud", 35_000_000, 300, "Google Cloud Storage", "cloud"),
PackageInfo("azure-storage-blob", "cloud", 30_000_000, 200, "Azure Blob Storage", "cloud"),
PackageInfo(
"google-cloud-storage",
"cloud",
35_000_000,
300,
"Google Cloud Storage",
"cloud",
),
PackageInfo(
"azure-storage-blob", "cloud", 30_000_000, 200, "Azure Blob Storage", "cloud"
),
]
# Data science and ML packages
DATA_SCIENCE_PACKAGES = [
PackageInfo("numpy", "data-science", 200_000_000, 26000, "Numerical computing", "data-science"),
PackageInfo("pandas", "data-science", 150_000_000, 42000, "Data manipulation", "data-science"),
PackageInfo("scikit-learn", "machine-learning", 80_000_000, 58000, "Machine learning", "machine-learning"),
PackageInfo("matplotlib", "visualization", 75_000_000, 19000, "Plotting library", "visualization"),
PackageInfo("scipy", "data-science", 70_000_000, 12000, "Scientific computing", "data-science"),
PackageInfo("seaborn", "visualization", 45_000_000, 11000, "Statistical visualization", "visualization"),
PackageInfo("plotly", "visualization", 40_000_000, 15000, "Interactive plots", "visualization"),
PackageInfo("jupyter", "development", 35_000_000, 7000, "Interactive notebooks", "development"),
PackageInfo("ipython", "development", 50_000_000, 8000, "Interactive Python", "development"),
PackageInfo("tensorflow", "machine-learning", 25_000_000, 185000, "Deep learning", "machine-learning"),
PackageInfo("torch", "machine-learning", 20_000_000, 81000, "PyTorch deep learning", "machine-learning"),
PackageInfo("transformers", "machine-learning", 15_000_000, 130000, "NLP transformers", "machine-learning"),
PackageInfo(
"numpy",
"data-science",
200_000_000,
26000,
"Numerical computing",
"data-science",
),
PackageInfo(
"pandas",
"data-science",
150_000_000,
42000,
"Data manipulation",
"data-science",
),
PackageInfo(
"scikit-learn",
"machine-learning",
80_000_000,
58000,
"Machine learning",
"machine-learning",
),
PackageInfo(
"matplotlib",
"visualization",
75_000_000,
19000,
"Plotting library",
"visualization",
),
PackageInfo(
"scipy",
"data-science",
70_000_000,
12000,
"Scientific computing",
"data-science",
),
PackageInfo(
"seaborn",
"visualization",
45_000_000,
11000,
"Statistical visualization",
"visualization",
),
PackageInfo(
"plotly",
"visualization",
40_000_000,
15000,
"Interactive plots",
"visualization",
),
PackageInfo(
"jupyter",
"development",
35_000_000,
7000,
"Interactive notebooks",
"development",
),
PackageInfo(
"ipython", "development", 50_000_000, 8000, "Interactive Python", "development"
),
PackageInfo(
"tensorflow",
"machine-learning",
25_000_000,
185000,
"Deep learning",
"machine-learning",
),
PackageInfo(
"torch",
"machine-learning",
20_000_000,
81000,
"PyTorch deep learning",
"machine-learning",
),
PackageInfo(
"transformers",
"machine-learning",
15_000_000,
130000,
"NLP transformers",
"machine-learning",
),
]
# Development and testing
DEVELOPMENT_PACKAGES = [
PackageInfo("typing-extensions", "development", 180_000_000, 3000, "Typing extensions", "development"),
PackageInfo("packaging", "development", 160_000_000, 600, "Package utilities", "development"),
PackageInfo("pytest", "testing", 100_000_000, 11000, "Testing framework", "testing"),
PackageInfo(
"typing-extensions",
"development",
180_000_000,
3000,
"Typing extensions",
"development",
),
PackageInfo(
"packaging", "development", 160_000_000, 600, "Package utilities", "development"
),
PackageInfo(
"pytest", "testing", 100_000_000, 11000, "Testing framework", "testing"
),
PackageInfo("click", "cli", 90_000_000, 15000, "Command line interface", "cli"),
PackageInfo("pyyaml", "serialization", 85_000_000, 2200, "YAML parser", "serialization"),
PackageInfo("jinja2", "templating", 80_000_000, 10000, "Template engine", "templating"),
PackageInfo("markupsafe", "templating", 75_000_000, 600, "Safe markup", "templating"),
PackageInfo("attrs", "development", 60_000_000, 5000, "Classes without boilerplate", "development"),
PackageInfo("black", "development", 40_000_000, 38000, "Code formatter", "development"),
PackageInfo("flake8", "development", 35_000_000, 3000, "Code linting", "development"),
PackageInfo("mypy", "development", 30_000_000, 17000, "Static type checker", "development"),
PackageInfo(
"pyyaml", "serialization", 85_000_000, 2200, "YAML parser", "serialization"
),
PackageInfo(
"jinja2", "templating", 80_000_000, 10000, "Template engine", "templating"
),
PackageInfo(
"markupsafe", "templating", 75_000_000, 600, "Safe markup", "templating"
),
PackageInfo(
"attrs",
"development",
60_000_000,
5000,
"Classes without boilerplate",
"development",
),
PackageInfo(
"black", "development", 40_000_000, 38000, "Code formatter", "development"
),
PackageInfo(
"flake8", "development", 35_000_000, 3000, "Code linting", "development"
),
PackageInfo(
"mypy", "development", 30_000_000, 17000, "Static type checker", "development"
),
]
# Web development
@ -83,49 +251,87 @@ WEB_PACKAGES = [
PackageInfo("flask", "web", 55_000_000, 66000, "Micro web framework", "web"),
PackageInfo("fastapi", "web", 35_000_000, 74000, "Modern web API framework", "web"),
PackageInfo("sqlalchemy", "database", 50_000_000, 8000, "SQL toolkit", "database"),
PackageInfo("psycopg2", "database", 25_000_000, 3000, "PostgreSQL adapter", "database"),
PackageInfo(
"psycopg2", "database", 25_000_000, 3000, "PostgreSQL adapter", "database"
),
PackageInfo("redis", "database", 30_000_000, 12000, "Redis client", "database"),
PackageInfo("celery", "async", 25_000_000, 23000, "Distributed task queue", "async"),
PackageInfo(
"celery", "async", 25_000_000, 23000, "Distributed task queue", "async"
),
PackageInfo("gunicorn", "web", 20_000_000, 9000, "WSGI server", "web"),
PackageInfo("uvicorn", "web", 15_000_000, 8000, "ASGI server", "web"),
]
# Security and cryptography
SECURITY_PACKAGES = [
PackageInfo("cryptography", "security", 120_000_000, 6000, "Cryptographic library", "security"),
PackageInfo("pyopenssl", "security", 60_000_000, 800, "OpenSSL wrapper", "security"),
PackageInfo(
"cryptography",
"security",
120_000_000,
6000,
"Cryptographic library",
"security",
),
PackageInfo(
"pyopenssl", "security", 60_000_000, 800, "OpenSSL wrapper", "security"
),
PackageInfo("pyjwt", "security", 40_000_000, 5000, "JSON Web Tokens", "security"),
PackageInfo("bcrypt", "security", 35_000_000, 1200, "Password hashing", "security"),
PackageInfo("pycryptodome", "security", 30_000_000, 2700, "Cryptographic library", "security"),
PackageInfo(
"pycryptodome",
"security",
30_000_000,
2700,
"Cryptographic library",
"security",
),
]
# Networking and API
NETWORKING_PACKAGES = [
PackageInfo("httpx", "networking", 25_000_000, 12000, "HTTP client", "networking"),
PackageInfo("aiohttp", "networking", 35_000_000, 14000, "Async HTTP", "networking"),
PackageInfo("websockets", "networking", 20_000_000, 5000, "WebSocket implementation", "networking"),
PackageInfo(
"websockets",
"networking",
20_000_000,
5000,
"WebSocket implementation",
"networking",
),
PackageInfo("paramiko", "networking", 25_000_000, 8000, "SSH client", "networking"),
]
# Text processing and parsing
TEXT_PACKAGES = [
PackageInfo("beautifulsoup4", "parsing", 40_000_000, 13000, "HTML/XML parser", "parsing"),
PackageInfo(
"beautifulsoup4", "parsing", 40_000_000, 13000, "HTML/XML parser", "parsing"
),
PackageInfo("lxml", "parsing", 35_000_000, 2600, "XML/HTML parser", "parsing"),
PackageInfo("regex", "text", 30_000_000, 700, "Regular expressions", "text-processing"),
PackageInfo("python-docx", "text", 15_000_000, 4000, "Word document processing", "text-processing"),
PackageInfo(
"regex", "text", 30_000_000, 700, "Regular expressions", "text-processing"
),
PackageInfo(
"python-docx",
"text",
15_000_000,
4000,
"Word document processing",
"text-processing",
),
PackageInfo("pillow", "imaging", 60_000_000, 11000, "Image processing", "imaging"),
]
# All packages combined for easy access
ALL_POPULAR_PACKAGES = (
INFRASTRUCTURE_PACKAGES +
CLOUD_PACKAGES +
DATA_SCIENCE_PACKAGES +
DEVELOPMENT_PACKAGES +
WEB_PACKAGES +
SECURITY_PACKAGES +
NETWORKING_PACKAGES +
TEXT_PACKAGES
INFRASTRUCTURE_PACKAGES
+ CLOUD_PACKAGES
+ DATA_SCIENCE_PACKAGES
+ DEVELOPMENT_PACKAGES
+ WEB_PACKAGES
+ SECURITY_PACKAGES
+ NETWORKING_PACKAGES
+ TEXT_PACKAGES
)
# Create lookup dictionaries
@ -136,11 +342,10 @@ for pkg in ALL_POPULAR_PACKAGES:
PACKAGES_BY_CATEGORY[pkg.category] = []
PACKAGES_BY_CATEGORY[pkg.category].append(pkg)
def get_popular_packages(
category: str = None,
limit: int = 50,
min_downloads: int = 0
) -> List[PackageInfo]:
category: str = None, limit: int = 50, min_downloads: int = 0
) -> list[PackageInfo]:
"""Get popular packages filtered by criteria.
Args:
@ -157,13 +362,18 @@ def get_popular_packages(
packages = [pkg for pkg in packages if pkg.category == category]
if min_downloads:
packages = [pkg for pkg in packages if pkg.estimated_monthly_downloads >= min_downloads]
packages = [
pkg for pkg in packages if pkg.estimated_monthly_downloads >= min_downloads
]
# Sort by estimated downloads (descending)
packages = sorted(packages, key=lambda x: x.estimated_monthly_downloads, reverse=True)
packages = sorted(
packages, key=lambda x: x.estimated_monthly_downloads, reverse=True
)
return packages[:limit]
def estimate_downloads_for_period(monthly_downloads: int, period: str) -> int:
"""Estimate downloads for different time periods.
@ -183,6 +393,7 @@ def estimate_downloads_for_period(monthly_downloads: int, period: str) -> int:
else:
return monthly_downloads
def get_package_info(package_name: str) -> PackageInfo:
"""Get information about a specific package.
@ -192,7 +403,10 @@ def get_package_info(package_name: str) -> PackageInfo:
Returns:
PackageInfo object or None if not found
"""
return PACKAGES_BY_NAME.get(package_name.lower().replace("-", "_").replace("_", "-"))
return PACKAGES_BY_NAME.get(
package_name.lower().replace("-", "_").replace("_", "-")
)
# GitHub repository URL patterns for fetching real-time data
GITHUB_REPO_PATTERNS = {

View File

@ -140,7 +140,7 @@ async def get_package_dependencies(
version: str | None = None,
include_transitive: bool = False,
max_depth: int = 5,
python_version: str | None = None
python_version: str | None = None,
) -> dict[str, Any]:
"""Get dependency information for a PyPI package.
@ -175,7 +175,11 @@ async def get_package_dependencies(
logger.info(
f"MCP tool: Querying dependencies for {package_name}"
+ (f" version {version}" if version else " (latest)")
+ (f" with transitive dependencies (max depth: {max_depth})" if include_transitive else " (direct only)")
+ (
f" with transitive dependencies (max depth: {max_depth})"
if include_transitive
else " (direct only)"
)
)
result = await query_package_dependencies(
package_name, version, include_transitive, max_depth, python_version

View File

@ -3,14 +3,13 @@
import logging
import os
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any
from ..core.github_client import GitHubAPIClient
from ..core.pypi_client import PyPIClient
from ..core.stats_client import PyPIStatsClient
from ..data.popular_packages import (
GITHUB_REPO_PATTERNS,
PACKAGES_BY_NAME,
estimate_downloads_for_period,
get_popular_packages,
)
@ -95,7 +94,9 @@ async def get_package_download_stats(
# Add reliability indicator
if data_source == "fallback_estimates":
result["reliability"] = "estimated"
result["warning"] = "Data is estimated due to API unavailability. Actual download counts may differ significantly."
result["warning"] = (
"Data is estimated due to API unavailability. Actual download counts may differ significantly."
)
elif "stale" in warning_note.lower() if warning_note else False:
result["reliability"] = "cached"
result["warning"] = "Data may be outdated due to current API issues."
@ -163,7 +164,9 @@ async def get_package_download_trends(
# Add reliability indicator
if data_source == "fallback_estimates":
result["reliability"] = "estimated"
result["warning"] = "Data is estimated due to API unavailability. Actual download trends may differ significantly."
result["warning"] = (
"Data is estimated due to API unavailability. Actual download trends may differ significantly."
)
elif "stale" in warning_note.lower() if warning_note else False:
result["reliability"] = "cached"
result["warning"] = "Data may be outdated due to current API issues."
@ -203,14 +206,10 @@ async def get_top_packages_by_downloads(
curated_packages = get_popular_packages(limit=max(limit * 2, 100))
# Try to enhance with real PyPI stats
enhanced_packages = await _enhance_with_real_stats(
curated_packages, period, limit
)
enhanced_packages = await _enhance_with_real_stats(curated_packages, period, limit)
# Try to enhance with GitHub metrics
final_packages = await _enhance_with_github_stats(
enhanced_packages, limit
)
final_packages = await _enhance_with_github_stats(enhanced_packages, limit)
# Ensure we have the requested number of packages
if len(final_packages) < limit:
@ -220,17 +219,19 @@ async def get_top_packages_by_downloads(
for pkg_info in curated_packages:
if pkg_info.name not in existing_names and additional_needed > 0:
final_packages.append({
"package": pkg_info.name,
"downloads": estimate_downloads_for_period(
pkg_info.estimated_monthly_downloads, period
),
"period": period,
"data_source": "curated",
"category": pkg_info.category,
"description": pkg_info.description,
"estimated": True,
})
final_packages.append(
{
"package": pkg_info.name,
"downloads": estimate_downloads_for_period(
pkg_info.estimated_monthly_downloads, period
),
"period": period,
"data_source": "curated",
"category": pkg_info.category,
"description": pkg_info.description,
"estimated": True,
}
)
additional_needed -= 1
# Sort by download count and assign ranks
@ -386,8 +387,8 @@ def _analyze_download_trends(
async def _enhance_with_real_stats(
curated_packages: List, period: str, limit: int
) -> List[Dict[str, Any]]:
curated_packages: list, period: str, limit: int
) -> list[dict[str, Any]]:
"""Try to enhance curated packages with real PyPI download statistics.
Args:
@ -403,7 +404,7 @@ async def _enhance_with_real_stats(
try:
async with PyPIStatsClient() as stats_client:
# Try to get real stats for top packages
for pkg_info in curated_packages[:limit * 2]: # Try more than needed
for pkg_info in curated_packages[: limit * 2]: # Try more than needed
try:
stats = await stats_client.get_recent_downloads(
pkg_info.name, period, use_cache=True
@ -414,30 +415,36 @@ async def _enhance_with_real_stats(
if real_download_count > 0:
# Use real stats
enhanced_packages.append({
"package": pkg_info.name,
"downloads": real_download_count,
"period": period,
"data_source": "pypistats.org",
"category": pkg_info.category,
"description": pkg_info.description,
"estimated": False,
})
logger.debug(f"Got real stats for {pkg_info.name}: {real_download_count}")
enhanced_packages.append(
{
"package": pkg_info.name,
"downloads": real_download_count,
"period": period,
"data_source": "pypistats.org",
"category": pkg_info.category,
"description": pkg_info.description,
"estimated": False,
}
)
logger.debug(
f"Got real stats for {pkg_info.name}: {real_download_count}"
)
else:
# Fall back to estimated downloads
estimated_downloads = estimate_downloads_for_period(
pkg_info.estimated_monthly_downloads, period
)
enhanced_packages.append({
"package": pkg_info.name,
"downloads": estimated_downloads,
"period": period,
"data_source": "estimated",
"category": pkg_info.category,
"description": pkg_info.description,
"estimated": True,
})
enhanced_packages.append(
{
"package": pkg_info.name,
"downloads": estimated_downloads,
"period": period,
"data_source": "estimated",
"category": pkg_info.category,
"description": pkg_info.description,
"estimated": True,
}
)
except Exception as e:
logger.debug(f"Failed to get real stats for {pkg_info.name}: {e}")
@ -445,15 +452,17 @@ async def _enhance_with_real_stats(
estimated_downloads = estimate_downloads_for_period(
pkg_info.estimated_monthly_downloads, period
)
enhanced_packages.append({
"package": pkg_info.name,
"downloads": estimated_downloads,
"period": period,
"data_source": "estimated",
"category": pkg_info.category,
"description": pkg_info.description,
"estimated": True,
})
enhanced_packages.append(
{
"package": pkg_info.name,
"downloads": estimated_downloads,
"period": period,
"data_source": "estimated",
"category": pkg_info.category,
"description": pkg_info.description,
"estimated": True,
}
)
# Stop if we have enough packages
if len(enhanced_packages) >= limit:
@ -466,22 +475,24 @@ async def _enhance_with_real_stats(
estimated_downloads = estimate_downloads_for_period(
pkg_info.estimated_monthly_downloads, period
)
enhanced_packages.append({
"package": pkg_info.name,
"downloads": estimated_downloads,
"period": period,
"data_source": "estimated",
"category": pkg_info.category,
"description": pkg_info.description,
"estimated": True,
})
enhanced_packages.append(
{
"package": pkg_info.name,
"downloads": estimated_downloads,
"period": period,
"data_source": "estimated",
"category": pkg_info.category,
"description": pkg_info.description,
"estimated": True,
}
)
return enhanced_packages
async def _enhance_with_github_stats(
packages: List[Dict[str, Any]], limit: int
) -> List[Dict[str, Any]]:
packages: list[dict[str, Any]], limit: int
) -> list[dict[str, Any]]:
"""Try to enhance packages with GitHub repository statistics.
Args:
@ -507,7 +518,9 @@ async def _enhance_with_github_stats(
if repo_paths:
# Fetch GitHub stats for all repositories concurrently
logger.debug(f"Fetching GitHub stats for {len(repo_paths)} repositories")
logger.debug(
f"Fetching GitHub stats for {len(repo_paths)} repositories"
)
repo_stats = await github_client.get_multiple_repo_stats(
repo_paths, use_cache=True, max_concurrent=3
)
@ -527,10 +540,14 @@ async def _enhance_with_github_stats(
# Adjust download estimates based on GitHub popularity
if pkg.get("estimated", False):
popularity_boost = _calculate_popularity_boost(stats)
pkg["downloads"] = int(pkg["downloads"] * popularity_boost)
pkg["downloads"] = int(
pkg["downloads"] * popularity_boost
)
pkg["github_enhanced"] = True
logger.info(f"Enhanced {len([p for p in packages if 'github_stars' in p])} packages with GitHub data")
logger.info(
f"Enhanced {len([p for p in packages if 'github_stars' in p])} packages with GitHub data"
)
except Exception as e:
logger.debug(f"GitHub enhancement failed: {e}")
@ -540,7 +557,7 @@ async def _enhance_with_github_stats(
return packages
def _calculate_popularity_boost(github_stats: Dict[str, Any]) -> float:
def _calculate_popularity_boost(github_stats: dict[str, Any]) -> float:
"""Calculate a popularity boost multiplier based on GitHub metrics.
Args:

View File

@ -68,8 +68,12 @@ def format_package_info(package_data: dict[str, Any]) -> dict[str, Any]:
formatted["total_versions"] = len(releases)
# Sort versions semantically and get the most recent 10
if releases:
sorted_versions = sort_versions_semantically(list(releases.keys()), reverse=True)
formatted["available_versions"] = sorted_versions[:10] # Most recent 10 versions
sorted_versions = sort_versions_semantically(
list(releases.keys()), reverse=True
)
formatted["available_versions"] = sorted_versions[
:10
] # Most recent 10 versions
else:
formatted["available_versions"] = []
@ -164,9 +168,26 @@ def format_dependency_info(package_data: dict[str, Any]) -> dict[str, Any]:
# Define development-related extra names (same as in DependencyParser)
dev_extra_names = {
'dev', 'development', 'test', 'testing', 'tests', 'lint', 'linting',
'doc', 'docs', 'documentation', 'build', 'check', 'cover', 'coverage',
'type', 'typing', 'mypy', 'style', 'format', 'quality'
"dev",
"development",
"test",
"testing",
"tests",
"lint",
"linting",
"doc",
"docs",
"documentation",
"build",
"check",
"cover",
"coverage",
"type",
"typing",
"mypy",
"style",
"format",
"quality",
}
for extra_name, deps in optional_deps.items():
@ -264,7 +285,7 @@ async def query_package_dependencies(
version: str | None = None,
include_transitive: bool = False,
max_depth: int = 5,
python_version: str | None = None
python_version: str | None = None,
) -> dict[str, Any]:
"""Query package dependency information from PyPI.
@ -293,7 +314,11 @@ async def query_package_dependencies(
logger.info(
f"Querying dependencies for package: {package_name}"
+ (f" version {version}" if version else " (latest)")
+ (f" with transitive dependencies (max depth: {max_depth})" if include_transitive else " (direct only)")
+ (
f" with transitive dependencies (max depth: {max_depth})"
if include_transitive
else " (direct only)"
)
)
try:
@ -306,7 +331,7 @@ async def query_package_dependencies(
python_version=python_version,
include_extras=[],
include_dev=False,
max_depth=max_depth
max_depth=max_depth,
)
# Format the transitive dependency result to match expected structure
@ -315,7 +340,9 @@ async def query_package_dependencies(
# Use direct dependency logic with version support
async with PyPIClient() as client:
# Pass the version parameter to get_package_info
package_data = await client.get_package_info(package_name, version=version)
package_data = await client.get_package_info(
package_name, version=version
)
return format_dependency_info(package_data)
except PyPIError:
# Re-raise PyPI-specific errors
@ -353,40 +380,49 @@ def format_transitive_dependency_info(
"include_transitive": True,
"max_depth": summary.get("max_depth", 0),
"python_version": resolver_result.get("python_version"),
# Direct dependencies (same as before)
"runtime_dependencies": main_package.get("dependencies", {}).get("runtime", []),
"development_dependencies": main_package.get("dependencies", {}).get("development", []),
"development_dependencies": main_package.get("dependencies", {}).get(
"development", []
),
"optional_dependencies": main_package.get("dependencies", {}).get("extras", {}),
# Transitive dependency information
"transitive_dependencies": {
"dependency_tree": _build_dependency_tree_structure(dependency_tree, normalized_name),
"dependency_tree": _build_dependency_tree_structure(
dependency_tree, normalized_name
),
"all_packages": _extract_all_packages_info(dependency_tree),
"circular_dependencies": _detect_circular_dependencies(dependency_tree),
"depth_analysis": _analyze_dependency_depths(dependency_tree),
},
# Enhanced summary statistics
"dependency_summary": {
"direct_runtime_count": len(main_package.get("dependencies", {}).get("runtime", [])),
"direct_dev_count": len(main_package.get("dependencies", {}).get("development", [])),
"direct_optional_groups": len(main_package.get("dependencies", {}).get("extras", {})),
"total_transitive_packages": summary.get("total_packages", 0) - 1, # Exclude main package
"direct_runtime_count": len(
main_package.get("dependencies", {}).get("runtime", [])
),
"direct_dev_count": len(
main_package.get("dependencies", {}).get("development", [])
),
"direct_optional_groups": len(
main_package.get("dependencies", {}).get("extras", {})
),
"total_transitive_packages": summary.get("total_packages", 0)
- 1, # Exclude main package
"total_runtime_dependencies": summary.get("total_runtime_dependencies", 0),
"total_development_dependencies": summary.get("total_development_dependencies", 0),
"total_development_dependencies": summary.get(
"total_development_dependencies", 0
),
"total_extra_dependencies": summary.get("total_extra_dependencies", 0),
"max_dependency_depth": summary.get("max_depth", 0),
"complexity_score": _calculate_complexity_score(summary),
},
# Performance and health metrics
"analysis": {
"resolution_stats": summary,
"potential_conflicts": _analyze_potential_conflicts(dependency_tree),
"maintenance_concerns": _analyze_maintenance_concerns(dependency_tree),
"performance_impact": _assess_performance_impact(summary),
}
},
}
return result
@ -416,7 +452,7 @@ def _build_dependency_tree_structure(
"depth": package_info.get("depth", 0),
"requires_python": package_info.get("requires_python", ""),
"dependencies": package_info.get("dependencies", {}),
"children": {}
"children": {},
}
# Recursively build children (with visited tracking to prevent infinite loops)
@ -428,13 +464,15 @@ def _build_dependency_tree_structure(
else:
tree_node["children"][child_name] = {
"circular_reference": True,
"package_name": child_name
"package_name": child_name,
}
return tree_node
def _extract_all_packages_info(dependency_tree: dict[str, Any]) -> dict[str, dict[str, Any]]:
def _extract_all_packages_info(
dependency_tree: dict[str, Any],
) -> dict[str, dict[str, Any]]:
"""Extract comprehensive information about all packages in the dependency tree."""
all_packages = {}
@ -446,20 +484,31 @@ def _extract_all_packages_info(dependency_tree: dict[str, Any]) -> dict[str, dic
"requires_python": package_info.get("requires_python", ""),
"direct_dependencies": {
"runtime": package_info.get("dependencies", {}).get("runtime", []),
"development": package_info.get("dependencies", {}).get("development", []),
"development": package_info.get("dependencies", {}).get(
"development", []
),
"extras": package_info.get("dependencies", {}).get("extras", {}),
},
"dependency_count": {
"runtime": len(package_info.get("dependencies", {}).get("runtime", [])),
"development": len(package_info.get("dependencies", {}).get("development", [])),
"total_extras": sum(len(deps) for deps in package_info.get("dependencies", {}).get("extras", {}).values()),
}
"development": len(
package_info.get("dependencies", {}).get("development", [])
),
"total_extras": sum(
len(deps)
for deps in package_info.get("dependencies", {})
.get("extras", {})
.values()
),
},
}
return all_packages
def _detect_circular_dependencies(dependency_tree: dict[str, Any]) -> list[dict[str, Any]]:
def _detect_circular_dependencies(
dependency_tree: dict[str, Any],
) -> list[dict[str, Any]]:
"""Detect circular dependencies in the dependency tree."""
circular_deps = []
@ -468,11 +517,13 @@ def _detect_circular_dependencies(dependency_tree: dict[str, Any]) -> list[dict[
# Found a circular dependency
cycle_start = path.index(package_name)
cycle = path[cycle_start:] + [package_name]
circular_deps.append({
"cycle": cycle,
"length": len(cycle) - 1,
"packages_involved": list(set(cycle))
})
circular_deps.append(
{
"cycle": cycle,
"length": len(cycle) - 1,
"packages_involved": list(set(cycle)),
}
)
return
if package_name in visited or package_name not in dependency_tree:
@ -524,12 +575,19 @@ def _analyze_dependency_depths(dependency_tree: dict[str, Any]) -> dict[str, Any
"max_depth": max_depth,
"depth_distribution": depth_counts,
"packages_by_depth": depth_packages,
"average_depth": sum(d * c for d, c in depth_counts.items()) / sum(depth_counts.values()) if depth_counts else 0,
"average_depth": sum(d * c for d, c in depth_counts.items())
/ sum(depth_counts.values())
if depth_counts
else 0,
"depth_analysis": {
"shallow_deps": depth_counts.get(1, 0), # Direct dependencies
"deep_deps": sum(count for depth, count in depth_counts.items() if depth > 2),
"leaf_packages": [pkg for pkg, info in dependency_tree.items() if not info.get("children")]
}
"deep_deps": sum(
count for depth, count in depth_counts.items() if depth > 2
),
"leaf_packages": [
pkg for pkg, info in dependency_tree.items() if not info.get("children")
],
},
}
@ -558,7 +616,9 @@ def _calculate_complexity_score(summary: dict[str, Any]) -> dict[str, Any]:
recommendation = "High complexity, consider dependency management strategies"
else:
complexity_level = "very_high"
recommendation = "Very high complexity, significant maintenance overhead expected"
recommendation = (
"Very high complexity, significant maintenance overhead expected"
)
return {
"score": round(complexity_score, 2),
@ -568,11 +628,13 @@ def _calculate_complexity_score(summary: dict[str, Any]) -> dict[str, Any]:
"total_packages": total_packages,
"max_depth": max_depth,
"total_dependencies": total_deps,
}
},
}
def _analyze_potential_conflicts(dependency_tree: dict[str, Any]) -> list[dict[str, Any]]:
def _analyze_potential_conflicts(
dependency_tree: dict[str, Any],
) -> list[dict[str, Any]]:
"""Analyze potential version conflicts in dependencies."""
# This is a simplified analysis - in a real implementation,
# you'd parse version constraints and check for conflicts
@ -585,24 +647,30 @@ def _analyze_potential_conflicts(dependency_tree: dict[str, Any]) -> list[dict[s
for dep_str in runtime_deps:
# Basic parsing of "package>=version" format
if ">=" in dep_str or "==" in dep_str or "<" in dep_str or ">" in dep_str:
parts = dep_str.replace(">=", "@").replace("==", "@").replace("<", "@").replace(">", "@")
parts = (
dep_str.replace(">=", "@")
.replace("==", "@")
.replace("<", "@")
.replace(">", "@")
)
dep_name = parts.split("@")[0].strip()
if dep_name not in package_versions:
package_versions[dep_name] = []
package_versions[dep_name].append({
"constraint": dep_str,
"required_by": package_name
})
package_versions[dep_name].append(
{"constraint": dep_str, "required_by": package_name}
)
# Look for packages with multiple version constraints
for dep_name, constraints in package_versions.items():
if len(constraints) > 1:
potential_conflicts.append({
"package": dep_name,
"conflicting_constraints": constraints,
"severity": "potential" if len(constraints) == 2 else "high"
})
potential_conflicts.append(
{
"package": dep_name,
"conflicting_constraints": constraints,
"severity": "potential" if len(constraints) == 2 else "high",
}
)
return potential_conflicts
@ -611,20 +679,20 @@ def _analyze_maintenance_concerns(dependency_tree: dict[str, Any]) -> dict[str,
"""Analyze maintenance concerns in the dependency tree."""
total_packages = len(dependency_tree)
packages_without_version = sum(
1 for info in dependency_tree.values()
1
for info in dependency_tree.values()
if info.get("version") in ["unknown", "", None]
)
packages_without_python_req = sum(
1 for info in dependency_tree.values()
if not info.get("requires_python")
1 for info in dependency_tree.values() if not info.get("requires_python")
)
# Calculate dependency concentration (packages with many dependencies)
high_dep_packages = [
{
"name": name,
"dependency_count": len(info.get("dependencies", {}).get("runtime", []))
"dependency_count": len(info.get("dependencies", {}).get("runtime", [])),
}
for name, info in dependency_tree.items()
if len(info.get("dependencies", {}).get("runtime", [])) > 5
@ -637,11 +705,18 @@ def _analyze_maintenance_concerns(dependency_tree: dict[str, Any]) -> dict[str,
"high_dependency_packages": high_dep_packages,
"maintenance_risk_score": {
"score": round(
(packages_without_version / total_packages * 100) +
(len(high_dep_packages) / total_packages * 50), 2
) if total_packages > 0 else 0,
"level": "low" if total_packages < 10 else "moderate" if total_packages < 30 else "high"
}
(packages_without_version / total_packages * 100)
+ (len(high_dep_packages) / total_packages * 50),
2,
)
if total_packages > 0
else 0,
"level": "low"
if total_packages < 10
else "moderate"
if total_packages < 30
else "high",
},
}
@ -659,9 +734,13 @@ def _assess_performance_impact(summary: dict[str, Any]) -> dict[str, Any]:
# Performance recommendations
recommendations = []
if total_packages > 50:
recommendations.append("Consider using virtual environments to isolate dependencies")
recommendations.append(
"Consider using virtual environments to isolate dependencies"
)
if max_depth > 5:
recommendations.append("Deep dependency chains may slow resolution and installation")
recommendations.append(
"Deep dependency chains may slow resolution and installation"
)
if total_packages > 100:
recommendations.append("Consider dependency analysis tools for large projects")
@ -669,14 +748,16 @@ def _assess_performance_impact(summary: dict[str, Any]) -> dict[str, Any]:
"estimated_install_time_seconds": estimated_install_time,
"estimated_memory_footprint_mb": estimated_memory_mb,
"performance_level": (
"good" if total_packages < 20
else "moderate" if total_packages < 50
"good"
if total_packages < 20
else "moderate"
if total_packages < 50
else "concerning"
),
"recommendations": recommendations,
"metrics": {
"package_count_impact": "low" if total_packages < 20 else "high",
"depth_impact": "low" if max_depth < 4 else "high",
"resolution_complexity": "simple" if total_packages < 10 else "complex"
}
"resolution_complexity": "simple" if total_packages < 10 else "complex",
},
}

View File

@ -34,7 +34,7 @@ httpx = "^0.28.0"
packaging = "^24.0"
pydantic = "^2.0.0"
pydantic-settings = "^2.0.0"
click = "^8.1.0"
click = "8.1.7"
[tool.poetry.group.dev.dependencies]
pytest = "^8.0.0"

View File

@ -2,8 +2,9 @@
"""Quick test to verify fallback mechanism works."""
import asyncio
import sys
import os
import sys
sys.path.insert(0, os.path.abspath("."))
from pypi_query_mcp.tools.download_stats import get_package_download_stats
@ -16,12 +17,12 @@ async def quick_test():
try:
stats = await get_package_download_stats("requests", period="month")
print(f"✅ Success!")
print("✅ Success!")
print(f"Package: {stats.get('package')}")
print(f"Data Source: {stats.get('data_source')}")
print(f"Reliability: {stats.get('reliability')}")
if stats.get('warning'):
if stats.get("warning"):
print(f"⚠️ Warning: {stats['warning']}")
downloads = stats.get("downloads", {})

View File

@ -1,12 +1,13 @@
#!/usr/bin/env python3
"""Simple test for the transitive dependency formatting functions."""
import sys
import os
import sys
# Add the current directory to Python path
sys.path.insert(0, os.path.dirname(__file__))
def test_formatting_functions():
"""Test the formatting functions directly."""
print("Testing transitive dependency formatting functions...")
@ -21,9 +22,13 @@ def test_formatting_functions():
"version": "2.31.0",
"requires_python": ">=3.7",
"dependencies": {
"runtime": ["urllib3>=1.21.1", "certifi>=2017.4.17", "charset-normalizer>=2.0"],
"runtime": [
"urllib3>=1.21.1",
"certifi>=2017.4.17",
"charset-normalizer>=2.0",
],
"development": [],
"extras": {}
"extras": {},
},
"depth": 0,
"children": {
@ -34,10 +39,10 @@ def test_formatting_functions():
"dependencies": {
"runtime": [],
"development": [],
"extras": {}
"extras": {},
},
"depth": 1,
"children": {}
"children": {},
},
"certifi": {
"name": "certifi",
@ -46,37 +51,29 @@ def test_formatting_functions():
"dependencies": {
"runtime": [],
"development": [],
"extras": {}
"extras": {},
},
"depth": 1,
"children": {}
}
}
"children": {},
},
},
},
"urllib3": {
"name": "urllib3",
"version": "2.0.4",
"requires_python": ">=3.7",
"dependencies": {
"runtime": [],
"development": [],
"extras": {}
},
"dependencies": {"runtime": [], "development": [], "extras": {}},
"depth": 1,
"children": {}
"children": {},
},
"certifi": {
"name": "certifi",
"version": "2023.7.22",
"requires_python": ">=3.6",
"dependencies": {
"runtime": [],
"development": [],
"extras": {}
},
"dependencies": {"runtime": [], "development": [], "extras": {}},
"depth": 1,
"children": {}
}
"children": {},
},
},
"summary": {
"total_packages": 3,
@ -84,19 +81,19 @@ def test_formatting_functions():
"total_development_dependencies": 0,
"total_extra_dependencies": 0,
"max_depth": 1,
"package_list": ["requests", "urllib3", "certifi"]
}
"package_list": ["requests", "urllib3", "certifi"],
},
}
# Import the formatting function
try:
from pypi_query_mcp.tools.package_query import (
format_transitive_dependency_info,
_build_dependency_tree_structure,
_extract_all_packages_info,
_detect_circular_dependencies,
_analyze_dependency_depths,
_calculate_complexity_score
_build_dependency_tree_structure,
_calculate_complexity_score,
_detect_circular_dependencies,
_extract_all_packages_info,
format_transitive_dependency_info,
)
# Test format_transitive_dependency_info
@ -110,19 +107,25 @@ def test_formatting_functions():
print(f" Max depth: {result.get('max_depth')}")
# Test transitive dependencies section
transitive = result.get('transitive_dependencies', {})
transitive = result.get("transitive_dependencies", {})
print(f" All packages count: {len(transitive.get('all_packages', {}))}")
print(f" Circular dependencies: {len(transitive.get('circular_dependencies', []))}")
print(
f" Circular dependencies: {len(transitive.get('circular_dependencies', []))}"
)
# Test dependency summary
summary = result.get('dependency_summary', {})
summary = result.get("dependency_summary", {})
print(f" Direct runtime count: {summary.get('direct_runtime_count')}")
print(f" Total transitive packages: {summary.get('total_transitive_packages')}")
print(
f" Total transitive packages: {summary.get('total_transitive_packages')}"
)
print(f" Complexity level: {summary.get('complexity_score', {}).get('level')}")
# Test analysis section
analysis = result.get('analysis', {})
print(f" Performance level: {analysis.get('performance_impact', {}).get('performance_level')}")
analysis = result.get("analysis", {})
print(
f" Performance level: {analysis.get('performance_impact', {}).get('performance_level')}"
)
print("✓ All formatting functions working correctly")
return True
@ -133,6 +136,7 @@ def test_formatting_functions():
except Exception as e:
print(f"✗ Error testing formatting functions: {e}")
import traceback
traceback.print_exc()
return False
@ -146,27 +150,22 @@ def test_helper_functions():
"name": "pkg-a",
"version": "1.0.0",
"depth": 0,
"children": {"pkg-b": {}, "pkg-c": {}}
},
"pkg-b": {
"name": "pkg-b",
"version": "2.0.0",
"depth": 1,
"children": {}
"children": {"pkg-b": {}, "pkg-c": {}},
},
"pkg-b": {"name": "pkg-b", "version": "2.0.0", "depth": 1, "children": {}},
"pkg-c": {
"name": "pkg-c",
"version": "3.0.0",
"depth": 1,
"children": {"pkg-b": {}} # Creates potential circular reference
}
"children": {"pkg-b": {}}, # Creates potential circular reference
},
}
try:
from pypi_query_mcp.tools.package_query import (
_extract_all_packages_info,
_analyze_dependency_depths,
_calculate_complexity_score
_calculate_complexity_score,
_extract_all_packages_info,
)
# Test _extract_all_packages_info
@ -178,9 +177,15 @@ def test_helper_functions():
print(f"✓ Depth analysis - max depth: {depth_analysis.get('max_depth')}")
# Test _calculate_complexity_score
sample_summary = {"total_packages": 3, "max_depth": 1, "total_runtime_dependencies": 2}
sample_summary = {
"total_packages": 3,
"max_depth": 1,
"total_runtime_dependencies": 2,
}
complexity = _calculate_complexity_score(sample_summary)
print(f"✓ Complexity score: {complexity.get('score')} ({complexity.get('level')})")
print(
f"✓ Complexity score: {complexity.get('score')} ({complexity.get('level')})"
)
return True

View File

@ -3,22 +3,24 @@
import asyncio
import logging
import sys
import os
import sys
# Add the project root to the Python path
sys.path.insert(0, os.path.dirname(__file__))
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
async def test_pypi_client():
"""Test the PyPIClient with version-specific queries."""
# Import only the core modules we need
from pypi_query_mcp.core.pypi_client import PyPIClient
from pypi_query_mcp.core.exceptions import PackageNotFoundError
from pypi_query_mcp.core.pypi_client import PyPIClient
async with PyPIClient() as client:
# Test 1: Django 4.2.0 (specific version)
@ -27,7 +29,9 @@ async def test_pypi_client():
data = await client.get_package_info("django", version="4.2.0")
actual_version = data.get("info", {}).get("version", "")
if actual_version in ["4.2", "4.2.0"]: # PyPI may normalize version numbers
logger.info(f"✅ Django 4.2.0 test passed (got version: {actual_version})")
logger.info(
f"✅ Django 4.2.0 test passed (got version: {actual_version})"
)
else:
logger.error(f"❌ Expected version 4.2.0, got {actual_version}")
return False
@ -95,7 +99,10 @@ async def test_pypi_client():
async def test_dependency_formatting():
"""Test the dependency formatting functions."""
from pypi_query_mcp.tools.package_query import format_dependency_info, validate_version_format
from pypi_query_mcp.tools.package_query import (
format_dependency_info,
validate_version_format,
)
# Test version validation
logger.info("Testing version validation...")
@ -116,7 +123,9 @@ async def test_dependency_formatting():
if result == expected:
logger.info(f"✅ Version validation for '{version}': {result}")
else:
logger.error(f"❌ Version validation for '{version}': expected {expected}, got {result}")
logger.error(
f"❌ Version validation for '{version}': expected {expected}, got {result}"
)
return False
# Test dependency formatting with mock data
@ -130,13 +139,18 @@ async def test_dependency_formatting():
"requests>=2.25.0",
"click>=8.0.0",
"pytest>=6.0.0; extra=='test'",
"black>=21.0.0; extra=='dev'"
]
"black>=21.0.0; extra=='dev'",
],
}
}
result = format_dependency_info(mock_data)
expected_fields = ["package_name", "version", "runtime_dependencies", "dependency_summary"]
expected_fields = [
"package_name",
"version",
"runtime_dependencies",
"dependency_summary",
]
for field in expected_fields:
if field not in result:
logger.error(f"❌ Missing field '{field}' in dependency formatting result")
@ -145,7 +159,9 @@ async def test_dependency_formatting():
if len(result["runtime_dependencies"]) >= 2: # Should have requests and click
logger.info("✅ Dependency formatting test passed")
else:
logger.error(f"❌ Expected at least 2 runtime dependencies, got {len(result['runtime_dependencies'])}")
logger.error(
f"❌ Expected at least 2 runtime dependencies, got {len(result['runtime_dependencies'])}"
)
return False
return True
@ -171,13 +187,19 @@ async def test_comparison():
# They should be different (unless 4.2.0 happens to be latest, which is unlikely)
if specific_version in ["4.2", "4.2.0"] and latest_version != specific_version:
logger.info("✅ Version-specific query returns different version than latest")
logger.info(
"✅ Version-specific query returns different version than latest"
)
return True
elif specific_version in ["4.2", "4.2.0"]:
logger.info("⚠️ Specific version matches latest (this is fine, but less conclusive)")
logger.info(
"⚠️ Specific version matches latest (this is fine, but less conclusive)"
)
return True
else:
logger.error(f"❌ Specific version query failed: expected 4.2.0, got {specific_version}")
logger.error(
f"❌ Specific version query failed: expected 4.2.0, got {specific_version}"
)
return False

View File

@ -3,22 +3,26 @@
import asyncio
import logging
import sys
import os
import re
import httpx
import sys
from urllib.parse import quote
import httpx
# Add the project root to the Python path
sys.path.insert(0, os.path.dirname(__file__))
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
class SimplePackageNotFoundError(Exception):
"""Simple exception for package not found."""
pass
@ -53,7 +57,9 @@ class SimplePyPIClient:
if response.status_code == 404:
if version:
raise SimplePackageNotFoundError(f"Version {version} not found for package {package_name}")
raise SimplePackageNotFoundError(
f"Version {version} not found for package {package_name}"
)
else:
raise SimplePackageNotFoundError(f"Package {package_name} not found")
@ -82,7 +88,9 @@ async def test_version_parameter_fix():
actual_version = data.get("info", {}).get("version", "")
if actual_version in ["4.2", "4.2.0"]:
logger.info(f"✅ Django 4.2.0 test passed (got version: {actual_version})")
logger.info(
f"✅ Django 4.2.0 test passed (got version: {actual_version})"
)
# Check dependencies
deps = data.get("info", {}).get("requires_dist", [])
@ -110,7 +118,9 @@ async def test_version_parameter_fix():
if latest_version not in ["4.2", "4.2.0"]:
logger.info("✅ Confirmed: latest version is different from 4.2.0")
else:
logger.info(" Latest version happens to be 4.2.0 (unlikely but possible)")
logger.info(
" Latest version happens to be 4.2.0 (unlikely but possible)"
)
except Exception as e:
logger.error(f"❌ Django latest test failed: {e}")
@ -171,7 +181,9 @@ async def test_version_parameter_fix():
actual_version = data.get("info", {}).get("version", "")
logger.info(f"✅ Django 5.0a1 test passed - got version: {actual_version}")
except SimplePackageNotFoundError:
logger.info(" Django 5.0a1 not found (this is expected for some pre-release versions)")
logger.info(
" Django 5.0a1 not found (this is expected for some pre-release versions)"
)
except Exception as e:
logger.error(f"❌ Django 5.0a1 test failed: {e}")
return False
@ -202,7 +214,9 @@ def test_version_validation():
if result == expected:
logger.info(f"✅ Version validation for '{version}': {result}")
else:
logger.error(f"❌ Version validation for '{version}': expected {expected}, got {result}")
logger.error(
f"❌ Version validation for '{version}': expected {expected}, got {result}"
)
all_passed = False
return all_passed
@ -222,7 +236,9 @@ async def compare_dependencies():
deps_latest = data_latest.get("info", {}).get("requires_dist", [])
logger.info(f"Django 4.2.0 dependencies: {len(deps_420) if deps_420 else 0}")
logger.info(f"Django latest dependencies: {len(deps_latest) if deps_latest else 0}")
logger.info(
f"Django latest dependencies: {len(deps_latest) if deps_latest else 0}"
)
# Show some dependencies for comparison
if deps_420:
@ -262,7 +278,9 @@ async def main():
success = False
if success:
logger.info("🎉 All tests passed! The version parameter fix is working correctly.")
logger.info(
"🎉 All tests passed! The version parameter fix is working correctly."
)
logger.info("")
logger.info("Summary of what was fixed:")
logger.info("- PyPIClient now supports version-specific queries")

View File

@ -4,8 +4,8 @@ Test script for the enhanced PyPI download statistics with fallback mechanisms.
"""
import asyncio
import sys
import os
import sys
# Add the package to Python path
sys.path.insert(0, os.path.abspath("."))
@ -38,21 +38,23 @@ async def test_download_stats():
print(f"Data Source: {stats.get('data_source')}")
print(f"Reliability: {stats.get('reliability', 'unknown')}")
if stats.get('warning'):
if stats.get("warning"):
print(f"⚠️ Warning: {stats['warning']}")
downloads = stats.get("downloads", {})
print(f"Downloads - Day: {downloads.get('last_day', 0):,}, " +
f"Week: {downloads.get('last_week', 0):,}, " +
f"Month: {downloads.get('last_month', 0):,}")
print(
f"Downloads - Day: {downloads.get('last_day', 0):,}, "
+ f"Week: {downloads.get('last_week', 0):,}, "
+ f"Month: {downloads.get('last_month', 0):,}"
)
if stats.get('data_quality_note'):
if stats.get("data_quality_note"):
print(f"Note: {stats['data_quality_note']}")
except Exception as e:
print(f"❌ Error: {e}")
print(f"\n📈 Testing download trends for 'requests':")
print("\n📈 Testing download trends for 'requests':")
print("-" * 50)
try:
@ -62,7 +64,7 @@ async def test_download_stats():
print(f"Data Source: {trends.get('data_source')}")
print(f"Reliability: {trends.get('reliability', 'unknown')}")
if trends.get('warning'):
if trends.get("warning"):
print(f"⚠️ Warning: {trends['warning']}")
trend_analysis = trends.get("trend_analysis", {})
@ -70,13 +72,13 @@ async def test_download_stats():
print(f"Total Downloads: {trend_analysis.get('total_downloads', 0):,}")
print(f"Trend Direction: {trend_analysis.get('trend_direction', 'unknown')}")
if trends.get('data_quality_note'):
if trends.get("data_quality_note"):
print(f"Note: {trends['data_quality_note']}")
except Exception as e:
print(f"❌ Error: {e}")
print(f"\n🏆 Testing top packages:")
print("\n🏆 Testing top packages:")
print("-" * 50)
try:
@ -84,9 +86,11 @@ async def test_download_stats():
print(f"Data Source: {top_packages.get('data_source')}")
print(f"Reliability: {top_packages.get('reliability', 'unknown')}")
print(f"Success Rate: {top_packages.get('data_collection_success_rate', 'unknown')}")
print(
f"Success Rate: {top_packages.get('data_collection_success_rate', 'unknown')}"
)
if top_packages.get('warning'):
if top_packages.get("warning"):
print(f"⚠️ Warning: {top_packages['warning']}")
packages_list = top_packages.get("top_packages", [])

View File

@ -2,44 +2,56 @@
"""Test script for the improved get_top_packages_by_downloads function."""
import asyncio
from pypi_query_mcp.tools.download_stats import get_top_packages_by_downloads
async def test_improved():
try:
result = await get_top_packages_by_downloads('month', 10)
print('✅ Success! Result keys:', list(result.keys()))
print(f'Number of packages returned: {len(result.get("top_packages", []))}')
print(f'Data source: {result.get("data_source")}')
print(f'Methodology: {result.get("methodology")}')
result = await get_top_packages_by_downloads("month", 10)
print("✅ Success! Result keys:", list(result.keys()))
print(f"Number of packages returned: {len(result.get('top_packages', []))}")
print(f"Data source: {result.get('data_source')}")
print(f"Methodology: {result.get('methodology')}")
print('\nTop 5 packages:')
for i, pkg in enumerate(result.get('top_packages', [])[:5]):
downloads = pkg.get('downloads', 0)
stars = pkg.get('github_stars', 'N/A')
estimated = '(estimated)' if pkg.get('estimated', False) else '(real)'
github_enhanced = ' 🌟' if pkg.get('github_enhanced', False) else ''
print(f'{i+1}. {pkg.get("package", "N/A")} - {downloads:,} downloads {estimated}{github_enhanced}')
if stars != 'N/A':
print(f' GitHub: {stars:,} stars, {pkg.get("category", "N/A")} category')
print("\nTop 5 packages:")
for i, pkg in enumerate(result.get("top_packages", [])[:5]):
downloads = pkg.get("downloads", 0)
stars = pkg.get("github_stars", "N/A")
estimated = "(estimated)" if pkg.get("estimated", False) else "(real)"
github_enhanced = " 🌟" if pkg.get("github_enhanced", False) else ""
print(
f"{i + 1}. {pkg.get('package', 'N/A')} - {downloads:,} downloads {estimated}{github_enhanced}"
)
if stars != "N/A":
print(
f" GitHub: {stars:,} stars, {pkg.get('category', 'N/A')} category"
)
# Test different periods
print('\n--- Testing different periods ---')
for period in ['day', 'week', 'month']:
print("\n--- Testing different periods ---")
for period in ["day", "week", "month"]:
result = await get_top_packages_by_downloads(period, 3)
top_3 = result.get('top_packages', [])
print(f'{period}: {len(top_3)} packages, avg downloads: {sum(p.get("downloads", 0) for p in top_3) // max(len(top_3), 1):,}')
top_3 = result.get("top_packages", [])
print(
f"{period}: {len(top_3)} packages, avg downloads: {sum(p.get('downloads', 0) for p in top_3) // max(len(top_3), 1):,}"
)
print('\n--- Testing different limits ---')
print("\n--- Testing different limits ---")
for limit in [5, 20, 50]:
result = await get_top_packages_by_downloads('month', limit)
packages = result.get('top_packages', [])
real_count = len([p for p in packages if not p.get('estimated', False)])
print(f'Limit {limit}: {len(packages)} packages returned, {real_count} with real stats')
result = await get_top_packages_by_downloads("month", limit)
packages = result.get("top_packages", [])
real_count = len([p for p in packages if not p.get("estimated", False)])
print(
f"Limit {limit}: {len(packages)} packages returned, {real_count} with real stats"
)
except Exception as e:
print(f'❌ Error: {e}')
print(f"❌ Error: {e}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
if __name__ == "__main__":
asyncio.run(test_improved())

View File

@ -3,14 +3,16 @@
import asyncio
import logging
import sys
import os
import sys
# Add the project root to the Python path
sys.path.insert(0, os.path.dirname(__file__))
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
@ -18,13 +20,16 @@ async def test_our_implementation():
"""Test our actual implementation directly."""
# Import just the core pieces we need
from pypi_query_mcp.core.exceptions import (
InvalidPackageNameError,
PackageNotFoundError,
)
from pypi_query_mcp.core.pypi_client import PyPIClient
from pypi_query_mcp.tools.package_query import (
format_dependency_info,
query_package_dependencies,
validate_version_format,
format_dependency_info
)
from pypi_query_mcp.core.exceptions import PackageNotFoundError, InvalidPackageNameError
logger.info("Testing our actual implementation...")
@ -50,7 +55,9 @@ async def test_our_implementation():
# Verify they're different (unless 4.2 is latest, which is unlikely)
if latest_version not in ["4.2", "4.2.0"]:
logger.info("✅ Confirmed version-specific queries work differently than latest")
logger.info(
"✅ Confirmed version-specific queries work differently than latest"
)
# Test 3: Dependency formatting
logger.info("Testing dependency formatting...")
@ -63,7 +70,9 @@ async def test_our_implementation():
assert "runtime_dependencies" in formatted
assert "dependency_summary" in formatted
assert formatted["version"] in ["4.2", "4.2.0"]
logger.info(f"✅ Dependency formatting works: {len(formatted['runtime_dependencies'])} runtime deps")
logger.info(
f"✅ Dependency formatting works: {len(formatted['runtime_dependencies'])} runtime deps"
)
# Test 4: Full query_package_dependencies function
logger.info("Testing query_package_dependencies function...")
@ -72,16 +81,22 @@ async def test_our_implementation():
result = await query_package_dependencies("django", "4.2.0")
assert result["package_name"].lower() == "django"
assert result["version"] in ["4.2", "4.2.0"]
logger.info(f"✅ Django 4.2.0 dependencies: {len(result['runtime_dependencies'])} runtime deps")
logger.info(
f"✅ Django 4.2.0 dependencies: {len(result['runtime_dependencies'])} runtime deps"
)
# Test with Django latest
result_latest = await query_package_dependencies("django", None)
assert result_latest["package_name"].lower() == "django"
logger.info(f"✅ Django latest dependencies: {len(result_latest['runtime_dependencies'])} runtime deps")
logger.info(
f"✅ Django latest dependencies: {len(result_latest['runtime_dependencies'])} runtime deps"
)
# Verify they might be different
if result["version"] != result_latest["version"]:
logger.info("✅ Confirmed: version-specific query returns different version than latest")
logger.info(
"✅ Confirmed: version-specific query returns different version than latest"
)
# Test 5: Error cases
logger.info("Testing error cases...")
@ -114,7 +129,9 @@ async def test_our_implementation():
result = await query_package_dependencies(package, version)
assert result["package_name"].lower() == package.lower()
assert result["version"] == version
logger.info(f"{package} {version}: {len(result['runtime_dependencies'])} runtime deps")
logger.info(
f"{package} {version}: {len(result['runtime_dependencies'])} runtime deps"
)
except Exception as e:
logger.warning(f"⚠️ {package} {version} failed (may not exist): {e}")
@ -134,6 +151,7 @@ async def main():
except Exception as e:
logger.error(f"❌ Test failed with exception: {e}")
import traceback
traceback.print_exc()
return 1

View File

@ -3,6 +3,7 @@
import asyncio
import logging
from pypi_query_mcp.tools.package_query import query_package_versions
# Configure logging
@ -18,9 +19,9 @@ async def test_real_package_versions():
# Test packages known to have complex version histories
test_packages = [
"django", # Known for alpha, beta, rc versions
"numpy", # Long history with various formats
"requests" # Simple but well-known package
"django", # Known for alpha, beta, rc versions
"numpy", # Long history with various formats
"requests", # Simple but well-known package
]
for package_name in test_packages:
@ -38,12 +39,12 @@ async def test_real_package_versions():
string_sorted = sorted(all_versions[:20], reverse=True)
print(f" String-sorted (first 10): {string_sorted[:10]}")
print(f" Semantic vs String comparison:")
print(" Semantic vs String comparison:")
for i in range(min(5, len(recent_versions))):
semantic = recent_versions[i] if i < len(recent_versions) else "N/A"
string_sort = string_sorted[i] if i < len(string_sorted) else "N/A"
match = "" if semantic == string_sort else ""
print(f" {i+1}: {semantic} vs {string_sort} {match}")
print(f" {i + 1}: {semantic} vs {string_sort} {match}")
except Exception as e:
print(f" Error querying {package_name}: {e}")
@ -89,7 +90,7 @@ async def test_specific_version_ordering():
async def main():
"""Main test function."""
print("Real Package Version Sorting Test")
print("="*60)
print("=" * 60)
# Test with real packages
await test_real_package_versions()

View File

@ -3,21 +3,23 @@
import asyncio
import logging
import sys
import os
import sys
# Add the project root to the Python path
sys.path.insert(0, os.path.dirname(__file__))
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
async def test_pypi_client():
"""Test the PyPIClient with version-specific queries."""
from pypi_query_mcp.core.pypi_client import PyPIClient
from pypi_query_mcp.core.exceptions import PackageNotFoundError
from pypi_query_mcp.core.pypi_client import PyPIClient
async with PyPIClient() as client:
# Test 1: Django 4.2.0 (specific version)
@ -96,8 +98,13 @@ async def test_pypi_client():
async def test_dependency_query():
"""Test the query_package_dependencies function."""
from pypi_query_mcp.tools.package_query import query_package_dependencies, validate_version_format
from pypi_query_mcp.core.exceptions import InvalidPackageNameError, PackageNotFoundError
from pypi_query_mcp.core.exceptions import (
InvalidPackageNameError,
)
from pypi_query_mcp.tools.package_query import (
query_package_dependencies,
validate_version_format,
)
# Test version validation
logger.info("Testing version validation...")
@ -118,7 +125,9 @@ async def test_dependency_query():
if result == expected:
logger.info(f"✅ Version validation for '{version}': {result}")
else:
logger.error(f"❌ Version validation for '{version}': expected {expected}, got {result}")
logger.error(
f"❌ Version validation for '{version}': expected {expected}, got {result}"
)
return False
# Test dependency queries
@ -127,10 +136,17 @@ async def test_dependency_query():
# Test Django 4.2.0 dependencies
try:
result = await query_package_dependencies("django", "4.2.0")
if result["package_name"].lower() == "django" and result["version"] in ["4.2", "4.2.0"]:
logger.info(f"✅ Django 4.2.0 dependencies query passed - {len(result['runtime_dependencies'])} runtime deps")
if result["package_name"].lower() == "django" and result["version"] in [
"4.2",
"4.2.0",
]:
logger.info(
f"✅ Django 4.2.0 dependencies query passed - {len(result['runtime_dependencies'])} runtime deps"
)
else:
logger.error(f"❌ Django dependencies query failed - got {result['package_name']} v{result['version']}")
logger.error(
f"❌ Django dependencies query failed - got {result['package_name']} v{result['version']}"
)
return False
except Exception as e:
logger.error(f"❌ Django dependencies query failed: {e}")

View File

@ -3,6 +3,7 @@
from pypi_query_mcp.core.version_utils import sort_versions_semantically
def test_specific_case():
"""Test the exact case mentioned in the task requirements."""
print("=" * 60)
@ -24,7 +25,7 @@ def test_specific_case():
print()
print("Analysis:")
print(f" Problem: '5.2rc1' was appearing before '5.2.5' in string sorting")
print(" Problem: '5.2rc1' was appearing before '5.2.5' in string sorting")
print(f" String sorting result: {old_sorted[0]} comes first")
print(f" Semantic sorting result: {new_sorted[0]} comes first")
print()
@ -39,8 +40,14 @@ def test_specific_case():
# Test a more comprehensive example
comprehensive_test = [
"5.2.5", "5.2rc1", "5.2.0", "5.2a1", "5.2b1",
"5.1.0", "5.3.0", "5.2.1"
"5.2.5",
"5.2rc1",
"5.2.0",
"5.2a1",
"5.2b1",
"5.1.0",
"5.3.0",
"5.2.1",
]
old_comprehensive = sorted(comprehensive_test, reverse=True)

View File

@ -3,7 +3,7 @@
import asyncio
import sys
import json
from pypi_query_mcp.tools.package_query import query_package_dependencies
@ -12,10 +12,14 @@ async def test_direct_dependencies():
print("Testing direct dependencies for 'requests'...")
try:
result = await query_package_dependencies("requests", include_transitive=False)
print(f"✓ Direct dependencies found: {len(result.get('runtime_dependencies', []))}")
print(
f"✓ Direct dependencies found: {len(result.get('runtime_dependencies', []))}"
)
print(f" Package: {result.get('package_name')}")
print(f" Version: {result.get('version')}")
print(f" Runtime deps: {result.get('runtime_dependencies', [])[:3]}...") # Show first 3
print(
f" Runtime deps: {result.get('runtime_dependencies', [])[:3]}..."
) # Show first 3
return True
except Exception as e:
print(f"✗ Error testing direct dependencies: {e}")
@ -27,44 +31,44 @@ async def test_transitive_dependencies():
print("\nTesting transitive dependencies for 'requests'...")
try:
result = await query_package_dependencies(
"requests",
include_transitive=True,
max_depth=3,
python_version="3.10"
"requests", include_transitive=True, max_depth=3, python_version="3.10"
)
print(f"✓ Transitive analysis completed")
print("✓ Transitive analysis completed")
print(f" Include transitive: {result.get('include_transitive')}")
print(f" Package: {result.get('package_name')}")
print(f" Version: {result.get('version')}")
# Check transitive dependency structure
transitive = result.get('transitive_dependencies', {})
all_packages = transitive.get('all_packages', {})
transitive = result.get("transitive_dependencies", {})
all_packages = transitive.get("all_packages", {})
print(f" Total packages in tree: {len(all_packages)}")
# Check summary
summary = result.get('dependency_summary', {})
summary = result.get("dependency_summary", {})
print(f" Direct runtime deps: {summary.get('direct_runtime_count', 0)}")
print(f" Total transitive packages: {summary.get('total_transitive_packages', 0)}")
print(
f" Total transitive packages: {summary.get('total_transitive_packages', 0)}"
)
print(f" Max depth: {summary.get('max_dependency_depth', 0)}")
# Check analysis
analysis = result.get('analysis', {})
performance = analysis.get('performance_impact', {})
analysis = result.get("analysis", {})
performance = analysis.get("performance_impact", {})
print(f" Performance level: {performance.get('performance_level', 'unknown')}")
complexity = summary.get('complexity_score', {})
complexity = summary.get("complexity_score", {})
print(f" Complexity level: {complexity.get('level', 'unknown')}")
# Check circular dependencies
circular = transitive.get('circular_dependencies', [])
circular = transitive.get("circular_dependencies", [])
print(f" Circular dependencies found: {len(circular)}")
return True
except Exception as e:
print(f"✗ Error testing transitive dependencies: {e}")
import traceback
traceback.print_exc()
return False
@ -74,17 +78,15 @@ async def test_small_package():
print("\nTesting transitive dependencies for 'six' (smaller package)...")
try:
result = await query_package_dependencies(
"six",
include_transitive=True,
max_depth=2
"six", include_transitive=True, max_depth=2
)
transitive = result.get('transitive_dependencies', {})
all_packages = transitive.get('all_packages', {})
print(f"✓ Analysis completed for 'six'")
transitive = result.get("transitive_dependencies", {})
all_packages = transitive.get("all_packages", {})
print("✓ Analysis completed for 'six'")
print(f" Total packages: {len(all_packages)}")
summary = result.get('dependency_summary', {})
summary = result.get("dependency_summary", {})
print(f" Direct runtime deps: {summary.get('direct_runtime_count', 0)}")
return True

View File

@ -3,21 +3,24 @@
import asyncio
import logging
import sys
import os
import sys
# Add the project root to the Python path
sys.path.insert(0, os.path.dirname(__file__))
from pypi_query_mcp.tools.package_query import query_package_dependencies
from pypi_query_mcp.core.exceptions import PackageNotFoundError, InvalidPackageNameError
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
async def test_package_version(package_name: str, version: str = None, expect_error: bool = False):
async def test_package_version(
package_name: str, version: str = None, expect_error: bool = False
):
"""Test a specific package and version combination."""
version_str = f" version {version}" if version else " (latest)"
logger.info(f"Testing {package_name}{version_str}")
@ -26,34 +29,53 @@ async def test_package_version(package_name: str, version: str = None, expect_er
result = await query_package_dependencies(package_name, version)
if expect_error:
logger.error(f"Expected error for {package_name}{version_str}, but got result")
logger.error(
f"Expected error for {package_name}{version_str}, but got result"
)
return False
# Verify the result contains expected fields
required_fields = ["package_name", "version", "runtime_dependencies", "dependency_summary"]
required_fields = [
"package_name",
"version",
"runtime_dependencies",
"dependency_summary",
]
for field in required_fields:
if field not in result:
logger.error(f"Missing field '{field}' in result for {package_name}{version_str}")
logger.error(
f"Missing field '{field}' in result for {package_name}{version_str}"
)
return False
# Check if we got the correct version
actual_version = result.get("version", "")
if version and actual_version != version:
logger.error(f"Expected version {version}, got {actual_version} for {package_name}")
logger.error(
f"Expected version {version}, got {actual_version} for {package_name}"
)
return False
logger.info(f"✅ Success: {package_name}{version_str} - Got version {actual_version}")
logger.info(
f"✅ Success: {package_name}{version_str} - Got version {actual_version}"
)
logger.info(f" Runtime dependencies: {len(result['runtime_dependencies'])}")
logger.info(f" Total dependencies: {result['dependency_summary']['runtime_count']}")
logger.info(
f" Total dependencies: {result['dependency_summary']['runtime_count']}"
)
return True
except Exception as e:
if expect_error:
logger.info(f"✅ Expected error for {package_name}{version_str}: {type(e).__name__}: {e}")
logger.info(
f"✅ Expected error for {package_name}{version_str}: {type(e).__name__}: {e}"
)
return True
else:
logger.error(f"❌ Unexpected error for {package_name}{version_str}: {type(e).__name__}: {e}")
logger.error(
f"❌ Unexpected error for {package_name}{version_str}: {type(e).__name__}: {e}"
)
return False
@ -66,16 +88,13 @@ async def main():
("django", "4.2.0", False),
("fastapi", "0.100.0", False),
("numpy", "1.20.0", False),
# Test latest versions (no version specified)
("requests", None, False),
("click", None, False),
# Test edge cases - should fail
("django", "999.999.999", True), # Non-existent version
("nonexistent-package-12345", None, True), # Non-existent package
("django", "invalid.version.format!", True), # Invalid version format
# Test pre-release versions
("django", "5.0a1", False), # Pre-release (may or may not exist)
]

View File

@ -3,6 +3,7 @@
import asyncio
import logging
from pypi_query_mcp.core.version_utils import sort_versions_semantically
from pypi_query_mcp.tools.package_query import query_package_versions
@ -20,41 +21,61 @@ def test_semantic_version_sorting():
# Test case 1: Basic pre-release ordering
test1_versions = ["5.2rc1", "5.2.5", "5.2.0", "5.2a1", "5.2b1"]
sorted1 = sort_versions_semantically(test1_versions)
print(f"Test 1 - Pre-release ordering:")
print("Test 1 - Pre-release ordering:")
print(f" Input: {test1_versions}")
print(f" Output: {sorted1}")
print(f" Expected: ['5.2.5', '5.2.0', '5.2rc1', '5.2b1', '5.2a1']")
print(" Expected: ['5.2.5', '5.2.0', '5.2rc1', '5.2b1', '5.2a1']")
print()
# Test case 2: Complex Django-like versions
test2_versions = [
"4.2.0", "4.2a1", "4.2b1", "4.2rc1", "4.1.0", "4.1.7",
"4.0.0", "3.2.18", "4.2.1", "4.2.2"
"4.2.0",
"4.2a1",
"4.2b1",
"4.2rc1",
"4.1.0",
"4.1.7",
"4.0.0",
"3.2.18",
"4.2.1",
"4.2.2",
]
sorted2 = sort_versions_semantically(test2_versions)
print(f"Test 2 - Django-like versions:")
print("Test 2 - Django-like versions:")
print(f" Input: {test2_versions}")
print(f" Output: {sorted2}")
print()
# Test case 3: TensorFlow-like versions with dev builds
test3_versions = [
"2.13.0", "2.13.0rc1", "2.13.0rc0", "2.12.0", "2.12.1",
"2.14.0dev20230517", "2.13.0rc2" # This might not parse correctly
"2.13.0",
"2.13.0rc1",
"2.13.0rc0",
"2.12.0",
"2.12.1",
"2.14.0dev20230517",
"2.13.0rc2", # This might not parse correctly
]
sorted3 = sort_versions_semantically(test3_versions)
print(f"Test 3 - TensorFlow-like versions:")
print("Test 3 - TensorFlow-like versions:")
print(f" Input: {test3_versions}")
print(f" Output: {sorted3}")
print()
# Test case 4: Edge cases and malformed versions
test4_versions = [
"1.0.0", "1.0.0.post1", "1.0.0.dev0", "1.0.0a1", "1.0.0b1",
"1.0.0rc1", "1.0.1", "invalid-version", "1.0"
"1.0.0",
"1.0.0.post1",
"1.0.0.dev0",
"1.0.0a1",
"1.0.0b1",
"1.0.0rc1",
"1.0.1",
"invalid-version",
"1.0",
]
sorted4 = sort_versions_semantically(test4_versions)
print(f"Test 4 - Edge cases and malformed versions:")
print("Test 4 - Edge cases and malformed versions:")
print(f" Input: {test4_versions}")
print(f" Output: {sorted4}")
print()
@ -64,7 +85,7 @@ def test_semantic_version_sorting():
test5_single = ["1.0.0"]
sorted5_empty = sort_versions_semantically(test5_empty)
sorted5_single = sort_versions_semantically(test5_single)
print(f"Test 5 - Edge cases:")
print("Test 5 - Edge cases:")
print(f" Empty list: {sorted5_empty}")
print(f" Single item: {sorted5_single}")
print()
@ -78,10 +99,10 @@ async def test_real_package_versions():
# Test packages known to have complex version histories
test_packages = [
"django", # Known for alpha, beta, rc versions
"django", # Known for alpha, beta, rc versions
"tensorflow", # Complex versioning with dev builds
"numpy", # Long history with various formats
"requests" # Simple but well-known package
"numpy", # Long history with various formats
"requests", # Simple but well-known package
]
for package_name in test_packages:
@ -115,7 +136,7 @@ def validate_sorting_correctness():
print("Task requirement validation:")
print(f" Input: {task_example}")
print(f" Output: {sorted_task}")
print(f" Requirement: '5.2rc1' should come after '5.2.5'")
print(" Requirement: '5.2rc1' should come after '5.2.5'")
if sorted_task == ["5.2.5", "5.2rc1"]:
print(" ✅ PASS: Requirement met!")
@ -131,7 +152,7 @@ def validate_sorting_correctness():
print("Pre-release ordering validation:")
print(f" Input: {pre_release_test}")
print(f" Output: {sorted_pre}")
print(f" Expected order: stable > rc > beta > alpha")
print(" Expected order: stable > rc > beta > alpha")
expected_order = ["1.0.0", "1.0.0rc1", "1.0.0b1", "1.0.0a1"]
if sorted_pre == expected_order:
@ -145,7 +166,7 @@ def validate_sorting_correctness():
async def main():
"""Main test function."""
print("Semantic Version Sorting Test Suite")
print("="*60)
print("=" * 60)
# Run unit tests
test_semantic_version_sorting()

View File

@ -2,7 +2,8 @@
"""Standalone test script to verify semantic version sorting functionality."""
import logging
from packaging.version import Version, InvalidVersion
from packaging.version import InvalidVersion, Version
# Configure logging
logging.basicConfig(level=logging.INFO)
@ -77,41 +78,61 @@ def test_semantic_version_sorting():
# Test case 1: Basic pre-release ordering
test1_versions = ["5.2rc1", "5.2.5", "5.2.0", "5.2a1", "5.2b1"]
sorted1 = sort_versions_semantically(test1_versions)
print(f"Test 1 - Pre-release ordering:")
print("Test 1 - Pre-release ordering:")
print(f" Input: {test1_versions}")
print(f" Output: {sorted1}")
print(f" Expected: ['5.2.5', '5.2.0', '5.2rc1', '5.2b1', '5.2a1']")
print(" Expected: ['5.2.5', '5.2.0', '5.2rc1', '5.2b1', '5.2a1']")
print()
# Test case 2: Complex Django-like versions
test2_versions = [
"4.2.0", "4.2a1", "4.2b1", "4.2rc1", "4.1.0", "4.1.7",
"4.0.0", "3.2.18", "4.2.1", "4.2.2"
"4.2.0",
"4.2a1",
"4.2b1",
"4.2rc1",
"4.1.0",
"4.1.7",
"4.0.0",
"3.2.18",
"4.2.1",
"4.2.2",
]
sorted2 = sort_versions_semantically(test2_versions)
print(f"Test 2 - Django-like versions:")
print("Test 2 - Django-like versions:")
print(f" Input: {test2_versions}")
print(f" Output: {sorted2}")
print()
# Test case 3: TensorFlow-like versions with dev builds
test3_versions = [
"2.13.0", "2.13.0rc1", "2.13.0rc0", "2.12.0", "2.12.1",
"2.14.0dev20230517", "2.13.0rc2" # This might not parse correctly
"2.13.0",
"2.13.0rc1",
"2.13.0rc0",
"2.12.0",
"2.12.1",
"2.14.0dev20230517",
"2.13.0rc2", # This might not parse correctly
]
sorted3 = sort_versions_semantically(test3_versions)
print(f"Test 3 - TensorFlow-like versions:")
print("Test 3 - TensorFlow-like versions:")
print(f" Input: {test3_versions}")
print(f" Output: {sorted3}")
print()
# Test case 4: Edge cases and malformed versions
test4_versions = [
"1.0.0", "1.0.0.post1", "1.0.0.dev0", "1.0.0a1", "1.0.0b1",
"1.0.0rc1", "1.0.1", "invalid-version", "1.0"
"1.0.0",
"1.0.0.post1",
"1.0.0.dev0",
"1.0.0a1",
"1.0.0b1",
"1.0.0rc1",
"1.0.1",
"invalid-version",
"1.0",
]
sorted4 = sort_versions_semantically(test4_versions)
print(f"Test 4 - Edge cases and malformed versions:")
print("Test 4 - Edge cases and malformed versions:")
print(f" Input: {test4_versions}")
print(f" Output: {sorted4}")
print()
@ -121,7 +142,7 @@ def test_semantic_version_sorting():
test5_single = ["1.0.0"]
sorted5_empty = sort_versions_semantically(test5_empty)
sorted5_single = sort_versions_semantically(test5_single)
print(f"Test 5 - Edge cases:")
print("Test 5 - Edge cases:")
print(f" Empty list: {sorted5_empty}")
print(f" Single item: {sorted5_single}")
print()
@ -140,7 +161,7 @@ def validate_sorting_correctness():
print("Task requirement validation:")
print(f" Input: {task_example}")
print(f" Output: {sorted_task}")
print(f" Requirement: '5.2rc1' should come after '5.2.5'")
print(" Requirement: '5.2rc1' should come after '5.2.5'")
if sorted_task == ["5.2.5", "5.2rc1"]:
print(" ✅ PASS: Requirement met!")
@ -156,7 +177,7 @@ def validate_sorting_correctness():
print("Pre-release ordering validation:")
print(f" Input: {pre_release_test}")
print(f" Output: {sorted_pre}")
print(f" Expected order: stable > rc > beta > alpha")
print(" Expected order: stable > rc > beta > alpha")
expected_order = ["1.0.0", "1.0.0rc1", "1.0.0b1", "1.0.0a1"]
if sorted_pre == expected_order:
@ -200,7 +221,7 @@ def test_version_comparison_details():
def main():
"""Main test function."""
print("Semantic Version Sorting Test Suite")
print("="*60)
print("=" * 60)
# Run unit tests
test_semantic_version_sorting()

View File

@ -122,7 +122,13 @@ class TestDependencyResolver:
elif package_name.lower() == "pytest":
return mock_pytest_data
else:
return {"info": {"name": package_name, "version": "1.0.0", "requires_dist": []}}
return {
"info": {
"name": package_name,
"version": "1.0.0",
"requires_dist": [],
}
}
mock_client.get_package_info.side_effect = mock_get_package_info
@ -200,13 +206,22 @@ class TestDependencyResolver:
elif package_name.lower() == "coverage":
return mock_coverage_data
else:
return {"info": {"name": package_name, "version": "1.0.0", "requires_dist": []}}
return {
"info": {
"name": package_name,
"version": "1.0.0",
"requires_dist": [],
}
}
mock_client.get_package_info.side_effect = mock_get_package_info
# Test with Python 3.11 - should not include typing-extensions but should include extras
result = await resolver.resolve_dependencies(
"test-package", python_version="3.11", include_extras=["test"], max_depth=2
"test-package",
python_version="3.11",
include_extras=["test"],
max_depth=2,
)
assert result["include_extras"] == ["test"]

View File

@ -194,12 +194,16 @@ class TestDownloadStats:
"forks": 5000,
"updated_at": "2024-01-01T00:00:00Z",
"language": "Python",
"topics": ["http", "requests"]
"topics": ["http", "requests"],
}
with (
patch("pypi_query_mcp.tools.download_stats.PyPIStatsClient") as mock_stats_client,
patch("pypi_query_mcp.tools.download_stats.GitHubAPIClient") as mock_github_client
patch(
"pypi_query_mcp.tools.download_stats.PyPIStatsClient"
) as mock_stats_client,
patch(
"pypi_query_mcp.tools.download_stats.GitHubAPIClient"
) as mock_github_client,
):
# Mock PyPI failure
mock_stats_instance = AsyncMock()
@ -211,12 +215,17 @@ class TestDownloadStats:
mock_github_instance.get_multiple_repo_stats.return_value = {
"psf/requests": mock_github_stats
}
mock_github_client.return_value.__aenter__.return_value = mock_github_instance
mock_github_client.return_value.__aenter__.return_value = (
mock_github_instance
)
result = await get_top_packages_by_downloads("month", 10)
# Find requests package (should be enhanced with GitHub data)
requests_pkg = next((pkg for pkg in result["top_packages"] if pkg["package"] == "requests"), None)
requests_pkg = next(
(pkg for pkg in result["top_packages"] if pkg["package"] == "requests"),
None,
)
if requests_pkg:
assert "github_stars" in requests_pkg
@ -245,9 +254,13 @@ class TestDownloadStats:
# Check that downloads are scaled appropriately for the period
# Day should have much smaller numbers than month
if period == "day":
assert all(pkg["downloads"] < 50_000_000 for pkg in result["top_packages"])
assert all(
pkg["downloads"] < 50_000_000 for pkg in result["top_packages"]
)
elif period == "month":
assert any(pkg["downloads"] > 100_000_000 for pkg in result["top_packages"])
assert any(
pkg["downloads"] > 100_000_000 for pkg in result["top_packages"]
)
def test_analyze_download_stats(self):
"""Test download statistics analysis."""

View File

@ -1,6 +1,5 @@
"""Tests for semantic version sorting functionality."""
import pytest
from pypi_query_mcp.core.version_utils import sort_versions_semantically