Plugin Development

This guide describes the plugin APIs that are currently implemented in Adapt.

Core Plugin Interfaces

Adapt plugin interfaces live in adapt/plugins/base.py.

PluginContext

PluginContext provides shared execution context:

  • engine
  • root
  • readonly
  • lock_manager

ResourceDescriptor

A discovered resource is represented by:

  • path
  • resource_type
  • schema_path
  • ui_path
  • options_path
  • metadata

Plugin Base Class

A plugin must implement:

  • detect(path)
  • load(path)
  • schema(resource)
  • read(resource, request)
  • write(resource, data, request, context)

Optional extension points:

  • apply_options(descriptor)
  • get_route_configs(descriptor)
  • index(resource)
  • filter_for_user(resource, user, rows)
  • default_ui(descriptor)
  • generate_companion_files(descriptor)

How Discovery Works

Discovery scans the document root. The plugin_registry entry for the file extension selects a candidate plugin.

Important behavior:

  • An extension without a registry entry is ignored.
  • Discovery creates the candidate plugin and calls detect(path).
  • If detect(path) returns False, discovery ignores the file.
  • If detect(path) returns True, discovery calls load(path).

Plugin Registration

Configure plugins in DOCROOT/.adapt/conf.json under plugin_registry.

Use dotted class paths (not module:Class):

{
  "plugin_registry": {
    ".csv": "adapt.plugins.csv_plugin.CsvPlugin",
    ".myext": "my_plugin.plugin.MyPlugin"
  }
}

Route Mounting Model

Plugins return route configs as (prefix, router) pairs.

Generated mounting combines prefix and namespace into routes such as:

  • /api/<namespace>
  • /schema/<namespace>
  • /ui/<namespace>
  • /media/<namespace>

For each resource, Adapt mounts routes for both namespace forms. For reports.myext, the API route exists at /api/reports/ and /api/reports.myext/.

Sub-namespaces follow the filename. For the Summary sheet in reports.xlsx, the forms are /api/reports/Summary/ and /api/reports.xlsx/Summary/.

Implementing a Dataset-Style Plugin

Dataset-style plugins can inherit from DatasetPlugin to reuse schema/UI/mutation patterns.

Set descriptor.metadata["readonly"] to True for a resource that cannot use the shared mutation path. You can add a user-facing message in descriptor.metadata["readonly_reason"]. The API returns this message with status 405, and the default UI hides its edit controls.

Expected mutation contract:

  • POST /api/<resource> with { "action": "create", "data": [...] }
  • PATCH /api/<resource> with { "action": "update", "data": { ... } }
  • DELETE /api/<resource> with { "action": "delete", "data": { ... } }

Mutations must:

  • Respect context.readonly
  • Use lock_manager to avoid unsafe concurrent writes
  • Invalidate cache after successful changes

The schema returned by schema() supplies serialization hints, UI column metadata, and validation rules for the shared dataset mutation path. Create and update fields use the common string, integer, number, and boolean types (plus corresponding pandas type names). Numeric and boolean strings are normalized; blank strings and null are permitted. Unknown columns or incompatible values return 422 before the resource is locked or written. Custom schema types without defined validation semantics remain metadata only.

filter_for_user() is applied on dataset reads and is available as a row-filtering extension point. The shared mutation implementation does not provide safe write-level row-security enforcement: it reads and rewrites the row collection, and row identifiers can diverge after filtering. A plugin that needs row-level write authorization must implement and test its own write path rather than relying on this hook. See Known Limitations.

Example Skeleton

from pathlib import Path
from typing import Any, Iterable, Sequence

from fastapi import Request
from fastapi.routing import APIRouter

from adapt.plugins.base import (
    Plugin,
    PluginContext,
    ResourceDescriptor,
    SearchDocument,
)


class MyPlugin(Plugin):
    def detect(self, path: Path) -> bool:
        return path.suffix.lower() == ".myext"

    def load(
        self, path: Path
    ) -> ResourceDescriptor | Sequence[ResourceDescriptor]:
        return ResourceDescriptor(path=path, resource_type="myext")

    def schema(self, resource: ResourceDescriptor) -> dict[str, Any]:
        return {}

    def read(self, resource: ResourceDescriptor, request: Request) -> Any:
        return {"ok": True}

    def write(
        self,
        resource: ResourceDescriptor,
        data: Any,
        request: Request,
        context: PluginContext,
    ) -> Any:
        if context.readonly:
            raise RuntimeError("read-only mode")
        return {"success": True}

    def apply_options(self, descriptor: ResourceDescriptor) -> None:
        pass

    def get_route_configs(
        self, descriptor: ResourceDescriptor
    ) -> list[tuple[str, APIRouter]]:
        router = APIRouter()

        @router.get("/")
        def get_resource(request: Request) -> Any:
            return self.read(descriptor, request)

        return [("api", router)]

    def index(
        self, resource: ResourceDescriptor
    ) -> Iterable[SearchDocument]:
        return []

Adapt mounts the returned router under both resource namespaces. It also adds the resource permission dependency to the router.

Python Handler Plugins

The built-in Python handler plugin (.py) loads modules and mounts an APIRouter named router under /api/<filename>.

If import fails, the handler is skipped.

Companion Files

Built-in dataset plugins generate companion files under .adapt/:

  • *.schema.json
  • *.index.html

The companion UI filename always ends in .index.html. For example, the UI for the Dashboard sheet in report.xlsx is .adapt/report.Dashboard.index.html.

The media plugin uses ui_path differently. It writes JSON metadata to that path, not an HTML template.

A generated *.schema.json carries "generated_by": "adapt". Adapt refreshes such a file when the resource's shape changes, and leaves any schema without that key alone, so hand-written schemas are never overwritten. The key is stripped before the schema is served from /schema/<resource>.

Resource Options

You can also hand-write *.options.json alongside the generated files to override how a resource is parsed. The naming matches the other companion files — for the sheet Dashboard in report.xlsx, that is .adapt/report.Dashboard.options.json.

Supported keys:

Key Applies to Meaning
header_row .xlsx, .xls 1-based row holding the column names. Defaults to 1.

Use header_row when a sheet opens with a title banner instead of column names. Without this option, Adapt parses the banner as the header:

{ "header_row": 3 }

Rows above the header row are ignored on both read and write. An unreadable options file or an invalid value is logged and ignored rather than raised, so a typo cannot take the server down.

Plugins consume options by overriding apply_options(descriptor), which discovery calls after load() (which sees only a path, and so cannot locate the companion directory) and before generate_companion_files() (so a derived schema reflects the options).

Testing Recommendations

When creating plugins, test:

  • Discovery and load behavior
  • Schema generation
  • Read/write behavior
  • Read-only mode behavior
  • Lock conflict behavior
  • Route registration and endpoint responses

Compatibility Notes

  • Keep plugin class paths stable for plugin_registry users.
  • Prefer additive schema changes when possible.
  • Document any plugin-specific configuration keys clearly.

Manual navigation: Previous: Configuration | Index | Next: Architecture