diff --git a/simple_repository_browser/controller.py b/simple_repository_browser/controller.py index 662c0ce..825eecd 100644 --- a/simple_repository_browser/controller.py +++ b/simple_repository_browser/controller.py @@ -9,9 +9,11 @@ import fastapi from fastapi.responses import StreamingResponse from markupsafe import Markup -from packaging.version import InvalidVersion, Version +from packaging.version import InvalidVersion as InvalidVersionError +from packaging.version import Version from . import errors, model, view +from .short_release_info import InvalidVersion from .static_files import HashedStaticFileHandler, StaticFilesManifest @@ -137,14 +139,14 @@ async def project( recache: bool = False, ) -> str | StreamingResponse: _ = page_section # Handled in javascript. - _version = None + _version: Version | InvalidVersion | None = None if version: try: _version = Version(version) - except InvalidVersion: - raise errors.RequestError( - status_code=404, detail=f"Invalid version {version}." - ) + except InvalidVersionError: + # Version string doesn't conform to PEP 440. + # Try to find it as an InvalidVersion in the releases. + _version = InvalidVersion(version) t = asyncio.create_task( self.model.project_page(project_name, _version, recache) diff --git a/simple_repository_browser/crawler.py b/simple_repository_browser/crawler.py index f0af9aa..5b364be 100644 --- a/simple_repository_browser/crawler.py +++ b/simple_repository_browser/crawler.py @@ -15,7 +15,7 @@ from . import fetch_projects from .fetch_description import PackageInfo, package_info -from .short_release_info import ReleaseInfoModel, ShortReleaseInfo +from .short_release_info import InvalidVersion, ReleaseInfoModel, ShortReleaseInfo class Crawler: @@ -146,8 +146,8 @@ async def run_reindex_periodically(self) -> None: async def fetch_pkg_info( self, prj: model.ProjectDetail, - version: Version, - releases: dict[Version, ShortReleaseInfo], + version: Version | InvalidVersion, + releases: dict[Version | InvalidVersion, ShortReleaseInfo], force_recache: bool, ) -> tuple[model.File, PackageInfo]: key = ("pkg-info", prj.name, str(version)) diff --git a/simple_repository_browser/model.py b/simple_repository_browser/model.py index 0eb5a67..9c56deb 100644 --- a/simple_repository_browser/model.py +++ b/simple_repository_browser/model.py @@ -14,7 +14,7 @@ from . import _search, compatibility_matrix, crawler, errors, fetch_projects from .fetch_description import PackageInfo -from .short_release_info import ReleaseInfoModel, ShortReleaseInfo +from .short_release_info import InvalidVersion, ReleaseInfoModel, ShortReleaseInfo @dataclasses.dataclass(frozen=True) @@ -189,7 +189,7 @@ async def project_query( async def project_page( self, project_name: str, - version: Version | None, + version: Version | InvalidVersion | None, recache: bool, ) -> ProjectPageModel: canonical_name = canonicalize_name(project_name) diff --git a/simple_repository_browser/short_release_info.py b/simple_repository_browser/short_release_info.py index d683c0f..26390b4 100644 --- a/simple_repository_browser/short_release_info.py +++ b/simple_repository_browser/short_release_info.py @@ -1,19 +1,59 @@ import dataclasses from datetime import datetime +import functools import types import typing from packaging.utils import canonicalize_name -from packaging.version import InvalidVersion, Version +from packaging.version import InvalidVersion as InvalidVersionError +from packaging.version import Version from simple_repository import model from simple_repository.packaging import extract_package_version +@functools.total_ordering +class InvalidVersion: + """Represents a version string that doesn't conform to PEP 440.""" + + def __init__(self, version_string: str = "unknown"): + self._version_string = version_string + + def __str__(self): + return self._version_string + + def __repr__(self): + return f"InvalidVersion({self._version_string!r})" + + def __hash__(self): + return hash(("invalid-version", self._version_string)) + + def __eq__(self, other): + return ( + isinstance(other, InvalidVersion) + and self._version_string == other._version_string + ) + + def __lt__(self, other): + # Sort invalid versions to the beginning (before all real versions) + # so they won't be selected as the latest version + if isinstance(other, InvalidVersion): + return self._version_string < other._version_string + return True + + @property + def is_prerelease(self): + return False + + @property + def is_devrelease(self): + return False + + @dataclasses.dataclass(frozen=True) class ShortReleaseInfo: # A short representation of a release. Intended to be lightweight to compute, # such that many ShortReleaseInfo instances can be provided to a view. - version: Version + version: Version | InvalidVersion files: tuple[model.File, ...] release_date: datetime | None labels: typing.Mapping[ @@ -25,30 +65,39 @@ class ReleaseInfoModel: @classmethod def release_infos( cls, project_detail: model.ProjectDetail - ) -> tuple[dict[Version, ShortReleaseInfo], Version]: - files_grouped_by_version: dict[Version, list[model.File]] = {} + ) -> tuple[ + dict[Version | InvalidVersion, ShortReleaseInfo], Version | InvalidVersion + ]: + files_grouped_by_version: dict[Version | InvalidVersion, list[model.File]] = {} if not project_detail.files: raise ValueError("No files for the release") canonical_name = canonicalize_name(project_detail.name) + release: Version | InvalidVersion for file in project_detail.files: + version_str = None try: - release = Version( - version=extract_package_version( - filename=file.filename, - project_name=canonical_name, - ), + version_str = extract_package_version( + filename=file.filename, + project_name=canonical_name, ) - except (ValueError, InvalidVersion): - release = Version("0.0rc0") + release = Version(version=version_str) + except (ValueError, InvalidVersionError): + # Use the extracted version_str if available, otherwise the filename + release = InvalidVersion(version_str or file.filename) files_grouped_by_version.setdefault(release, []).append(file) # Ensure there is a release for each version, even if there is no files for it. + version: Version | InvalidVersion for version_str in project_detail.versions or []: - files_grouped_by_version.setdefault(Version(version_str), []) + try: + version = Version(version_str) + except (ValueError, InvalidVersionError): + version = InvalidVersion(version_str) + files_grouped_by_version.setdefault(version, []) - result: dict[Version, ShortReleaseInfo] = {} + result: dict[Version | InvalidVersion, ShortReleaseInfo] = {} latest_version = cls.compute_latest_version(files_grouped_by_version) @@ -77,7 +126,9 @@ def release_infos( or [] ) - quarantined_files_by_release: dict[Version, list[Quarantinefile]] = {} + quarantined_files_by_release: dict[ + Version | InvalidVersion, list[Quarantinefile] + ] = {} date_format = "%Y-%m-%dT%H:%M:%SZ" for file_info in quarantined_files: @@ -88,12 +139,16 @@ def release_infos( ), "upload_time": datetime.strptime(file_info["upload_time"], date_format), } - release = Version( - extract_package_version( + version_str = None + try: + version_str = extract_package_version( filename=quarantined_file["filename"], project_name=canonical_name, - ), - ) + ) + release = Version(version_str) + except (ValueError, InvalidVersionError): + # Use the extracted version_str if available, otherwise the filename + release = InvalidVersion(version_str or quarantined_file["filename"]) quarantined_files_by_release.setdefault(release, []).append( quarantined_file ) @@ -160,8 +215,8 @@ def release_infos( @classmethod def compute_latest_version( - cls, versions: dict[Version, list[typing.Any]] - ) -> Version: + cls, versions: dict[Version | InvalidVersion, list[typing.Any]] + ) -> Version | InvalidVersion: # Use the pip logic to determine the latest release. First, pick the greatest non-dev version, # and if nothing, fall back to the greatest dev version. If no release is available return None. sorted_versions = sorted( diff --git a/simple_repository_browser/templates/base/project.html b/simple_repository_browser/templates/base/project.html index eb6024c..dd46b5d 100644 --- a/simple_repository_browser/templates/base/project.html +++ b/simple_repository_browser/templates/base/project.html @@ -225,6 +225,13 @@