feat: Plugin-System Umbau — 6 Phasen komplett abgeschlossen
Check Cross-Plugin Imports / check (push) Has been cancelled
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
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check for forbidden direct cross-plugin imports.
|
||||
|
||||
This script enforces that plugins communicate only through contracts,
|
||||
not by importing internal modules from each other.
|
||||
|
||||
Allowed:
|
||||
from app.plugins.builtins.contracts import get_contract
|
||||
from app.plugins.builtins.<name>.contracts import ...
|
||||
from app.plugins.builtins.<name> import <PluginClass> (in __init__.py only)
|
||||
|
||||
Forbidden:
|
||||
from app.plugins.builtins.<name>.services import ...
|
||||
from app.plugins.builtins.<name>.models import ...
|
||||
from app.plugins.builtins.<name>.routes import ...
|
||||
|
||||
Exceptions (files that are allowed to import anything):
|
||||
- */contracts.py — contracts import from internal modules
|
||||
- */__init__.py — plugin discovery
|
||||
- app/plugins/registry.py — registry manages all plugins
|
||||
- app/plugins/builtins/__init__.py — builtin discovery
|
||||
- tests/* — test files
|
||||
- conftest.py — test fixtures
|
||||
|
||||
Usage:
|
||||
python scripts/check_cross_plugin_imports.py
|
||||
python scripts/check_cross_plugin_imports.py --path app/plugins/builtins
|
||||
|
||||
Exit codes:
|
||||
0 — no violations
|
||||
1 — violations found
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ─── Configuration ───
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
BUILTINS_DIR = PROJECT_ROOT / "app" / "plugins" / "builtins"
|
||||
|
||||
# Files that are exempt from the rule
|
||||
EXEMPT_FILES = {
|
||||
"contracts.py",
|
||||
"__init__.py",
|
||||
"conftest.py",
|
||||
}
|
||||
|
||||
# Directories that are exempt
|
||||
EXEMPT_DIRS = {
|
||||
"tests",
|
||||
"__pycache__",
|
||||
"migrations",
|
||||
}
|
||||
|
||||
# Files that are exempt by path
|
||||
EXEMPT_PATHS = {
|
||||
PROJECT_ROOT / "app" / "plugins" / "registry.py",
|
||||
PROJECT_ROOT / "app" / "plugins" / "builtins" / "__init__.py",
|
||||
PROJECT_ROOT / "app" / "plugins" / "base.py",
|
||||
PROJECT_ROOT / "app" / "plugins" / "manifest.py",
|
||||
PROJECT_ROOT / "app" / "plugins" / "migration_runner.py",
|
||||
}
|
||||
|
||||
# Pattern for cross-plugin imports
|
||||
IMPORT_PATTERN = re.compile(
|
||||
r"^\s*(?:from|import)\s+app\.plugins\.builtins\.([^.]+)\.(.+?)\s+import\s+(.+)$"
|
||||
)
|
||||
|
||||
# Pattern for allowed contract imports
|
||||
CONTRACT_IMPORT_PATTERN = re.compile(
|
||||
r"^\s*from\s+app\.plugins\.builtins\.(?:contracts|[^.]+\.contracts)\s+import\s+(.+)$"
|
||||
)
|
||||
|
||||
# Pattern for __init__.py plugin class imports (allowed in __init__.py only)
|
||||
PLUGIN_CLASS_IMPORT_PATTERN = re.compile(
|
||||
r"^\s*from\s+app\.plugins\.builtins\.([^.]+)\s+import\s+([A-Z]\w*Plugin)\s*$"
|
||||
)
|
||||
|
||||
|
||||
def is_exempt(filepath: Path) -> bool:
|
||||
"""Check if a file is exempt from the cross-plugin import rule."""
|
||||
# Exempt by filename
|
||||
if filepath.name in EXEMPT_FILES:
|
||||
return True
|
||||
|
||||
# Exempt by path
|
||||
if filepath in EXEMPT_PATHS:
|
||||
return True
|
||||
|
||||
# Exempt test directories
|
||||
parts = filepath.parts
|
||||
for exempt_dir in EXEMPT_DIRS:
|
||||
if exempt_dir in parts:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def check_file(filepath: Path) -> list[str]:
|
||||
"""Check a single file for forbidden cross-plugin imports.
|
||||
|
||||
Returns a list of violation messages (empty if clean).
|
||||
"""
|
||||
if is_exempt(filepath):
|
||||
return []
|
||||
|
||||
violations: list[str] = []
|
||||
rel_path = filepath.relative_to(PROJECT_ROOT)
|
||||
|
||||
# Determine the source plugin from the file path
|
||||
try:
|
||||
parts = filepath.relative_to(BUILTINS_DIR).parts
|
||||
src_plugin = parts[0] if parts else ""
|
||||
except ValueError:
|
||||
src_plugin = ""
|
||||
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
line_stripped = line.strip()
|
||||
|
||||
# Skip comments and empty lines
|
||||
if not line_stripped or line_stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
# Check for cross-plugin import
|
||||
match = IMPORT_PATTERN.match(line)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
tgt_plugin = match.group(1)
|
||||
tgt_module = match.group(2)
|
||||
|
||||
# Skip if importing from contracts
|
||||
if tgt_module == "contracts":
|
||||
continue
|
||||
|
||||
# Skip if same plugin (INTRA-Plugin import)
|
||||
if tgt_plugin == src_plugin:
|
||||
continue
|
||||
|
||||
# Skip if it's a contract import via the central registry
|
||||
if CONTRACT_IMPORT_PATTERN.match(line):
|
||||
continue
|
||||
|
||||
# This is a forbidden cross-plugin import
|
||||
violations.append(
|
||||
f"{rel_path}:{line_num}: {line_stripped}\n"
|
||||
f" → Forbidden cross-plugin import: '{tgt_plugin}.{tgt_module}'. "
|
||||
f"Use contracts instead: get_contract(\"{tgt_plugin}\")"
|
||||
)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def find_python_files(search_path: Path | None = None) -> list[Path]:
|
||||
"""Find all Python files in the search path."""
|
||||
if search_path is None:
|
||||
search_path = BUILTINS_DIR
|
||||
|
||||
files: list[Path] = []
|
||||
for root, dirs, fnames in os.walk(search_path):
|
||||
# Skip exempt directories
|
||||
dirs[:] = [d for d in dirs if d not in EXEMPT_DIRS]
|
||||
for fname in fnames:
|
||||
if fname.endswith(".py"):
|
||||
files.append(Path(root) / fname)
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the cross-plugin import checker."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Check for forbidden cross-plugin imports.")
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
type=Path,
|
||||
default=BUILTINS_DIR,
|
||||
help="Path to check (default: app/plugins/builtins)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Print checked files even if clean.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
files = find_python_files(args.path)
|
||||
all_violations: list[str] = []
|
||||
checked = 0
|
||||
|
||||
for filepath in files:
|
||||
checked += 1
|
||||
violations = check_file(filepath)
|
||||
if violations:
|
||||
all_violations.extend(violations)
|
||||
elif args.verbose:
|
||||
print(f" ✅ {filepath.relative_to(PROJECT_ROOT)}")
|
||||
|
||||
print(f"\nChecked {checked} files.")
|
||||
|
||||
if all_violations:
|
||||
print(f"\n❌ Found {len(all_violations)} violation(s):\n")
|
||||
for v in all_violations:
|
||||
print(f" {v}")
|
||||
print(
|
||||
"\nFix: Replace direct imports with contract-based access:\n"
|
||||
" from app.plugins.builtins.contracts import get_contract\n"
|
||||
" contract = get_contract(\"plugin_name\")\n"
|
||||
" if contract:\n result = await contract.some_function(...)"
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
print("✅ No forbidden cross-plugin imports found.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user