fix: implement semantic version sorting
- Add sort_versions_semantically function using packaging library - Fix issue where pre-release versions appeared before stable (5.2rc1 vs 5.2.5) - Handle edge cases: dev, post, invalid versions with graceful fallback - Add comprehensive test suite covering all scenarios - Maintain backward compatibility with existing functionality
This commit is contained in:
parent
146952f404
commit
251ceb4c2d
182
VERSION_SORTING_FIX.md
Normal file
182
VERSION_SORTING_FIX.md
Normal file
@ -0,0 +1,182 @@
|
||||
# Semantic Version Sorting Fix
|
||||
|
||||
## Problem Description
|
||||
|
||||
The `get_package_versions` tool was using basic string sorting for package versions instead of semantic version sorting. This caused incorrect ordering where pre-release versions (like `5.2rc1`) appeared before stable versions (like `5.2.5`) when they should come after.
|
||||
|
||||
### Specific Issue
|
||||
- **Problem**: `"5.2rc1"` was appearing before `"5.2.5"` in version lists
|
||||
- **Root Cause**: Using `sorted(releases.keys(), reverse=True)` performs lexicographic string sorting
|
||||
- **Impact**: Misleading version order in package version queries
|
||||
|
||||
## Solution Implemented
|
||||
|
||||
### 1. Added Semantic Version Sorting Function
|
||||
|
||||
**File**: `/pypi_query_mcp/core/version_utils.py`
|
||||
|
||||
```python
|
||||
def sort_versions_semantically(versions: list[str], reverse: bool = True) -> list[str]:
|
||||
"""Sort package versions using semantic version ordering.
|
||||
|
||||
This function properly sorts versions by parsing them as semantic versions,
|
||||
ensuring that pre-release versions (alpha, beta, rc) are ordered correctly
|
||||
relative to stable releases.
|
||||
"""
|
||||
```
|
||||
|
||||
**Key Features**:
|
||||
- Uses the `packaging.version.Version` class for proper semantic parsing
|
||||
- Handles pre-release versions correctly (alpha < beta < rc < stable)
|
||||
- Gracefully handles invalid versions by falling back to string sorting
|
||||
- Maintains original version strings in output
|
||||
- Comprehensive logging for debugging
|
||||
|
||||
### 2. Updated Package Query Functions
|
||||
|
||||
**File**: `/pypi_query_mcp/tools/package_query.py`
|
||||
|
||||
**Changes Made**:
|
||||
1. **Import**: Added `from ..core.version_utils import sort_versions_semantically`
|
||||
2. **format_version_info()**: Replaced basic sorting with semantic sorting
|
||||
3. **format_package_info()**: Updated available_versions to use semantic sorting
|
||||
|
||||
**Before**:
|
||||
```python
|
||||
# Sort versions (basic sorting, could be improved with proper version parsing)
|
||||
sorted_versions = sorted(releases.keys(), reverse=True)
|
||||
```
|
||||
|
||||
**After**:
|
||||
```python
|
||||
# Sort versions using semantic version ordering
|
||||
sorted_versions = sort_versions_semantically(list(releases.keys()), reverse=True)
|
||||
```
|
||||
|
||||
## Test Results
|
||||
|
||||
### 1. Unit Tests - Semantic Version Sorting
|
||||
|
||||
```
|
||||
Test 1 - Pre-release ordering:
|
||||
Input: ['5.2rc1', '5.2.5', '5.2.0', '5.2a1', '5.2b1']
|
||||
Output: ['5.2.5', '5.2.0', '5.2rc1', '5.2b1', '5.2a1']
|
||||
✅ PASS: Correct pre-release ordering
|
||||
```
|
||||
|
||||
### 2. Task Requirement Validation
|
||||
|
||||
```
|
||||
Task requirement validation:
|
||||
Input: ['5.2rc1', '5.2.5']
|
||||
Output: ['5.2.5', '5.2rc1']
|
||||
Requirement: '5.2rc1' should come after '5.2.5'
|
||||
✅ PASS: Requirement met!
|
||||
```
|
||||
|
||||
### 3. Pre-release Ordering Validation
|
||||
|
||||
```
|
||||
Pre-release ordering validation:
|
||||
Input: ['1.0.0', '1.0.0rc1', '1.0.0b1', '1.0.0a1']
|
||||
Output: ['1.0.0', '1.0.0rc1', '1.0.0b1', '1.0.0a1']
|
||||
Expected order: stable > rc > beta > alpha
|
||||
✅ PASS: Pre-release ordering correct!
|
||||
```
|
||||
|
||||
### 4. Real Package Testing
|
||||
|
||||
**Django** (complex versioning with pre-releases):
|
||||
```
|
||||
Recent versions: ['5.2.5', '5.2.4', '5.2.3', '5.2.2', '5.2.1', '5.2', '5.2rc1', '5.2b1', '5.2a1', '5.1.11']
|
||||
String-sorted: ['5.2rc1', '5.2b1', '5.2a1', '5.2.5', '5.2.4', '5.2.3', '5.2.2', '5.2.1', '5.2', '5.1.9']
|
||||
✅ Semantic sorting correctly places stable versions before pre-releases
|
||||
```
|
||||
|
||||
**NumPy** (simple versioning):
|
||||
```
|
||||
Recent versions: ['2.3.2', '2.3.1', '2.3.0', '2.2.6', '2.2.5', '2.2.4', '2.2.3', '2.2.2', '2.2.1', '2.2.0']
|
||||
✅ Both sorting methods produce identical results (as expected for simple versions)
|
||||
```
|
||||
|
||||
### 5. Edge Cases Testing
|
||||
|
||||
**Complex versions with dev, post, and invalid versions**:
|
||||
```
|
||||
Input: ['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']
|
||||
Output: ['1.0.1', '1.0.0.post1', '1.0.0', '1.0', '1.0.0rc1', '1.0.0b1', '1.0.0a1', '1.0.0.dev0', 'invalid-version']
|
||||
✅ Handles all edge cases correctly
|
||||
```
|
||||
|
||||
### 6. Regression Testing
|
||||
|
||||
```bash
|
||||
poetry run python -m pytest tests/ -v
|
||||
============================= test session starts ==============================
|
||||
64 passed in 9.25s
|
||||
✅ All existing tests continue to pass
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Semantic Version Ordering Rules
|
||||
|
||||
1. **Stable versions** come before **pre-release versions** of the same base version
|
||||
2. **Pre-release ordering**: `alpha < beta < rc < stable`
|
||||
3. **Development versions** (`dev`) come before **alpha versions**
|
||||
4. **Post-release versions** (`post`) come after **stable versions**
|
||||
5. **Invalid versions** are sorted lexicographically and placed after valid versions
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Invalid version strings are gracefully handled
|
||||
- Falls back to string sorting for unparseable versions
|
||||
- Logs warnings for invalid versions (debug level)
|
||||
- Maintains all original version strings in output
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- Minimal performance impact (parsing is fast)
|
||||
- Uses efficient sorting algorithms
|
||||
- Caches parsed versions during single sort operation
|
||||
- No breaking changes to existing API
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
# Run standalone semantic version tests
|
||||
python test_version_sorting_standalone.py
|
||||
|
||||
# Test with real PyPI packages
|
||||
poetry run python test_real_packages.py
|
||||
|
||||
# Test specific task requirement
|
||||
poetry run python test_specific_case.py
|
||||
|
||||
# Run full test suite
|
||||
poetry run python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **`/pypi_query_mcp/core/version_utils.py`**: Added `sort_versions_semantically()` function
|
||||
2. **`/pypi_query_mcp/tools/package_query.py`**: Updated to use semantic version sorting
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Uses existing `packaging` library (already a dependency in `pyproject.toml`)
|
||||
- No new dependencies added
|
||||
- Compatible with Python 3.10+
|
||||
|
||||
## Conclusion
|
||||
|
||||
The semantic version sorting fix successfully resolves the issue where pre-release versions were incorrectly appearing before stable versions. The implementation:
|
||||
|
||||
- ✅ Fixes the specific problem mentioned (`5.2rc1` vs `5.2.5`)
|
||||
- ✅ Handles all pre-release types correctly (alpha, beta, rc)
|
||||
- ✅ Manages edge cases (dev, post, invalid versions)
|
||||
- ✅ Maintains backward compatibility
|
||||
- ✅ Passes all existing tests
|
||||
- ✅ Uses robust, industry-standard version parsing
|
||||
|
||||
The fix provides accurate, intuitive version ordering that matches user expectations and semantic versioning standards.
|
@ -284,3 +284,62 @@ class VersionCompatibility:
|
||||
)
|
||||
|
||||
return recommendations
|
||||
|
||||
|
||||
def sort_versions_semantically(versions: list[str], reverse: bool = True) -> list[str]:
|
||||
"""Sort package versions using semantic version ordering.
|
||||
|
||||
This function properly sorts versions by parsing them as semantic versions,
|
||||
ensuring that pre-release versions (alpha, beta, rc) are ordered correctly
|
||||
relative to stable releases.
|
||||
|
||||
Args:
|
||||
versions: List of version strings to sort
|
||||
reverse: If True, sort in descending order (newest first). Default True.
|
||||
|
||||
Returns:
|
||||
List of version strings sorted semantically
|
||||
|
||||
Examples:
|
||||
>>> sort_versions_semantically(['1.0.0', '2.0.0a1', '1.5.0', '2.0.0'])
|
||||
['2.0.0', '2.0.0a1', '1.5.0', '1.0.0']
|
||||
|
||||
>>> sort_versions_semantically(['5.2rc1', '5.2.5', '5.2.0'])
|
||||
['5.2.5', '5.2.0', '5.2rc1']
|
||||
"""
|
||||
if not versions:
|
||||
return []
|
||||
|
||||
def parse_version_safe(version_str: str) -> tuple[Version | None, str]:
|
||||
"""Safely parse a version string, returning (parsed_version, original_string).
|
||||
|
||||
Returns (None, original_string) if parsing fails.
|
||||
"""
|
||||
try:
|
||||
return (Version(version_str), version_str)
|
||||
except InvalidVersion:
|
||||
logger.debug(f"Failed to parse version '{version_str}' as semantic version")
|
||||
return (None, version_str)
|
||||
|
||||
# Parse all versions, keeping track of originals
|
||||
parsed_versions = [parse_version_safe(v) for v in versions]
|
||||
|
||||
# Separate valid and invalid versions
|
||||
valid_versions = [(v, orig) for v, orig in parsed_versions if v is not None]
|
||||
invalid_versions = [orig for v, orig in parsed_versions if v is None]
|
||||
|
||||
# Sort valid versions semantically
|
||||
valid_versions.sort(key=lambda x: x[0], reverse=reverse)
|
||||
|
||||
# Sort invalid versions lexicographically as fallback
|
||||
invalid_versions.sort(reverse=reverse)
|
||||
|
||||
# Combine results: valid versions first, then invalid ones
|
||||
result = [orig for _, orig in valid_versions] + invalid_versions
|
||||
|
||||
logger.debug(
|
||||
f"Sorted {len(versions)} versions: {len(valid_versions)} valid, "
|
||||
f"{len(invalid_versions)} invalid"
|
||||
)
|
||||
|
||||
return result
|
||||
|
@ -4,6 +4,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from ..core import InvalidPackageNameError, NetworkError, PyPIClient, PyPIError
|
||||
from ..core.version_utils import sort_versions_semantically
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -46,7 +47,12 @@ def format_package_info(package_data: dict[str, Any]) -> dict[str, Any]:
|
||||
# Add release information
|
||||
releases = package_data.get("releases", {})
|
||||
formatted["total_versions"] = len(releases)
|
||||
formatted["available_versions"] = list(releases.keys())[-10:] # Last 10 versions
|
||||
# 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
|
||||
else:
|
||||
formatted["available_versions"] = []
|
||||
|
||||
# Add download statistics if available
|
||||
if "urls" in package_data:
|
||||
@ -79,8 +85,8 @@ def format_version_info(package_data: dict[str, Any]) -> dict[str, Any]:
|
||||
info = package_data.get("info", {})
|
||||
releases = package_data.get("releases", {})
|
||||
|
||||
# Sort versions (basic sorting, could be improved with proper version parsing)
|
||||
sorted_versions = sorted(releases.keys(), reverse=True)
|
||||
# Sort versions using semantic version ordering
|
||||
sorted_versions = sort_versions_semantically(list(releases.keys()), reverse=True)
|
||||
|
||||
return {
|
||||
"package_name": info.get("name", ""),
|
||||
|
105
test_real_packages.py
Normal file
105
test_real_packages.py
Normal file
@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script to verify semantic version sorting with real PyPI packages."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pypi_query_mcp.tools.package_query import query_package_versions
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def test_real_package_versions():
|
||||
"""Test with real PyPI packages that have complex version histories."""
|
||||
print("=" * 60)
|
||||
print("Testing Real Package Version Sorting")
|
||||
print("=" * 60)
|
||||
|
||||
# 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
|
||||
]
|
||||
|
||||
for package_name in test_packages:
|
||||
try:
|
||||
print(f"\nTesting {package_name}:")
|
||||
result = await query_package_versions(package_name)
|
||||
|
||||
recent_versions = result.get("recent_versions", [])[:10]
|
||||
print(f" Recent versions (first 10): {recent_versions}")
|
||||
|
||||
# Show older-style string sorting for comparison
|
||||
all_versions = result.get("versions", [])
|
||||
if all_versions:
|
||||
# Use basic string sorting (the old way)
|
||||
string_sorted = sorted(all_versions[:20], reverse=True)
|
||||
print(f" String-sorted (first 10): {string_sorted[:10]}")
|
||||
|
||||
print(f" 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}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error querying {package_name}: {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
async def test_specific_version_ordering():
|
||||
"""Test specific version ordering scenarios."""
|
||||
print("=" * 60)
|
||||
print("Specific Version Ordering Tests")
|
||||
print("=" * 60)
|
||||
|
||||
# Let's test django which is known to have alpha, beta, rc versions
|
||||
try:
|
||||
print("Testing Django version ordering:")
|
||||
result = await query_package_versions("django")
|
||||
|
||||
all_versions = result.get("versions", [])
|
||||
|
||||
# Find versions around a specific release to verify ordering
|
||||
django_4_versions = [v for v in all_versions if v.startswith("4.2")][:15]
|
||||
print(f" Django 4.2.x versions: {django_4_versions}")
|
||||
|
||||
# Check if pre-release versions are properly ordered
|
||||
pre_release_pattern = ["4.2a1", "4.2b1", "4.2rc1", "4.2.0"]
|
||||
found_versions = [v for v in django_4_versions if v in pre_release_pattern]
|
||||
print(f" Found pre-release sequence: {found_versions}")
|
||||
|
||||
if len(found_versions) > 1:
|
||||
print(" Checking pre-release ordering:")
|
||||
for i in range(len(found_versions) - 1):
|
||||
current = found_versions[i]
|
||||
next_ver = found_versions[i + 1]
|
||||
print(f" {current} comes before {next_ver}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error testing Django versions: {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main test function."""
|
||||
print("Real Package Version Sorting Test")
|
||||
print("="*60)
|
||||
|
||||
# Test with real packages
|
||||
await test_real_package_versions()
|
||||
|
||||
# Test specific version ordering scenarios
|
||||
await test_specific_version_ordering()
|
||||
|
||||
print("=" * 60)
|
||||
print("Real package test completed!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
60
test_specific_case.py
Normal file
60
test_specific_case.py
Normal file
@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test the specific case mentioned in the task: 5.2rc1 vs 5.2.5"""
|
||||
|
||||
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)
|
||||
print("Testing Specific Task Requirement")
|
||||
print("=" * 60)
|
||||
|
||||
# The exact problem mentioned in the task
|
||||
versions = ["5.2rc1", "5.2.5"]
|
||||
|
||||
# Old way (string sorting)
|
||||
old_sorted = sorted(versions, reverse=True)
|
||||
|
||||
# New way (semantic sorting)
|
||||
new_sorted = sort_versions_semantically(versions, reverse=True)
|
||||
|
||||
print(f"Original versions: {versions}")
|
||||
print(f"Old string sorting: {old_sorted}")
|
||||
print(f"New semantic sorting: {new_sorted}")
|
||||
print()
|
||||
|
||||
print("Analysis:")
|
||||
print(f" 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()
|
||||
|
||||
if new_sorted == ["5.2.5", "5.2rc1"]:
|
||||
print(" ✅ SUCCESS: Semantic sorting correctly places 5.2.5 before 5.2rc1")
|
||||
print(" ✅ This fixes the issue described in the task!")
|
||||
else:
|
||||
print(" ❌ FAILED: The issue is not resolved")
|
||||
|
||||
print()
|
||||
|
||||
# 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"
|
||||
]
|
||||
|
||||
old_comprehensive = sorted(comprehensive_test, reverse=True)
|
||||
new_comprehensive = sort_versions_semantically(comprehensive_test, reverse=True)
|
||||
|
||||
print("Comprehensive version sorting test:")
|
||||
print(f" Input: {comprehensive_test}")
|
||||
print(f" String sorted: {old_comprehensive}")
|
||||
print(f" Semantic sorted: {new_comprehensive}")
|
||||
print()
|
||||
|
||||
print("Expected semantic order (newest to oldest):")
|
||||
print(" 5.3.0 > 5.2.5 > 5.2.1 > 5.2.0 > 5.2rc1 > 5.2b1 > 5.2a1 > 5.1.0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_specific_case()
|
164
test_version_sorting.py
Normal file
164
test_version_sorting.py
Normal file
@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script to verify semantic version sorting functionality."""
|
||||
|
||||
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
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def test_semantic_version_sorting():
|
||||
"""Test the semantic version sorting function with various edge cases."""
|
||||
print("=" * 60)
|
||||
print("Testing Semantic Version Sorting Function")
|
||||
print("=" * 60)
|
||||
|
||||
# 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(f" Input: {test1_versions}")
|
||||
print(f" Output: {sorted1}")
|
||||
print(f" 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"
|
||||
]
|
||||
sorted2 = sort_versions_semantically(test2_versions)
|
||||
print(f"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
|
||||
]
|
||||
sorted3 = sort_versions_semantically(test3_versions)
|
||||
print(f"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"
|
||||
]
|
||||
sorted4 = sort_versions_semantically(test4_versions)
|
||||
print(f"Test 4 - Edge cases and malformed versions:")
|
||||
print(f" Input: {test4_versions}")
|
||||
print(f" Output: {sorted4}")
|
||||
print()
|
||||
|
||||
# Test case 5: Empty and single item lists
|
||||
test5_empty = []
|
||||
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(f" Empty list: {sorted5_empty}")
|
||||
print(f" Single item: {sorted5_single}")
|
||||
print()
|
||||
|
||||
|
||||
async def test_real_package_versions():
|
||||
"""Test with real PyPI packages that have complex version histories."""
|
||||
print("=" * 60)
|
||||
print("Testing Real Package Version Sorting")
|
||||
print("=" * 60)
|
||||
|
||||
# Test packages known to have complex version histories
|
||||
test_packages = [
|
||||
"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
|
||||
]
|
||||
|
||||
for package_name in test_packages:
|
||||
try:
|
||||
print(f"\nTesting {package_name}:")
|
||||
result = await query_package_versions(package_name)
|
||||
|
||||
recent_versions = result.get("recent_versions", [])[:10]
|
||||
print(f" Recent versions (first 10): {recent_versions}")
|
||||
|
||||
# Check if versions seem to be properly sorted
|
||||
if len(recent_versions) >= 3:
|
||||
print(f" First three versions: {recent_versions[:3]}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error querying {package_name}: {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def validate_sorting_correctness():
|
||||
"""Validate that our sorting meets the requirements."""
|
||||
print("=" * 60)
|
||||
print("Validation Tests")
|
||||
print("=" * 60)
|
||||
|
||||
# The specific example from the task: "5.2rc1" should come after "5.2.5"
|
||||
task_example = ["5.2rc1", "5.2.5"]
|
||||
sorted_task = sort_versions_semantically(task_example)
|
||||
|
||||
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'")
|
||||
|
||||
if sorted_task == ["5.2.5", "5.2rc1"]:
|
||||
print(" ✅ PASS: Requirement met!")
|
||||
else:
|
||||
print(" ❌ FAIL: Requirement not met!")
|
||||
|
||||
print()
|
||||
|
||||
# Test pre-release ordering: alpha < beta < rc < stable
|
||||
pre_release_test = ["1.0.0", "1.0.0rc1", "1.0.0b1", "1.0.0a1"]
|
||||
sorted_pre = sort_versions_semantically(pre_release_test)
|
||||
|
||||
print("Pre-release ordering validation:")
|
||||
print(f" Input: {pre_release_test}")
|
||||
print(f" Output: {sorted_pre}")
|
||||
print(f" 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:
|
||||
print(" ✅ PASS: Pre-release ordering correct!")
|
||||
else:
|
||||
print(" ❌ FAIL: Pre-release ordering incorrect!")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main test function."""
|
||||
print("Semantic Version Sorting Test Suite")
|
||||
print("="*60)
|
||||
|
||||
# Run unit tests
|
||||
test_semantic_version_sorting()
|
||||
|
||||
# Validate specific requirements
|
||||
validate_sorting_correctness()
|
||||
|
||||
# Test with real packages
|
||||
await test_real_package_versions()
|
||||
|
||||
print("=" * 60)
|
||||
print("Test suite completed!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
219
test_version_sorting_standalone.py
Normal file
219
test_version_sorting_standalone.py
Normal file
@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone test script to verify semantic version sorting functionality."""
|
||||
|
||||
import logging
|
||||
from packaging.version import Version, InvalidVersion
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sort_versions_semantically(versions: list[str], reverse: bool = True) -> list[str]:
|
||||
"""Sort package versions using semantic version ordering.
|
||||
|
||||
This function properly sorts versions by parsing them as semantic versions,
|
||||
ensuring that pre-release versions (alpha, beta, rc) are ordered correctly
|
||||
relative to stable releases.
|
||||
|
||||
Args:
|
||||
versions: List of version strings to sort
|
||||
reverse: If True, sort in descending order (newest first). Default True.
|
||||
|
||||
Returns:
|
||||
List of version strings sorted semantically
|
||||
|
||||
Examples:
|
||||
>>> sort_versions_semantically(['1.0.0', '2.0.0a1', '1.5.0', '2.0.0'])
|
||||
['2.0.0', '2.0.0a1', '1.5.0', '1.0.0']
|
||||
|
||||
>>> sort_versions_semantically(['5.2rc1', '5.2.5', '5.2.0'])
|
||||
['5.2.5', '5.2.0', '5.2rc1']
|
||||
"""
|
||||
if not versions:
|
||||
return []
|
||||
|
||||
def parse_version_safe(version_str: str) -> tuple[Version | None, str]:
|
||||
"""Safely parse a version string, returning (parsed_version, original_string).
|
||||
|
||||
Returns (None, original_string) if parsing fails.
|
||||
"""
|
||||
try:
|
||||
return (Version(version_str), version_str)
|
||||
except InvalidVersion:
|
||||
logger.debug(f"Failed to parse version '{version_str}' as semantic version")
|
||||
return (None, version_str)
|
||||
|
||||
# Parse all versions, keeping track of originals
|
||||
parsed_versions = [parse_version_safe(v) for v in versions]
|
||||
|
||||
# Separate valid and invalid versions
|
||||
valid_versions = [(v, orig) for v, orig in parsed_versions if v is not None]
|
||||
invalid_versions = [orig for v, orig in parsed_versions if v is None]
|
||||
|
||||
# Sort valid versions semantically
|
||||
valid_versions.sort(key=lambda x: x[0], reverse=reverse)
|
||||
|
||||
# Sort invalid versions lexicographically as fallback
|
||||
invalid_versions.sort(reverse=reverse)
|
||||
|
||||
# Combine results: valid versions first, then invalid ones
|
||||
result = [orig for _, orig in valid_versions] + invalid_versions
|
||||
|
||||
logger.debug(
|
||||
f"Sorted {len(versions)} versions: {len(valid_versions)} valid, "
|
||||
f"{len(invalid_versions)} invalid"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def test_semantic_version_sorting():
|
||||
"""Test the semantic version sorting function with various edge cases."""
|
||||
print("=" * 60)
|
||||
print("Testing Semantic Version Sorting Function")
|
||||
print("=" * 60)
|
||||
|
||||
# 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(f" Input: {test1_versions}")
|
||||
print(f" Output: {sorted1}")
|
||||
print(f" 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"
|
||||
]
|
||||
sorted2 = sort_versions_semantically(test2_versions)
|
||||
print(f"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
|
||||
]
|
||||
sorted3 = sort_versions_semantically(test3_versions)
|
||||
print(f"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"
|
||||
]
|
||||
sorted4 = sort_versions_semantically(test4_versions)
|
||||
print(f"Test 4 - Edge cases and malformed versions:")
|
||||
print(f" Input: {test4_versions}")
|
||||
print(f" Output: {sorted4}")
|
||||
print()
|
||||
|
||||
# Test case 5: Empty and single item lists
|
||||
test5_empty = []
|
||||
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(f" Empty list: {sorted5_empty}")
|
||||
print(f" Single item: {sorted5_single}")
|
||||
print()
|
||||
|
||||
|
||||
def validate_sorting_correctness():
|
||||
"""Validate that our sorting meets the requirements."""
|
||||
print("=" * 60)
|
||||
print("Validation Tests")
|
||||
print("=" * 60)
|
||||
|
||||
# The specific example from the task: "5.2rc1" should come after "5.2.5"
|
||||
task_example = ["5.2rc1", "5.2.5"]
|
||||
sorted_task = sort_versions_semantically(task_example)
|
||||
|
||||
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'")
|
||||
|
||||
if sorted_task == ["5.2.5", "5.2rc1"]:
|
||||
print(" ✅ PASS: Requirement met!")
|
||||
else:
|
||||
print(" ❌ FAIL: Requirement not met!")
|
||||
|
||||
print()
|
||||
|
||||
# Test pre-release ordering: alpha < beta < rc < stable
|
||||
pre_release_test = ["1.0.0", "1.0.0rc1", "1.0.0b1", "1.0.0a1"]
|
||||
sorted_pre = sort_versions_semantically(pre_release_test)
|
||||
|
||||
print("Pre-release ordering validation:")
|
||||
print(f" Input: {pre_release_test}")
|
||||
print(f" Output: {sorted_pre}")
|
||||
print(f" 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:
|
||||
print(" ✅ PASS: Pre-release ordering correct!")
|
||||
else:
|
||||
print(" ❌ FAIL: Pre-release ordering incorrect!")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def test_version_comparison_details():
|
||||
"""Test detailed version comparison to understand packaging behavior."""
|
||||
print("=" * 60)
|
||||
print("Version Comparison Details")
|
||||
print("=" * 60)
|
||||
|
||||
test_versions = [
|
||||
("1.0.0", "1.0.0a1"),
|
||||
("1.0.0", "1.0.0b1"),
|
||||
("1.0.0", "1.0.0rc1"),
|
||||
("1.0.0rc1", "1.0.0b1"),
|
||||
("1.0.0b1", "1.0.0a1"),
|
||||
("5.2.5", "5.2rc1"),
|
||||
("5.2.0", "5.2rc1"),
|
||||
("1.0.0.post1", "1.0.0"),
|
||||
("1.0.0.dev0", "1.0.0"),
|
||||
]
|
||||
|
||||
for v1, v2 in test_versions:
|
||||
try:
|
||||
ver1 = Version(v1)
|
||||
ver2 = Version(v2)
|
||||
comparison = ">" if ver1 > ver2 else "<" if ver1 < ver2 else "="
|
||||
print(f" {v1} {comparison} {v2}")
|
||||
except Exception as e:
|
||||
print(f" Error comparing {v1} and {v2}: {e}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main test function."""
|
||||
print("Semantic Version Sorting Test Suite")
|
||||
print("="*60)
|
||||
|
||||
# Run unit tests
|
||||
test_semantic_version_sorting()
|
||||
|
||||
# Validate specific requirements
|
||||
validate_sorting_correctness()
|
||||
|
||||
# Show detailed version comparisons
|
||||
test_version_comparison_details()
|
||||
|
||||
print("=" * 60)
|
||||
print("Test suite completed!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
87
tests/test_version_sorting.py
Normal file
87
tests/test_version_sorting.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Tests for semantic version sorting functionality."""
|
||||
|
||||
import pytest
|
||||
from pypi_query_mcp.core.version_utils import sort_versions_semantically
|
||||
|
||||
|
||||
class TestSemanticVersionSorting:
|
||||
"""Test semantic version sorting function."""
|
||||
|
||||
def test_basic_version_sorting(self):
|
||||
"""Test basic version sorting with stable versions."""
|
||||
versions = ["1.0.0", "2.0.0", "1.5.0", "1.0.1"]
|
||||
expected = ["2.0.0", "1.5.0", "1.0.1", "1.0.0"]
|
||||
result = sort_versions_semantically(versions, reverse=True)
|
||||
assert result == expected
|
||||
|
||||
def test_pre_release_ordering(self):
|
||||
"""Test that pre-release versions are ordered correctly."""
|
||||
versions = ["1.0.0", "1.0.0rc1", "1.0.0b1", "1.0.0a1"]
|
||||
expected = ["1.0.0", "1.0.0rc1", "1.0.0b1", "1.0.0a1"]
|
||||
result = sort_versions_semantically(versions, reverse=True)
|
||||
assert result == expected
|
||||
|
||||
def test_task_requirement(self):
|
||||
"""Test the specific requirement from the task: 5.2rc1 vs 5.2.5."""
|
||||
versions = ["5.2rc1", "5.2.5"]
|
||||
expected = ["5.2.5", "5.2rc1"]
|
||||
result = sort_versions_semantically(versions, reverse=True)
|
||||
assert result == expected
|
||||
|
||||
def test_complex_pre_release_scenario(self):
|
||||
"""Test complex pre-release scenario with multiple types."""
|
||||
versions = ["5.2rc1", "5.2.5", "5.2.0", "5.2a1", "5.2b1"]
|
||||
expected = ["5.2.5", "5.2.0", "5.2rc1", "5.2b1", "5.2a1"]
|
||||
result = sort_versions_semantically(versions, reverse=True)
|
||||
assert result == expected
|
||||
|
||||
def test_dev_and_post_versions(self):
|
||||
"""Test development and post-release versions."""
|
||||
versions = ["1.0.0", "1.0.0.post1", "1.0.0.dev0", "1.0.1"]
|
||||
result = sort_versions_semantically(versions, reverse=True)
|
||||
|
||||
# 1.0.1 should be first, then 1.0.0.post1, then 1.0.0, then 1.0.0.dev0
|
||||
assert result[0] == "1.0.1"
|
||||
assert result[1] == "1.0.0.post1"
|
||||
assert result[2] == "1.0.0"
|
||||
assert result[3] == "1.0.0.dev0"
|
||||
|
||||
def test_invalid_versions_fallback(self):
|
||||
"""Test that invalid versions fall back to string sorting."""
|
||||
versions = ["1.0.0", "invalid-version", "another-invalid", "2.0.0"]
|
||||
result = sort_versions_semantically(versions, reverse=True)
|
||||
|
||||
# Valid versions should come first
|
||||
assert result[0] == "2.0.0"
|
||||
assert result[1] == "1.0.0"
|
||||
# Invalid versions should be at the end, string-sorted
|
||||
assert "invalid-version" in result[2:]
|
||||
assert "another-invalid" in result[2:]
|
||||
|
||||
def test_empty_list(self):
|
||||
"""Test that empty list returns empty list."""
|
||||
result = sort_versions_semantically([])
|
||||
assert result == []
|
||||
|
||||
def test_single_version(self):
|
||||
"""Test that single version returns single version."""
|
||||
result = sort_versions_semantically(["1.0.0"])
|
||||
assert result == ["1.0.0"]
|
||||
|
||||
def test_ascending_order(self):
|
||||
"""Test sorting in ascending order."""
|
||||
versions = ["2.0.0", "1.0.0", "1.5.0"]
|
||||
expected = ["1.0.0", "1.5.0", "2.0.0"]
|
||||
result = sort_versions_semantically(versions, reverse=False)
|
||||
assert result == expected
|
||||
|
||||
def test_mixed_version_formats(self):
|
||||
"""Test sorting with mixed version formats."""
|
||||
versions = ["1.0", "1.0.0", "1.0.1", "v1.0.2"] # v1.0.2 might be invalid
|
||||
result = sort_versions_semantically(versions, reverse=True)
|
||||
|
||||
# Should handle mixed formats gracefully
|
||||
assert len(result) == 4
|
||||
assert "1.0.1" in result
|
||||
assert "1.0.0" in result
|
||||
assert "1.0" in result
|
Loading…
x
Reference in New Issue
Block a user