Files
leocrm/app/plugins/semver.py
T
Agent Zero 7fbbe420bd
Check Cross-Plugin Imports / check (push) Has been cancelled
fix: comprehensive system audit fixes (55+ issues)
CRITICAL:
- Fix SQL injection in prestart.sh (parameterized query)
- Fix secret key validation (always validate, not just production)
- Fix workspace model partial index bug (func.text -> text)
- Fix HealthResponse schema (add checks field)
- Fix Tenant import in permissions.py (NameError on every auth request)
- Fix README tech stack (React instead of Alpine.js)
- Delete broken test_cross_tenant_security_v2.py
- Add fail-closed RLS migration 0084 (48 tenant tables)

HIGH:
- Add GeneralRateLimitMiddleware for all API routes
- Add file type blocklist for DMS and attachment uploads
- Fix guest auth: Pydantic schema, tenant_slug required, CSRF bypass
- Fix CSRF bypass path matching (in -> endswith)
- Add worker healthcheck in docker-compose.yml
- Add ARQ max_tries=3 for job retries
- Fix 28 bare pass in mail services (-> logger.debug)
- Fix print() -> logger in main.py and ai_assistant
- Fix duplicate email handling (catch IntegrityError -> 409)
- Add session revocation (invalidate_all_user_sessions)
- Add resource limits to all containers
- Fix CORS default (localhost -> production domain)
- Fix SameSite=Lax -> Strict
- Fix Redis password visibility in healthcheck
- Fix npm vulnerabilities (19 -> 9)
- Fix Sidebar OOM (wildcard lucide import -> curated ICON_MAP)

MEDIUM:
- Localize ErrorBoundary to German
- Wire Mail.tsx save/delete filter to API
- Document system_notif plugin (no routes needed)
- Fix datetime.utcnow() -> datetime.now(UTC)
- Pin litellm version (>=1.0,<2.0)
- Move CSRF token from sessionStorage to in-memory
- Fix restore_backup error handling and transaction
- Fix Dms.tsx useEffect cleanup
- Add skip-to-content link for accessibility
- Add selectinload imports to 3 services
- Add .env.example missing variables
- Fix AppShell/TopBar/Sidebar test mocks

NEW TESTS:
- test_guest_auth.py (6 tests)
- test_user_service.py (8 tests)
- test_backup_service.py (5 tests)

NEW SCHEMAS:
- saved_filter, saved_view, user_preference, workspace, entity_policy

Tests: 22/22 PASSED
2026-07-31 00:58:05 +02:00

216 lines
6.5 KiB
Python

"""Semantic version comparison for plugin versions.
Supports parsing, comparison, and compatibility checks for SemVer strings.
Handles pre-release versions (e.g. 1.0.0-alpha.1) per SemVer spec.
Usage::
from app.plugins.semver import SemVer
v1 = SemVer.parse("1.2.3")
v2 = SemVer.parse("1.3.0")
if v1 < v2:
logger.info(f"{v1} is older than {v2}")
if v1.is_breaking_change(v2):
logger.warning("Major version changed — breaking!")
if v2.is_compatible_with(v1):
logger.info("v2 is compatible with v1")
"""
from __future__ import annotations
import re
from dataclasses import dataclass
@dataclass(frozen=True)
class SemVer:
"""A semantic version following semver.org spec.
Attributes:
major: Major version (breaking changes).
minor: Minor version (new features, backward compatible).
patch: Patch version (bug fixes, backward compatible).
prerelease: Optional pre-release string (e.g. "alpha.1", "beta.2").
"""
major: int
minor: int
patch: int
prerelease: str = ""
@classmethod
def parse(cls, version: str) -> SemVer:
"""Parse a SemVer string into a SemVer instance.
Args:
version: Version string like "1.2.3" or "1.2.3-alpha.1".
Returns:
SemVer instance.
Raises:
ValueError: If the version string is not valid SemVer.
"""
if not version:
raise ValueError("Version string is empty")
# Strip leading 'v' if present
version = version.strip().lstrip("v")
match = re.match(
r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$",
version,
)
if not match:
raise ValueError(
f"Invalid semver '{version}': expected MAJOR.MINOR.PATCH[-prerelease]"
)
return cls(
major=int(match.group(1)),
minor=int(match.group(2)),
patch=int(match.group(3)),
prerelease=match.group(4) or "",
)
def __str__(self) -> str:
base = f"{self.major}.{self.minor}.{self.patch}"
if self.prerelease:
return f"{base}-{self.prerelease}"
return base
def __repr__(self) -> str:
return f"SemVer({self!s})"
def __lt__(self, other: SemVer) -> bool:
if not isinstance(other, SemVer):
return NotImplemented
# Compare major.minor.patch
if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch):
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
# Pre-release versions are lower than release versions
if not self.prerelease and other.prerelease:
return False
if self.prerelease and not other.prerelease:
return True
# Both have pre-release — compare lexically
return self._compare_prerelease(self.prerelease, other.prerelease) < 0
def __eq__(self, other: object) -> bool:
if not isinstance(other, SemVer):
return NotImplemented
return (
self.major == other.major
and self.minor == other.minor
and self.patch == other.patch
and self.prerelease == other.prerelease
)
def __le__(self, other: SemVer) -> bool:
return self == other or self < other
def __gt__(self, other: SemVer) -> bool:
if not isinstance(other, SemVer):
return NotImplemented
return not self <= other
def __ge__(self, other: SemVer) -> bool:
return not self < other
def __hash__(self) -> int:
return hash((self.major, self.minor, self.patch, self.prerelease))
# ─── Compatibility checks ───
def is_breaking_change(self, other: SemVer) -> bool:
"""Return True if the major version differs (breaking change)."""
return self.major != other.major
def is_compatible_with(self, min_version: SemVer) -> bool:
"""Return True if this version satisfies the minimum version requirement.
A version is compatible if:
- Same major version and >= min_version, OR
- Higher major version (forward compatible)
"""
if self.major > min_version.major:
return True
if self.major < min_version.major:
return False
# Same major — compare minor.patch
return self >= min_version
def is_upgrade_from(self, old_version: SemVer) -> bool:
"""Return True if this version is newer than old_version."""
return self > old_version
def is_downgrade_from(self, old_version: SemVer) -> bool:
"""Return True if this version is older than old_version."""
return self < old_version
# ─── Internal helpers ───
@staticmethod
def _compare_prerelease(a: str, b: str) -> int:
"""Compare two pre-release strings per SemVer spec.
Numeric identifiers are compared numerically, alphanumeric lexically.
"""
a_parts = a.split(".")
b_parts = b.split(".")
for i in range(min(len(a_parts), len(b_parts))):
ap, bp = a_parts[i], b_parts[i]
a_is_num = ap.isdigit()
b_is_num = bp.isdigit()
if a_is_num and b_is_num:
ai, bi = int(ap), int(bp)
if ai < bi:
return -1
if ai > bi:
return 1
elif a_is_num and not b_is_num:
return -1 # Numeric < alphanumeric
elif not a_is_num and b_is_num:
return 1 # Alphanumeric > numeric
else:
if ap < bp:
return -1
if ap > bp:
return 1
# All compared parts are equal — shorter pre-release is lower
return len(a_parts) - len(b_parts)
def compare_versions(v1: str, v2: str) -> int:
"""Compare two version strings.
Returns:
-1 if v1 < v2
0 if v1 == v2
1 if v1 > v2
"""
sv1 = SemVer.parse(v1)
sv2 = SemVer.parse(v2)
if sv1 < sv2:
return -1
if sv1 > sv2:
return 1
return 0
def is_breaking_change(old: str, new: str) -> bool:
"""Check if upgrading from old to new is a breaking change."""
return SemVer.parse(old).is_breaking_change(SemVer.parse(new))
def is_compatible(current: str, min_required: str) -> bool:
"""Check if current version satisfies the minimum required version."""
return SemVer.parse(current).is_compatible_with(SemVer.parse(min_required))