Source code for absfuyu.version

"""
Absfuyu: Version
----------------
Package versioning module

Version: 5.1.0
Date updated: 10/03/2025 (dd/mm/yyyy)
"""

# Module level
# ---------------------------------------------------------------------------
__all__ = [
    # Options
    "ReleaseOption",
    "ReleaseLevel",
    # Class
    "Version",
    "Bumper",
    "PkgVersion",
]


# Library
# ---------------------------------------------------------------------------
import json
import re
import subprocess
from enum import StrEnum
from typing import Self, TypedDict
from urllib.error import URLError
from urllib.request import Request, urlopen

from absfuyu.core import BaseClass
from absfuyu.logger import logger


# Class
# ---------------------------------------------------------------------------
[docs] class ReleaseOption(StrEnum): """ ``MAJOR``, ``MINOR``, ``PATCH`` """ MAJOR = "major" MINOR = "minor" PATCH = "patch"
[docs] @classmethod def all_option(cls) -> list[str]: """Return a list of release options""" return [cls.MAJOR.value, cls.MINOR.value, cls.PATCH.value]
[docs] class ReleaseLevel(StrEnum): """ ``FINAL``, ``DEV``, ``RC`` """ FINAL = "final" DEV = "dev" RC = "rc" # Release candidate
[docs] @classmethod def all_level(cls) -> list[str]: """Return a list of release levels""" return [cls.FINAL.value, cls.DEV.value, cls.RC.value]
class VersionDictFormat(TypedDict): """ Format for the ``version`` section in ``config`` :param major: Major changes :param minor: Minor changes :param patch: Patches and fixes :param release_level: Release level :param serial: Release serial """ major: int minor: int patch: int release_level: str serial: int
[docs] class Version(BaseClass): """Version""" def __init__( self, major: int | str, minor: int | str, patch: int | str, release_level: str = ReleaseLevel.FINAL, serial: int | str = 0, ) -> None: """ Create ``Version`` instance Parameters ---------- major : int | str Major change minor : int | str Minor change patch : int | str Patch release_level : str, optional Release level: ``final`` | ``rc`` | ``dev``, by default ``ReleaseLevel.FINAL`` serial : int | str, optional Serial for release level ``rc`` | ``dev``, by default ``0`` """ self.major: int = major if isinstance(major, int) else int(major) self.minor: int = minor if isinstance(minor, int) else int(minor) self.patch: int = patch if isinstance(patch, int) else int(patch) self.release_level: str = release_level self.serial: int = serial if isinstance(serial, int) else int(serial) def __str__(self) -> str: return self.version # def __repr__(self) -> str: # cls_name = self.__class__.__name__ # if self.release_level.startswith(ReleaseLevel.FINAL): # return f"{cls_name}(major={self.major}, minor={self.minor}, patch={self.patch})" # else: # return ( # f"{cls_name}(" # f"major={self.major}, minor={self.minor}, patch={self.patch}, " # f"release_level={self.release_level}, serial={self.serial})" # ) def __format__(self, format_spec: str) -> str: """ Change format of an object. Avaiable option: ``full`` Usage ----- >>> print(f"{<object>:<format_spec>}") >>> print(<object>.__format__(<format_spec>)) >>> print(format(<object>, <format_spec>)) Example: -------- >>> test = Version(1, 0, 0) >>> print(f"{test:full}") 1.0.0.final0 """ # Logic if format_spec.lower().startswith("full"): return f"{self.major}.{self.minor}.{self.patch}.{self.release_level}{self.serial}" # Else return self.__str__() @property def version(self) -> str: """ Return version string Example: -------- >>> test = Version(1, 0, 0) >>> test.version 1.0.0 >>> str(test) # test.__str__() 1.0.0 >>> test_serial = Version(1, 0, 0, "dev", 1) >>> test_serial.version 1.0.0.dev1 """ if self.release_level.startswith(ReleaseLevel.FINAL): return f"{self.major}.{self.minor}.{self.patch}" else: return f"{self.major}.{self.minor}.{self.patch}.{self.release_level}{self.serial}"
[docs] @classmethod def from_tuple( cls, iterable: tuple[int, int, int] | tuple[int, int, int, str, int] ) -> Self: """ Convert to ``Version`` from a ``tuple`` Parameters ---------- iterable : tuple[int, int, int] | tuple[int, int, int, str, int] Version tuple in correct format Returns ------- Version Version Raises ------ ValueError Wrong tuple format Example: -------- >>> test = Version.from_tuple((1, 0, 0)) >>> test.version 1.0.0 """ if len(iterable) == 5: return cls( iterable[0], iterable[1], iterable[2], iterable[3], iterable[4] ) # Full elif len(iterable) == 3: return cls(iterable[0], iterable[1], iterable[2]) # major.minor.patch only else: raise ValueError("iterable must have len of 5 or 3")
[docs] @classmethod def from_str(cls, version_string: str) -> Self: """ Convert to ``Version`` from a ``str`` Parameters ---------- version_string : str | Version str in correct format | ``<major>.<minor>.<patch>`` | ``<major>.<minor>.<patch>.<release level><serial>`` Returns ------- Version Version Raises ------ ValueError Wrong version_string format Example: -------- >>> test = Version.from_str("1.0.0") >>> test.version 1.0.0 """ short_ver_pattern = re.compile(r"\b(\d)+\.(\d+)\.(\d+)\b") long_ver_pattern = re.compile(r"\b(\d)+\.(\d+)\.(\d+)\.(dev|rc|final)(\d+)\b") ver = version_string.lower().strip() long_ver = re.search(long_ver_pattern, ver) if long_ver: return cls.from_tuple(long_ver.groups()) # type: ignore short_ver = re.search(short_ver_pattern, ver) if short_ver: return cls.from_tuple(short_ver.groups()) # type: ignore raise ValueError("Wrong version_string format")
[docs] def to_dict(self) -> VersionDictFormat: """ Convert ``Version`` into ``dict`` Returns ------- VersionDictFormat Version dict Example: -------- >>> test = Version(1, 0, 0) >>> test.to_dict() { "major": 1, "minor": 0, "patch": 0, "release_level": "final", "serial": 0 } """ out: VersionDictFormat = { "major": self.major, "minor": self.minor, "patch": self.patch, "release_level": self.release_level, "serial": self.serial, } return out
[docs] class Bumper(Version): """Version bumper""" def _bump_ver(self, release_option: str) -> None: """ Bumping major, minor, patch """ if release_option.startswith(ReleaseOption.MAJOR): self.major += 1 self.minor = 0 self.patch = 0 elif release_option.startswith(ReleaseOption.MINOR): self.minor += 1 self.patch = 0 else: self.patch += 1
[docs] def bump( self, *, option: str = ReleaseOption.PATCH, channel: str = ReleaseLevel.FINAL ) -> None: """ Bump current version (internally) Parameters ---------- option : str Release option (Default: ``"patch"``) channel : str Release channel (Default: ``"final"``) Example: -------- >>> test = Bumper(1, 0, 0) >>> test.version 1.0.0 >>> test.bump() >>> test.version 1.0.1 """ # Check conditions - use default values if fail if option not in ReleaseOption.all_option(): logger.warning(f"Available option: {ReleaseOption.all_option()}") option = ReleaseOption.PATCH if channel not in ReleaseLevel.all_level(): logger.warning(f"Available level: {ReleaseLevel.all_level()}") channel = ReleaseLevel.FINAL logger.debug(f"Target: {option} {channel}") # Bump ver if channel.startswith(ReleaseLevel.FINAL): # Final release level if self.release_level in [ ReleaseLevel.RC, ReleaseLevel.DEV, ]: # current release channel is dev or rc self.release_level = ReleaseLevel.FINAL self.serial = 0 else: self.serial = 0 # final channel does not need serial self._bump_ver(option) elif channel.startswith(ReleaseLevel.RC): # release candidate release level if self.release_level.startswith( ReleaseLevel.DEV ): # current release channel is dev self.release_level = ReleaseLevel.RC self.serial = 0 # reset serial elif channel == self.release_level: # current release channel is rc self.serial += 1 else: # current release channel is final self.release_level = channel self.serial = 0 # reset serial self._bump_ver(option) else: # dev release level if channel == self.release_level: # current release channel is dev self.serial += 1 else: # current release channel is final or rc self.release_level = channel self.serial = 0 self._bump_ver(option)
[docs] class PkgVersion: """ Package Version """ def __init__(self, package_name: str) -> None: self.package_name = package_name # Check for update @staticmethod def _fetch_data_from_server(link: str): """Fetch data from API""" req = Request(link) try: response = urlopen(req) # return response except URLError as e: if hasattr(e, "reason"): logger.error("Failed to reach server.") logger.error("Reason: ", e.reason) elif hasattr(e, "code"): logger.error("The server couldn't fulfill the request.") logger.error("Error code: ", e.code) except Exception: logger.error("Fetch failed!") else: return response.read().decode() def _get_latest_version_legacy(self) -> str: """ Load data from PyPI's RSS -- OLD """ rss = f"https://pypi.org/rss/project/{self.package_name}/releases.xml" xml_file: str = self._fetch_data_from_server(rss) ver = xml_file[ xml_file.find("<item>") : xml_file.find( "</item>" ) # noqa: E203 ] # First item version = ver[ ver.find("<title>") + len("<title>") : ver.find( "</title>" ) # noqa: E203 ] return version def _load_data_from_json(self, json_link: str) -> dict: """ Load data from api then convert to json """ json_file: str = self._fetch_data_from_server(json_link) return json.loads(json_file) # type: ignore def _get_latest_version(self) -> str: """ Get latest version from PyPI's API """ link = f"https://pypi.org/pypi/{self.package_name}/json" ver: str = self._load_data_from_json(link)["info"]["version"] logger.debug(f"Latest: {ver}") return ver def _get_update(self): """ Run pip upgrade command """ cmd = f"pip install -U {self.package_name}".split() return subprocess.run(cmd)
[docs] def check_for_update( self, *, force_update: bool = False, ) -> None: """ Check for latest update :param force_update: Auto update the package when run (Default: ``False``) :type force_update: bool """ try: latest = self._get_latest_version() except Exception: latest = self._get_latest_version_legacy() try: import importlib _pk = importlib.__import__(self.package_name) current: str = _pk.__version__ except Exception: current = "" logger.debug(f"Current: {current} | Lastest: {latest}") if current == latest: print(f"You are using the latest version ({latest})") else: if force_update: print(f"Newer version ({latest}) available. Upgrading...") try: self._get_update() except Exception: print( f""" Unable to perform update. Please update manually with: pip install -U {self.package_name}=={latest} """ ) else: print( f"Newer version ({latest}) available. Upgrade with:\npip install -U {self.package_name}=={latest}" )