Files
leocrm/app/plugins/semver.py
T
Agent Zero 98eb1d0d89
Check Cross-Plugin Imports / check (push) Has been cancelled
feat: Plugin-System Umbau — 6 Phasen komplett abgeschlossen
Phase 1: Contracts konsequent nutzen
- 12 neue contracts.py erstellt (alle 19 Plugins haben jetzt contracts)
- 4 bestehende contracts.py an zentrale ContractRegistry angepasst
- Alle 19 Plugins haben on_deactivate mit Contract-Unregister
- 0 echte problematische INTER-Plugin Imports

Phase 2: Hooks/Filters-System
- app/core/hooks.py (HookRegistry mit actions + filters)
- 15 Hook-Punkte in Core-Services (contact, auth, mail, calendar, user, dms)
- BasePlugin.on_deactivate meldet alle Hooks ab

Phase 3: Plugin-Isolation
- scripts/check_cross_plugin_imports.py (Linting-Regel)
- .github/workflows/check-cross-plugin-imports.yml (CI/CD)
- .pre-commit-cross-plugin.yaml (Pre-commit Hook)
- 155 Dateien geprueft, 0 Verstoesse

Phase 4: Plugin-Versioning
- app/plugins/semver.py (SemVer mit Parse, Compare, Pre-release)
- migration_runner.py erweitert: run_migration_down, rollback_to_version
- manifest.py: min_app_version Feld
- registry.py: App-Version-Compatibility-Check bei Installation
- GET /api/v1/plugins/updates Endpoint

Phase 5: Marketplace-Vorbereitung
- app/plugins/signature.py (Ed25519 Signatur-Validierung)
- app/plugins/quarantine.py (Plugin-Quarantine mit Validierung)
- app/models/plugin_allowlist.py + Migration 0046
- manifest.py: author, license, homepage, icon, screenshots, changelog, marketplace_tags, price
- registry.py: discover_external(), discover_all()
- POST /api/v1/plugins/install-marketplace (deaktiviert)

Phase 6: Manifest-Anpassung
- manifest.py: 12 neue Felder + SemVer/Hook-Name Validierung
- MANIFEST_SCHEMA_DOC aktualisiert
- Alle 19 Plugin-Manifeste aktualisiert
- Frontend PluginUiManifest Typ erweitert

Zusaetzliche Bug-Fixes:
- test_sample-Modul erstellt
- conftest.py Deadlock-Prevention
- SESSION_COOKIE_SECURE=true
- dump.rdb aus Git entfernt + .gitignore
- backup.py datetime.utcnow -> func.now()
- system_settings.py JSONB-Import nach oben
- tax.py Mapped[float] -> Mapped[Decimal]
- notification.py type_key-Laengen vereinheitlicht

Tests: 91 neue Tests, alle bestanden
2026-07-26 23:15:34 +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:
print(f"{v1} is older than {v2}")
if v1.is_breaking_change(v2):
print("Major version changed — breaking!")
if v2.is_compatible_with(v1):
print("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))