98eb1d0d89
Check Cross-Plugin Imports / check (push) Has been cancelled
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
216 lines
6.5 KiB
Python
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))
|