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:
enginerootreadonlylock_manager
ResourceDescriptor
A discovered resource is represented by:
pathresource_typeschema_pathui_pathoptions_pathmetadata
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)returnsFalse, discovery ignores the file. - If
detect(path)returnsTrue, discovery callsload(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_managerto 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_registryusers. - Prefer additive schema changes when possible.
- Document any plugin-specific configuration keys clearly.
Manual navigation: Previous: Configuration | Index | Next: Architecture