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:
Ryan Malloy 2025-08-15 11:53:40 -06:00
parent 146952f404
commit 251ceb4c2d
8 changed files with 885 additions and 3 deletions

View file

@ -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

View file

@ -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", ""),