Source code for fastsandpm.versioning.library_version

####################################################################################################
# FastSandPM is a package management and dependency resolution tool for HDL Design and DV projects
# Copyright (C) 2026, Benjamin Davis
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, see
# <https://www.gnu.org/licenses/>.
####################################################################################################
"""Semantic version representation and comparison.

This module provides the LibraryVersion class for parsing, representing, and
comparing semantic versions. Versions follow the semantic versioning specification
with optional pre-release identifiers.

Supported Version Formats:
    - Standard: ``major.minor.patch`` (e.g., "1.2.3")
    - Pre-release with dash: ``major.minor.patch-pre`` (e.g., "1.2.3-alpha")
    - Pre-release with dot: ``major.minor.patch.pre`` (e.g., "1.2.3.rc1")
    - Pre-release abbreviated: ``major.minor.patchpre`` (e.g., "1.2.3b2")

Pre-release Stages (in order):
    - alpha (a): Development/testing phase
    - beta (b): Feature-complete but may have bugs
    - rc (release-candidate): Ready for release

Classes:
    - :py:class:`~PreReleaseStage`: Enum for pre-release stages.
    - :py:class:`~LibraryVersion`: Represents and compares semantic versions.

Example:
    >>> v1 = LibraryVersion("1.2.3")
    >>> v2 = LibraryVersion("1.2.3-alpha")
    >>> v1 > v2  # Release versions are greater than pre-release
    True
"""

from __future__ import annotations

import re
from enum import Enum
from functools import total_ordering
from typing import overload


[docs] class PreReleaseStage(Enum): """Enum representing pre-release stages in semantic versioning. Pre-release stages indicate the maturity level of a version before its official release. They are ordered from least to most mature: ALPHA < BETA < RELEASE_CANDIDATE. Example: >>> stage = PreReleaseStage.from_string("rc") >>> stage <PreReleaseStage.RELEASE_CANDIDATE: 'rc'> """ ALPHA = "alpha" """Early development/testing phase.""" BETA = "beta" """Feature-complete but may contain bugs.""" RELEASE_CANDIDATE = "rc" """Ready for release."""
[docs] @classmethod def from_string(cls, value: str) -> PreReleaseStage | None: """Convert a string to a PreReleaseStage enum. Args: value: A string representing a pre-release stage. Supports full names (alpha, beta, release-candidate) and abbreviations (a, b, rc). Returns: The corresponding PreReleaseStage enum, or None if not recognized. """ value_lower = value.lower() if value_lower in ("a", "alpha"): return cls.ALPHA if value_lower in ("b", "beta"): return cls.BETA if value_lower in ("rc", "release-candidate"): return cls.RELEASE_CANDIDATE return None
[docs] @total_ordering class LibraryVersion: """Represents a semantic version with comparison support. A semantic version consists of major, minor, and patch numbers, with an optional pre-release identifier. Versions can be compared using standard comparison operators. The version format is: major.minor.patch[.pre] or major.minor.patch[-pre] Examples: "1.0.0", "2.3.1", "1.0.0.alpha", "2.0.0-rc1" Attributes: major: The major version number. minor: The minor version number. patch: The patch version number. pre_stage: Optional pre-release stage (ALPHA, BETA, RELEASE_CANDIDATE). pre: Optional pre-release number (e.g., 1 for rc1). Example: >>> v1 = LibraryVersion("1.2.3") >>> v2 = LibraryVersion(major=2, minor=0, patch=0) >>> v1 < v2 True """ @overload def __init__(self, version: str) -> None: """Initialize from a version string. Args: version: A version string in "major.minor.patch" or "major.minor.patch.pre" or "major.minor.patch-pre" format. Raises: ValueError: If the version string format is invalid. """ ... @overload def __init__( self, *, major: int, minor: int, patch: int, ) -> None: """Initialize from individual version components. Args: major: The major version number. minor: The minor version number. patch: The patch version number. """ ... @overload def __init__(self, *, major: int, minor: int, patch: int, pre_stage: PreReleaseStage) -> None: """Initialize from individual version components. Args: major: The major version number. minor: The minor version number. patch: The patch version number. pre_stage: Pre-release stage (ALPHA, BETA, RELEASE_CANDIDATE). """ ... @overload def __init__( self, *, major: int, minor: int, patch: int, pre_stage: PreReleaseStage, pre: int ) -> None: """Initialize from individual version components. Args: major: The major version number. minor: The minor version number. patch: The patch version number. pre_stage: Pre-release stage (ALPHA, BETA, RELEASE_CANDIDATE). pre: Pre-release number (e.g., 1 for rc1). """ def __init__( self, version: str | None = None, *, major: int | None = None, minor: int | None = None, patch: int | None = None, pre_stage: PreReleaseStage | str | None = None, pre: int | None = None, ): # type: ignore[no-untyped-def] """Initialize a LibraryVersion instance. Can be initialized either from a version string or from individual version components (major, minor, patch, and optional pre-release). Raises: ValueError: If the version string format is invalid. """ if version is not None: if ( major is not None or minor is not None or patch is not None or pre_stage is not None or pre is not None ): raise ValueError( "Cannot specify version string along with other version components" ) self.major, self.minor, self.patch, self.pre_stage, self.pre = LibraryVersion.parse( version ) return if major is None or minor is None or patch is None: raise ValueError( "'major', 'minor', and 'patch' must be specified when using version components" ) self.major = major self.minor = minor self.patch = patch if pre_stage is None: if pre is not None: raise ValueError("Cannot specify 'pre' without 'pre_stage'") self.pre_stage = None self.pre = None return if isinstance(pre_stage, str): self.pre_stage = PreReleaseStage.from_string(pre_stage) else: self.pre_stage = pre_stage self.pre = pre def __eq__(self, other: object) -> bool: """Check equality with another LibraryVersion. Args: other: The object to compare against. Returns: True if both versions have identical components, False otherwise. """ if not isinstance(other, LibraryVersion): return False return (self.major, self.minor, self.patch, self.pre_stage, self.pre) == ( other.major, other.minor, other.patch, other.pre_stage, other.pre, ) def __hash__(self) -> int: """Return a hash value for this version. Enables use in sets and as dictionary keys. Returns: An integer hash value. """ return hash((self.major, self.minor, self.patch, self.pre_stage, self.pre)) def _get_pre_for_comparison(self) -> tuple[int, int] | None: """Get a normalized pre-release tuple for comparison. Converts the pre-release stage and number into a comparable tuple that maintains the correct ordering (alpha < beta < rc). Returns: A tuple of ``(stage_order, pre_number)`` where stage_order is 0 for alpha, 1 for beta, 2 for rc. Returns None if this version has no pre-release identifier. """ if self.pre_stage is None: return None # Define ordering for stages (lower number = earlier in release cycle) stage_order_map = { PreReleaseStage.ALPHA: 0, PreReleaseStage.BETA: 1, PreReleaseStage.RELEASE_CANDIDATE: 2, } stage_order = stage_order_map.get(self.pre_stage, 99) pre_num = self.pre if self.pre is not None else 0 return (stage_order, pre_num) def __lt__(self, other: LibraryVersion) -> bool: """Check if this version is less than another. Comparison follows semantic versioning rules: - Major, minor, patch are compared numerically - Pre-release versions are less than release versions (1.0.0.alpha < 1.0.0) - Pre-release identifiers are compared by stage (alpha < beta < rc) then number Args: other: The LibraryVersion to compare against. Returns: True if this version is less than the other, False otherwise. """ # Compare major, minor, patch first base_self = (self.major, self.minor, self.patch) base_other = (other.major, other.minor, other.patch) if base_self != base_other: return base_self < base_other # Same base version - compare pre-release self_pre = self._get_pre_for_comparison() other_pre = other._get_pre_for_comparison() # A version without pre-release is greater than one with pre-release if self_pre is None and other_pre is None: return False # Equal if self_pre is None: return False # Release > pre-release if other_pre is None: return True # Pre-release < release # Both have pre-release - compare by (stage_order, pre_number) return self_pre < other_pre
[docs] @staticmethod def parse(version: str) -> tuple[int, int, int, PreReleaseStage | None, int | None]: """Parse a version string into its components. Args: version: A version string in "major.minor.patch" or "major.minor.patch.pre" or "major.minor.patch-pre" or "major.minor.patchpre" format (e.g., "1.2.3b3"). Returns: A tuple of (major, minor, patch, pre_stage, pre) where pre_stage and pre are None if not specified. Raises: ValueError: If the version string doesn't match expected format, or if major/minor/patch are not valid integers. """ # Pattern to match version strings with optional pre-release # Supports dot, dash, or no separator for pre-release # Pre-release can be: alpha, beta, rc, a, b, release-candidate # Optionally followed by a number (with optional dot separator before number) # No separator only supported for abbreviated forms (a, b, rc) pattern = r""" ^ (\d+)\.(\d+)\.(\d+) # major.minor.patch (?: # optional pre-release group (?: [.\-] # separator (dot or dash) ( # pre-release identifier (with separator) a|alpha| b|beta| rc|release-candidate ) (?:\.)? # optional dot before pre-release number (\d+)? # optional pre-release number ) | (?: # no separator - only abbreviated forms (a|b|rc) # abbreviated pre-release identifier (?:\.)? # optional dot before pre-release number (\d+)? # optional pre-release number ) )? $ """ match = re.match(pattern, version, re.IGNORECASE | re.VERBOSE) if match: major = int(match.group(1)) minor = int(match.group(2)) patch = int(match.group(3)) # Groups 4-5 are for separator case, groups 6-7 for no-separator case pre_stage_str = match.group(4) or match.group(6) pre_num_str = match.group(5) or match.group(7) pre_stage = None pre_num = None if pre_stage_str: pre_stage = PreReleaseStage.from_string(pre_stage_str) if pre_num_str: pre_num = int(pre_num_str) return major, minor, patch, pre_stage, pre_num # Try simple dot-separated format without pre-release values = version.split(".") if len(values) == 3: try: return int(values[0]), int(values[1]), int(values[2]), None, None except ValueError: pass raise ValueError(f"Invalid version: {version}")
def __str__(self) -> str: """Return the string representation of this version. Returns: The version as "major.minor.patch" or "major.minor.patch.pre". """ if self.pre_stage is not None: if self.pre is not None: return f"{self.major}.{self.minor}.{self.patch}.{self.pre_stage.value}{self.pre}" return f"{self.major}.{self.minor}.{self.patch}.{self.pre_stage.value}" return f"{self.major}.{self.minor}.{self.patch}" def __repr__(self) -> str: """Return a detailed string representation for debugging. Returns: A string like "LibraryVersion(1.2.3)" or "LibraryVersion(1.2.3.alpha)". """ return f"LibraryVersion({str(self)})"