From d4ffe2e98560227d2712b31edcf5c515302c31db Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 7 Apr 2026 23:54:27 +0300 Subject: [PATCH] refactor: drop packaging dependency, inline version parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only user of 'packaging' was version_check.py — two small functions (normalize_version, is_newer) that just need to parse "1.2.3-alpha.1" and compare PEP 440-style versions. That's well within stdlib reach. - Inline a NamedTuple-based Version with kind/pre_num ordering (dev < alpha < beta < rc < release), same regex-normalized format - Define a local InvalidVersion exception - Remove packaging>=23.0 from pyproject.toml dependencies Why now: the Windows cross-build uses a hard-coded DEPS array in build-dist-windows.sh, which was never updated when 'packaging' was added on March 25. Result: importable from pip-installed dev envs, missing from the portable installer — tray icon appeared but uvicorn died with ModuleNotFoundError: No module named 'packaging'. Removing the dep entirely is cleaner than adding one more hard-coded entry to the Windows DEPS list. Tests (678 passing) and a manual test matrix covering dev/alpha/beta/rc/release ordering all pass. --- server/pyproject.toml | 1 - .../core/update/version_check.py | 93 +++++++++++++++---- 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/server/pyproject.toml b/server/pyproject.toml index 05afd56..c45fcd4 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -26,7 +26,6 @@ dependencies = [ "fastapi>=0.115.0", "uvicorn[standard]>=0.32.0", "httpx>=0.27.2", - "packaging>=23.0", "mss>=9.0.2", "numpy>=2.1.3", "pydantic>=2.9.2", diff --git a/server/src/wled_controller/core/update/version_check.py b/server/src/wled_controller/core/update/version_check.py index 77376b8..7f3439e 100644 --- a/server/src/wled_controller/core/update/version_check.py +++ b/server/src/wled_controller/core/update/version_check.py @@ -1,40 +1,99 @@ """Version comparison utilities. Normalizes Gitea-style tags (v0.3.0-alpha.1) to PEP 440 (0.3.0a1) -so that ``packaging.version.Version`` can compare them correctly. +and compares them using a tuple-based ordering. Deliberately does +not depend on the external ``packaging`` library — it's just one +more dependency to ship in the portable installer for a handful +of simple comparisons we can do with stdlib. """ import logging import re - -from packaging.version import InvalidVersion, Version +from typing import NamedTuple logger = logging.getLogger(__name__) +class InvalidVersion(ValueError): + """Raised when a version string cannot be parsed.""" + + +# Release-kind ordering — smaller = earlier. +# dev < alpha < beta < rc < release +_KIND_DEV = 0 +_KIND_ALPHA = 1 +_KIND_BETA = 2 +_KIND_RC = 3 +_KIND_RELEASE = 4 + + +class Version(NamedTuple): + """Comparable version tuple. Larger tuples are newer versions.""" + + major: int + minor: int + patch: int + kind: int + pre_num: int + + _PRE_MAP = { - "alpha": "a", - "beta": "b", - "rc": "rc", + "alpha": _KIND_ALPHA, + "a": _KIND_ALPHA, + "beta": _KIND_BETA, + "b": _KIND_BETA, + "rc": _KIND_RC, } -_PRE_PATTERN = re.compile( - r"^(\d+\.\d+\.\d+)[-.]?(alpha|beta|rc)[.-]?(\d+)$", re.IGNORECASE +# Matches "1.2.3" optionally followed by a pre-release segment. +# Accepts Gitea-style ("1.2.3-alpha.1") and PEP 440 ("1.2.3a1", "1.2.3.dev0"). +_VERSION_PATTERN = re.compile( + r""" + ^ + (?P\d+)\.(?P\d+)\.(?P\d+) + (?: + [-.]? # optional separator + (?Palpha|beta|rc|a|b|dev) # pre-release label + [.-]? # optional separator + (?P\d+) # pre-release number + )? + $ + """, + re.IGNORECASE | re.VERBOSE, ) def normalize_version(raw: str) -> Version: - """Convert a tag like ``v0.3.0-alpha.1`` to a PEP 440 ``Version``. + """Parse a version string into a comparable ``Version`` tuple. - Raises ``InvalidVersion`` if the string cannot be parsed. + Accepts Gitea-style tags (``v0.3.0-alpha.1``) and PEP 440 + (``0.3.0a1``, ``0.0.0.dev0``). Raises ``InvalidVersion`` if + the string cannot be parsed. """ - cleaned = raw.lstrip("v").strip() - m = _PRE_PATTERN.match(cleaned) - if m: - base, pre_label, pre_num = m.group(1), m.group(2).lower(), m.group(3) - pep_label = _PRE_MAP.get(pre_label, pre_label) - cleaned = f"{base}{pep_label}{pre_num}" - return Version(cleaned) + cleaned = raw.lstrip("vV").strip() + m = _VERSION_PATTERN.match(cleaned) + if not m: + raise InvalidVersion(f"Unparseable version: {raw!r}") + + major = int(m.group("major")) + minor = int(m.group("minor")) + patch = int(m.group("patch")) + + pre_label = m.group("pre_label") + pre_num_str = m.group("pre_num") + + if pre_label is None: + kind = _KIND_RELEASE + pre_num = 0 + else: + label = pre_label.lower() + if label == "dev": + kind = _KIND_DEV + else: + kind = _PRE_MAP[label] + pre_num = int(pre_num_str) if pre_num_str is not None else 0 + + return Version(major, minor, patch, kind, pre_num) def is_newer(candidate: str, current: str) -> bool: