"""Plugin catalog registry. Maintains the authoritative list of plugins, their review status, and aggregate install counts. Storage is in-memory until Step 12 migrates to the ``plugins`` PostgreSQL table. Module-level singleton:: from app.marketplace.plugin_registry import registry """ from __future__ import annotations import copy import time import uuid from typing import Any, Literal from app.schemas import PluginListResponse, PluginManifest # ── Pre-seeded approved plugins (mirrors the Step 8 stub catalog) ───── _SEED_PLUGINS: list[dict[str, Any]] = [ { "manifest": PluginManifest( id="plugin-github-sync", name="GitHub Sync", description="Sync tasks with GitHub Issues and pull requests.", version="1.0.0", author="Adiuva", permissions=["read:tasks", "write:tasks"], category="productivity", price_cents=0, ), "status": "approved", "s3_package_key": "plugins/plugin-github-sync/1.0.0/package.zip", "install_count": 0, "avg_rating": 0.0, "rejection_reason": None, "submitted_at": int(time.time()), }, { "manifest": PluginManifest( id="plugin-slack-notify", name="Slack Notifier", description="Post task and checkpoint updates to Slack channels.", version="1.2.0", author="Adiuva", permissions=["read:tasks", "read:checkpoints"], category="communication", price_cents=499, ), "status": "approved", "s3_package_key": "plugins/plugin-slack-notify/1.2.0/package.zip", "install_count": 0, "avg_rating": 0.0, "rejection_reason": None, "submitted_at": int(time.time()), }, { "manifest": PluginManifest( id="plugin-time-tracker", name="Time Tracker", description="Track time spent on tasks with automatic reporting.", version="0.9.1", author="Third Party", permissions=["read:tasks", "write:tasks"], category="productivity", price_cents=999, ), "status": "approved", "s3_package_key": "plugins/plugin-time-tracker/0.9.1/package.zip", "install_count": 0, "avg_rating": 0.0, "rejection_reason": None, "submitted_at": int(time.time()), }, ] _PAGE_SIZE = 20 class PluginRegistry: """In-process plugin catalog. All mutating methods are ``async`` to make the future DB swap transparent to callers. """ def __init__(self) -> None: # plugin_id → entry dict (deep-copied so each instance is independent) self._catalog: dict[str, dict[str, Any]] = { e["manifest"].id: copy.deepcopy(e) for e in _SEED_PLUGINS } # ── Queries ────────────────────────────────────────────────────── async def list_plugins( self, category: str | None = None, query: str | None = None, page: int = 1, sort: Literal["rating", "installs", "newest"] = "newest", ) -> PluginListResponse: """Return a page of approved plugins, optionally filtered and sorted.""" entries = [e for e in self._catalog.values() if e["status"] == "approved"] if category: entries = [e for e in entries if e["manifest"].category == category] if query: q_lower = query.lower() entries = [ e for e in entries if q_lower in e["manifest"].name.lower() or q_lower in e["manifest"].description.lower() ] if sort == "installs": entries = sorted(entries, key=lambda e: e["install_count"], reverse=True) elif sort == "rating": entries = sorted(entries, key=lambda e: e["avg_rating"], reverse=True) # "newest" = catalog insertion order (dict preserves insertion in Python 3.7+) total = len(entries) start = (page - 1) * _PAGE_SIZE page_entries = entries[start : start + _PAGE_SIZE] return PluginListResponse( plugins=[e["manifest"] for e in page_entries], total=total, page=page, ) async def get_plugin(self, plugin_id: str) -> dict[str, Any] | None: """Return ``{manifest, status, install_count, avg_rating}`` or ``None``.""" entry = self._catalog.get(plugin_id) if entry is None: return None return { "manifest": entry["manifest"], "status": entry["status"], "install_count": entry["install_count"], "avg_rating": entry["avg_rating"], } # ── Mutations ──────────────────────────────────────────────────── async def submit_plugin( self, manifest: PluginManifest, package_s3_key: str, ) -> str: """Add *manifest* to the catalog with ``status='pending_review'``. Returns the plugin_id. If a plugin with the same id already exists it is overwritten (re-submission after rejection). """ plugin_id = manifest.id or str(uuid.uuid4()) self._catalog[plugin_id] = { "manifest": manifest, "status": "pending_review", "s3_package_key": package_s3_key, "install_count": 0, "avg_rating": 0.0, "rejection_reason": None, "submitted_at": int(time.time()), } return plugin_id async def approve_plugin(self, plugin_id: str) -> None: """Set *plugin_id* status to ``'approved'``. Raises ``KeyError`` if the plugin is not found. """ if plugin_id not in self._catalog: raise KeyError(f"Plugin not found: {plugin_id}") self._catalog[plugin_id]["status"] = "approved" self._catalog[plugin_id]["rejection_reason"] = None async def reject_plugin(self, plugin_id: str, reason: str) -> None: """Set *plugin_id* status to ``'rejected'`` and record the reason. Raises ``KeyError`` if the plugin is not found. """ if plugin_id not in self._catalog: raise KeyError(f"Plugin not found: {plugin_id}") self._catalog[plugin_id]["status"] = "rejected" self._catalog[plugin_id]["rejection_reason"] = reason async def record_install(self, plugin_id: str) -> None: """Increment the install count for *plugin_id* (no-op if not found).""" if plugin_id in self._catalog: self._catalog[plugin_id]["install_count"] += 1 async def record_uninstall(self, plugin_id: str) -> None: """Decrement the install count for *plugin_id*, floored at 0.""" if plugin_id in self._catalog: current = self._catalog[plugin_id]["install_count"] self._catalog[plugin_id]["install_count"] = max(0, current - 1) # ── Internal helpers used by ReviewQueue ───────────────────────── def _get_pending_entries(self) -> list[dict[str, Any]]: """Return all entries with status='pending_review' (synchronous helper).""" return [e for e in self._catalog.values() if e["status"] == "pending_review"] # Module-level singleton registry = PluginRegistry()