Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 27 additions & 43 deletions backend/druks/extensions/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from druks.models import StoredSubject
from druks.user_settings.models import SettingsOverride

from .exceptions import SettingsDeclarationError
from .exceptions import ExtensionSubjectContractError, SettingsDeclarationError
from .registry import agents as agent_registry
from .registry import autodiscover
from .registry import workflows as workflow_registry
Expand All @@ -23,7 +23,7 @@
)

if TYPE_CHECKING:
from fastapi import APIRouter, FastAPI
from fastapi import APIRouter

from druks.agents import Agent
from druks.doctor import CheckResult
Expand Down Expand Up @@ -57,10 +57,10 @@ def clean(self) -> dict[str, str]:

class Extension:
"""A pluggable application. Subclass it, set ``name``, and register the
subclass under the ``druks.extensions`` entry-point group. At boot the platform
calls ``load`` for every extension, which imports the package's
conventionally-named modules — that import is where the extension's webhooks,
workflows, agents, and subscribers self-register.
subclass under the ``druks.extensions`` entry-point group. At boot the loader
calls ``discover``, which imports the package's conventionally-named modules —
that import is where the extension's webhooks, workflows, agents, and
subscribers self-register.

Used as a class, never instantiated: an extension is a stateless install
singleton, so an instance would only be ceremony.
Expand Down Expand Up @@ -181,20 +181,33 @@ def agents(cls) -> "list[Agent]":
def workflows(cls) -> "list[type[Workflow]]":
"""The workflows living in this extension's package."""
prefix = cls.package + "."
return [wf for wf in workflow_registry.all() if wf.__module__.startswith(prefix)]
return [
workflow
for workflow in workflow_registry.all()
if workflow.__module__.startswith(prefix)
]

@classmethod
def subject_classes(cls) -> "list[type[Subject] | type[StoredSubject]]":
"""What this extension's runs are about, read off the workflows that declare
them — each one gets a board and a page, ordered by subject type so the routes
it mounts are stable."""
declared = {wf.subject for wf in cls.workflows() if wf.subject}
def subjects(cls) -> "list[type[Subject] | type[StoredSubject]]":
"""The subjects this extension's workflows declare, ordered by subject type.
Each must implement ``list_summaries()``. The check compares method identity
and does not call the method."""
from druks.durable.datastructures import Subject

stubs = {Subject.list_summaries.__func__, StoredSubject.list_summaries.__func__}
declared = {workflow.subject for workflow in cls.workflows() if workflow.subject}
for subject_class in declared:
if subject_class.subject_type == "transcripts":
raise TypeError(
raise ExtensionSubjectContractError(
f"{subject_class.__name__} is a 'transcripts' subject; that segment "
"serves every extension's agent-call reads. Name it for what it is"
)
if subject_class.list_summaries.__func__ in stubs:
raise ExtensionSubjectContractError(
f"extension {cls.name!r} declares subject {subject_class.__name__} "
f"without list_summaries(); the board calls it. Implement "
f"list_summaries() on {subject_class.__name__}."
)
return sorted(declared, key=lambda subject_class: subject_class.subject_type)

@classmethod
Expand Down Expand Up @@ -260,35 +273,6 @@ def frontend_dist(cls) -> Path | None:
dist = package_dir / "dist"
return dist if (dist / "entry.js").is_file() else None

@classmethod
def load(cls, app: "FastAPI") -> None:
"""Wire the extension into the running API: import its capabilities
(``discover``), mount its routers under ``/api/<name>``, and serve its
shipped frontend (if any) under ``/app/<name>``. The loader calls this once
per extension at boot."""
# Local, matching get_routers: the loader stays importable app-lessly.
from fastapi import Depends

from druks.accounts.dependencies import current_account

modules = cls.discover()
# /api/<name> wraps the author's own prefix so extensions can't shadow
# the platform or each other; every route sits behind the identity gate.
# The extension's name tags them all, so a router says only what it serves.
prefix = f"/api/{cls.name}"
for router in cls.get_routers(modules):
app.include_router(
router,
prefix=prefix,
tags=[cls.name],
dependencies=[Depends(current_account)],
)
dist = cls.frontend_dist()
if dist:
# /app, not /api: unknown /api/* paths must stay JSON 404s, never fall
# through to an index.html.
app.frontend(f"/app/{cls.name}", directory=dist)

@classmethod
def get_routers(cls, modules: list[ModuleType]) -> "list[APIRouter]":
"""Every router mounted under the extension's namespace: the ones it declares in
Expand All @@ -315,7 +299,7 @@ def get_routers(cls, modules: list[ModuleType]) -> "list[APIRouter]":
# no way to take a read the platform serves, not even with a catch-all.
return [
cls._get_transcript_routes(),
*(cls._get_subject_routes(subject) for subject in cls.subject_classes()),
*(cls._get_subject_routes(subject) for subject in cls.subjects()),
*declared,
]

Expand Down
9 changes: 7 additions & 2 deletions backend/druks/extensions/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ class SubscriberDeclarationError(Exception):


class ExtensionLoadError(Exception):
"""An extension could not be loaded app-lessly. The concrete subclass names
which stage failed nothing raises this base directly."""
"""An extension could not be loaded, app-lessly or at full boot. The concrete
subclass names the failed stage; nothing raises this base directly."""


class ExtensionNotFound(ExtensionLoadError):
Expand All @@ -38,3 +38,8 @@ class ExtensionImportError(ExtensionLoadError):
"""Importing the extension's models or capability modules raised. The
extension is installed and well-declared, but its own code failed on
import — carries the original exception as its cause."""


class ExtensionSubjectContractError(ExtensionLoadError):
"""A declared subject fails the read-side contract: no ``list_summaries()``
implementation, or it names the reserved ``transcripts`` segment."""
43 changes: 35 additions & 8 deletions backend/druks/extensions/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

if TYPE_CHECKING:
from importlib.metadata import EntryPoint
from types import ModuleType

from fastapi import FastAPI

Expand Down Expand Up @@ -70,8 +71,8 @@ def iter_extensions() -> list[type[Extension]]:
if extension.name in seen:
raise ValueError(f"duplicate extension name {extension.name!r}")
seen.add(extension.name)
# Ownership registers before load(app)/discover() imports the capability
# modules, whose Workflow classes resolve their extension at definition.
# Ownership registers before discover() imports the capability modules,
# whose Workflow classes resolve their extension at definition.
register_workflow_package(extension.package, extension.name)
extensions.append(extension)
return extensions
Expand All @@ -91,18 +92,21 @@ def load_extension(name: str) -> type[Extension]:
class, with every surface then enumerable off it (``workflows()``,
``routers()``, ``capability_modules()``, ``settings_model``,
``migrations_dir()``). The load path used by the CLI, tests, and evals —
no FastAPI, no ``load(app)``.
no FastAPI, nothing mounted.

Fails loudly and by name: an uninstalled package raises ``ExtensionNotFound``;
an entry point that doesn't resolve to an ``Extension`` raises
``MalformedExtension``; the extension's own code raising on import raises
``ExtensionImportError``."""
``ExtensionImportError``; a declared subject that fails the read-side contract
raises ``ExtensionSubjectContractError``."""
extension = _resolve(name)
try:
import_extension_models(extension)
extension.discover()
except Exception as error:
raise ExtensionImportError(f"extension {name!r} failed to import: {error}") from error
# Outside the try: a contract break is not an import error.
extension.subjects()
return extension


Expand Down Expand Up @@ -215,12 +219,35 @@ def import_extension_models(only: type[Extension] | None = None) -> None:
)


def mount(app: "FastAPI", extension: type[Extension], modules: list["ModuleType"]) -> None:
"""Mount one discovered extension under ``/api/<name>`` and ``/app/<name>``.
The prefix and the identity gate are the loader's — no extension hook can
override them."""
# Local, matching get_routers: the loader stays importable app-lessly.
from fastapi import Depends

from druks.accounts.dependencies import current_account

prefix = f"/api/{extension.name}"
for router in extension.get_routers(modules):
app.include_router(
router,
prefix=prefix,
tags=[extension.name],
dependencies=[Depends(current_account)],
)
dist = extension.frontend_dist()
if dist:
# /app, not /api: unknown /api/* paths stay JSON 404s.
app.frontend(f"/app/{extension.name}", directory=dist)


def load(app: "FastAPI") -> None:
"""API boot entry: every extension imports its capabilities (self-registering its
webhooks, workflows, agents, subscribers) and mounts its routers under
``/api/<name>``."""
"""API boot: for each extension — discover, validate subjects, mount."""
# The table-prefix check runs here, not just in makemigrations — an author
# who hand-writes migrations still can't boot with an unprefixed table.
import_extension_models()
for extension in iter_extensions():
extension.load(app)
modules = extension.discover()
extension.subjects()
mount(app, extension, modules)
2 changes: 1 addition & 1 deletion backend/druks/extensions/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ async def list_extensions() -> list[ExtensionResponse]:
icon=extension.icon,
description=extension.description,
builtin=extension.builtin,
subject_types=[subject.subject_type for subject in extension.subject_classes()],
subject_types=[subject.subject_type for subject in extension.subjects()],
has_frontend=bool(extension.frontend_dist()),
navigation=extension.navigation,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ from druks.db import Base, StoredSubject
# ticket. Subclass ``StoredSubject`` for that one and point a workflow at it with
# ``subject = ThatModel``, and druks shows it a board and a page. Something you keep no
# row for subclasses ``druks.workflows.Subject`` instead — same board, same page.
# A declared subject must implement ``list_summaries()`` — the board reads it.
# Druks checks this at load and refuses the extension if it is missing.
# After adding a model: ``druks makemigrations {{ name }} -m "..." && druks init-db``.
86 changes: 85 additions & 1 deletion backend/tests/test_extension_appless_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
ExtensionImportError,
ExtensionLoadError,
ExtensionNotFound,
ExtensionSubjectContractError,
MalformedExtension,
)
from druks.extensions.loader import load_extension
Expand Down Expand Up @@ -93,6 +94,39 @@ class Settings(BaseModel):
}


# A package whose declared ``StoredSubject`` omits ``list_summaries()``. Its own
# table keeps it off the conforming probe's shared metadata.
_BROKEN_STORED_FILES = {
"extension.py": """
from druks.extensions import Extension


class BrokenStored(Extension):
name = "brokenstored"
""",
"models.py": """
from druks.db import StoredSubject


class Ledger(StoredSubject):
__tablename__ = "brokenstored_ledgers"
# No list_summaries() — the load gate must reject it.
""",
"workflows.py": """
from druks.workflows import Workflow

from .models import Ledger


class Post(Workflow):
subject = Ledger

async def run(self) -> None:
...
""",
}


def _write_package(root: Path, package: str, files: dict[str, str]) -> None:
directory = root / package
(directory / "migrations" / "versions").mkdir(parents=True)
Expand Down Expand Up @@ -120,7 +154,9 @@ def external_extension(tmp_path_factory):
sys.path.insert(0, str(root))

tables = set(Base.metadata.tables)
registries = {r: dict(r._items) for r in (agents, services, webhooks, workflows)}
registries = {
registry: dict(registry._items) for registry in (agents, services, webhooks, workflows)
}
packages = dict(extensions_loader._workflow_packages)
finished = signal("workflow.finished")
receivers = dict(finished.receivers)
Expand Down Expand Up @@ -325,3 +361,51 @@ def test_import_error_in_models_raises_extension_import_error(tmp_path, monkeypa
with pytest.raises(ExtensionImportError, match="failed to import") as caught:
load_extension("probe")
assert isinstance(caught.value.__cause__, RuntimeError)


@pytest.fixture
def broken_stored_extension(tmp_path_factory, monkeypatch):
"""The broken package as the only installed entry point; restores the globals
its load mutates."""
from druks.extensions import loader as extensions_loader
from druks.extensions.registry import agents, services, webhooks, workflows
from druks.models import Base

package = "druks_broken_stored"
root = tmp_path_factory.mktemp("broken_stored")
_write_package(root, package, _BROKEN_STORED_FILES)
sys.path.insert(0, str(root))

tables = set(Base.metadata.tables)
registries = {
registry: dict(registry._items) for registry in (agents, services, webhooks, workflows)
}
packages = dict(extensions_loader._workflow_packages)
entry = EntryPoint(
name="brokenstored", value=f"{package}.extension:BrokenStored", group="druks.extensions"
)
monkeypatch.setattr(loader, "entry_points", lambda *, group: [entry])
try:
yield
finally:
sys.path.remove(str(root))
for name in set(Base.metadata.tables) - tables:
Base.metadata.remove(Base.metadata.tables[name])
for registry, snapshot in registries.items():
registry._items = snapshot
extensions_loader._workflow_packages.clear()
extensions_loader._workflow_packages.update(packages)
for name in [m for m in sys.modules if m == package or m.startswith(f"{package}.")]:
del sys.modules[name]


def test_appless_load_rejects_a_stored_subject_missing_list_summaries(broken_stored_extension):
"""A row-backed subject is gated the same as ``Subject`` — typed, not an import error."""
with pytest.raises(ExtensionSubjectContractError) as caught:
load_extension("brokenstored")
message = str(caught.value)
assert "brokenstored" in message # the extension name
assert "Ledger" in message # the subject class
assert "list_summaries()" in message # the missing method
assert "Implement list_summaries()" in message # the implementation direction
assert isinstance(caught.value, ExtensionLoadError)
Loading