@palettelab/cli 0.3.12 → 0.3.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend-sdk/palette_sdk/__init__.py +48 -0
- package/backend-sdk/palette_sdk/config.py +21 -0
- package/backend-sdk/palette_sdk/db/__init__.py +38 -0
- package/backend-sdk/palette_sdk/db/alembic_env.py +89 -0
- package/backend-sdk/palette_sdk/db/base.py +50 -0
- package/backend-sdk/palette_sdk/db/rls.py +34 -0
- package/backend-sdk/palette_sdk/events.py +54 -0
- package/backend-sdk/palette_sdk/manifest.py +112 -0
- package/backend-sdk/palette_sdk/permissions.py +94 -0
- package/backend-sdk/palette_sdk/plugin_context.py +63 -0
- package/backend-sdk/palette_sdk/plugin_router.py +27 -0
- package/backend-sdk/palette_sdk/schemas.py +30 -0
- package/backend-sdk/palette_sdk/testing.py +45 -0
- package/backend-sdk/palette_sdk/tool_definition.py +66 -0
- package/backend-sdk/palette_sdk/webhooks.py +17 -0
- package/backend-sdk/pyproject.toml +32 -0
- package/lib/commands/test.js +7 -3
- package/package.json +4 -1
- package/template-fallback/pyproject.toml +2 -1
- package/template-fallback/templates/agent-tool/pyproject.toml +2 -1
- package/template-fallback/templates/dashboard/pyproject.toml +2 -1
- package/template-fallback/templates/database/pyproject.toml +1 -1
- package/template-fallback/templates/external-service/pyproject.toml +2 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Palette Platform SDK for building backend plugins."""
|
|
2
|
+
|
|
3
|
+
from palette_sdk.plugin_router import PluginRouter
|
|
4
|
+
from palette_sdk.plugin_context import PluginContext, get_plugin_context
|
|
5
|
+
from palette_sdk.tool_definition import ToolDefinition
|
|
6
|
+
from palette_sdk.manifest import PluginManifest, load_manifest
|
|
7
|
+
from palette_sdk.schemas import (
|
|
8
|
+
SuccessResponse,
|
|
9
|
+
ErrorResponse,
|
|
10
|
+
PaginatedResponse,
|
|
11
|
+
)
|
|
12
|
+
from palette_sdk.db import OrgScopedTable, PluginBase, ensure_org_rls
|
|
13
|
+
from palette_sdk.permissions import (
|
|
14
|
+
KNOWN_PERMISSIONS,
|
|
15
|
+
is_known_permission,
|
|
16
|
+
require_permission,
|
|
17
|
+
)
|
|
18
|
+
from palette_sdk.events import Event, subscribe_event
|
|
19
|
+
from palette_sdk.config import get_config, require_config
|
|
20
|
+
from palette_sdk.webhooks import sign_webhook, verify_webhook_signature
|
|
21
|
+
from palette_sdk.testing import route_permission_issues
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"PluginRouter",
|
|
25
|
+
"PluginContext",
|
|
26
|
+
"get_plugin_context",
|
|
27
|
+
"ToolDefinition",
|
|
28
|
+
"PluginManifest",
|
|
29
|
+
"load_manifest",
|
|
30
|
+
"SuccessResponse",
|
|
31
|
+
"ErrorResponse",
|
|
32
|
+
"PaginatedResponse",
|
|
33
|
+
"OrgScopedTable",
|
|
34
|
+
"PluginBase",
|
|
35
|
+
"ensure_org_rls",
|
|
36
|
+
"KNOWN_PERMISSIONS",
|
|
37
|
+
"is_known_permission",
|
|
38
|
+
"require_permission",
|
|
39
|
+
"Event",
|
|
40
|
+
"subscribe_event",
|
|
41
|
+
"get_config",
|
|
42
|
+
"require_config",
|
|
43
|
+
"sign_webhook",
|
|
44
|
+
"verify_webhook_signature",
|
|
45
|
+
"route_permission_issues",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
__version__ = "0.1.3"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Install-scoped plugin configuration helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from palette_sdk.plugin_context import PluginContext
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_config(ctx: PluginContext, key: str, default: T | None = None) -> Any | T | None:
|
|
13
|
+
"""Return a config value injected by the platform for this org install."""
|
|
14
|
+
return ctx.config.get(key, default)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def require_config(ctx: PluginContext, key: str) -> Any:
|
|
18
|
+
"""Return a config value or raise a clear runtime error."""
|
|
19
|
+
if key not in ctx.config:
|
|
20
|
+
raise KeyError(f"missing required plugin config key: {key}")
|
|
21
|
+
return ctx.config[key]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Database primitives for Palette plugins.
|
|
2
|
+
|
|
3
|
+
Plugins that declare a `database` block in their manifest get their own
|
|
4
|
+
Postgres schema, owned by a dedicated low-privilege role, with row-level
|
|
5
|
+
security enforcing per-organization isolation.
|
|
6
|
+
|
|
7
|
+
Typical plugin usage:
|
|
8
|
+
|
|
9
|
+
# models.py
|
|
10
|
+
from palette_sdk.db import OrgScopedTable
|
|
11
|
+
from sqlalchemy import String
|
|
12
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
13
|
+
|
|
14
|
+
class Expense(OrgScopedTable):
|
|
15
|
+
__tablename__ = "expenses"
|
|
16
|
+
title: Mapped[str] = mapped_column(String(200))
|
|
17
|
+
amount_cents: Mapped[int]
|
|
18
|
+
|
|
19
|
+
# migrations/versions/0001_init.py
|
|
20
|
+
from alembic import op
|
|
21
|
+
import sqlalchemy as sa
|
|
22
|
+
from palette_sdk.db import ensure_org_rls
|
|
23
|
+
|
|
24
|
+
def upgrade():
|
|
25
|
+
op.create_table(
|
|
26
|
+
"expenses",
|
|
27
|
+
sa.Column("id", sa.Integer, primary_key=True),
|
|
28
|
+
sa.Column("organization_id", sa.BigInteger, nullable=False, index=True),
|
|
29
|
+
sa.Column("title", sa.String(200), nullable=False),
|
|
30
|
+
sa.Column("amount_cents", sa.Integer, nullable=False),
|
|
31
|
+
)
|
|
32
|
+
ensure_org_rls(op, "expenses")
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from palette_sdk.db.base import OrgScopedTable, PluginBase
|
|
36
|
+
from palette_sdk.db.rls import ensure_org_rls
|
|
37
|
+
|
|
38
|
+
__all__ = ["OrgScopedTable", "PluginBase", "ensure_org_rls"]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Alembic env template for plugin migrations.
|
|
2
|
+
|
|
3
|
+
Plugins ship an `env.py` that is a one-liner:
|
|
4
|
+
|
|
5
|
+
from palette_sdk.db.alembic_env import run_migrations
|
|
6
|
+
run_migrations()
|
|
7
|
+
|
|
8
|
+
The runner reads three environment variables set by the platform when it
|
|
9
|
+
invokes Alembic:
|
|
10
|
+
|
|
11
|
+
- ``PALETTE_PLUGIN_ID`` — plugin id; determines the target schema
|
|
12
|
+
(``app_<sanitized_id>``) and version table schema.
|
|
13
|
+
- ``PALETTE_DB_URL`` — SQLAlchemy async URL for the plugin's DB.
|
|
14
|
+
- ``PALETTE_PLUGIN_ROLE`` (optional) — role the migration should run as;
|
|
15
|
+
defaults to the connecting user (so DDL works).
|
|
16
|
+
|
|
17
|
+
Plugin devs running ``alembic upgrade head`` locally without those vars get
|
|
18
|
+
a helpful error rather than a scribbled-on main DB.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from logging.config import fileConfig
|
|
27
|
+
|
|
28
|
+
from alembic import context
|
|
29
|
+
from sqlalchemy import pool, text
|
|
30
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _plugin_schema(plugin_id: str) -> str:
|
|
34
|
+
safe = re.sub(r"[^a-z0-9_]", "_", plugin_id.lower())
|
|
35
|
+
return f"app_{safe}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _do_run_migrations(connection, schema: str, plugin_role: str | None) -> None:
|
|
39
|
+
if plugin_role:
|
|
40
|
+
connection.execute(text(f'SET ROLE "{plugin_role}"'))
|
|
41
|
+
connection.execute(text(f'SET search_path TO "{schema}"'))
|
|
42
|
+
|
|
43
|
+
context.configure(
|
|
44
|
+
connection=connection,
|
|
45
|
+
target_metadata=None,
|
|
46
|
+
version_table="alembic_version",
|
|
47
|
+
version_table_schema=schema,
|
|
48
|
+
include_schemas=False,
|
|
49
|
+
)
|
|
50
|
+
with context.begin_transaction():
|
|
51
|
+
context.run_migrations()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _run_async(schema: str, db_url: str, plugin_role: str | None) -> None:
|
|
55
|
+
connectable = create_async_engine(db_url, poolclass=pool.NullPool)
|
|
56
|
+
try:
|
|
57
|
+
# `.begin()` (not `.connect()`): commits on clean exit, rolls back on
|
|
58
|
+
# exception. With `.connect()` the surrounding transaction is rolled
|
|
59
|
+
# back at close time, silently discarding every CREATE/ALTER the
|
|
60
|
+
# migration issued.
|
|
61
|
+
async with connectable.begin() as connection:
|
|
62
|
+
await connection.run_sync(
|
|
63
|
+
lambda sync_conn: _do_run_migrations(sync_conn, schema, plugin_role)
|
|
64
|
+
)
|
|
65
|
+
finally:
|
|
66
|
+
await connectable.dispose()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_migrations() -> None:
|
|
70
|
+
plugin_id = os.environ.get("PALETTE_PLUGIN_ID")
|
|
71
|
+
db_url = os.environ.get("PALETTE_DB_URL")
|
|
72
|
+
plugin_role = os.environ.get("PALETTE_PLUGIN_ROLE") or None
|
|
73
|
+
|
|
74
|
+
if not plugin_id or not db_url:
|
|
75
|
+
raise RuntimeError(
|
|
76
|
+
"Plugin migrations require PALETTE_PLUGIN_ID and PALETTE_DB_URL "
|
|
77
|
+
"to be set. These are provided automatically by the platform; "
|
|
78
|
+
"use `palette dev` for local runs."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
config = context.config
|
|
82
|
+
if config.config_file_name:
|
|
83
|
+
try:
|
|
84
|
+
fileConfig(config.config_file_name)
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
schema = _plugin_schema(plugin_id)
|
|
89
|
+
asyncio.run(_run_async(schema, db_url, plugin_role))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Declarative bases for plugin models.
|
|
2
|
+
|
|
3
|
+
`OrgScopedTable` is the base every plugin table must inherit from. It adds
|
|
4
|
+
`organization_id` automatically and registers the class so migration tooling
|
|
5
|
+
and the build linter can verify RLS is in place.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import BigInteger, Index
|
|
11
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PluginBase(DeclarativeBase):
|
|
15
|
+
"""Declarative base for plugin models.
|
|
16
|
+
|
|
17
|
+
Prefer `OrgScopedTable` for any table that stores org data. Use `PluginBase`
|
|
18
|
+
directly only for tables that are genuinely global to the plugin (e.g. a
|
|
19
|
+
lookup table seeded by a migration). Any such use still lives inside the
|
|
20
|
+
plugin's own schema and is not cross-org by design.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OrgScopedTable(PluginBase):
|
|
25
|
+
"""Base for all per-organization plugin tables.
|
|
26
|
+
|
|
27
|
+
Inheriting from this gives you:
|
|
28
|
+
- An `organization_id BIGINT NOT NULL` column with an index.
|
|
29
|
+
- A row-level security policy, enforced at migration time via
|
|
30
|
+
`ensure_org_rls(op, "<tablename>")` in your Alembic migration.
|
|
31
|
+
|
|
32
|
+
Never write `WHERE organization_id = ?` in your queries — the database
|
|
33
|
+
will filter rows for you based on the session variable set by the
|
|
34
|
+
platform at request time.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__abstract__ = True
|
|
38
|
+
|
|
39
|
+
@declared_attr
|
|
40
|
+
def organization_id(cls) -> Mapped[int]:
|
|
41
|
+
return mapped_column(BigInteger, nullable=False, index=True)
|
|
42
|
+
|
|
43
|
+
@declared_attr.directive
|
|
44
|
+
def __table_args__(cls):
|
|
45
|
+
tablename = getattr(cls, "__tablename__", None)
|
|
46
|
+
if not tablename:
|
|
47
|
+
return ()
|
|
48
|
+
return (
|
|
49
|
+
Index(f"ix_{tablename}_org", "organization_id"),
|
|
50
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Row-level security helpers for plugin migrations.
|
|
2
|
+
|
|
3
|
+
Every plugin table that holds org data must call `ensure_org_rls(op, table)`
|
|
4
|
+
in the migration that creates it. This enables RLS and installs the standard
|
|
5
|
+
policy, which filters rows by the `app.current_org_id` session variable set
|
|
6
|
+
by the platform at request time.
|
|
7
|
+
|
|
8
|
+
The `palette build` CLI lints migrations and will reject any that:
|
|
9
|
+
- create an org table without a matching `ensure_org_rls` call,
|
|
10
|
+
- contain `DROP POLICY` or `DISABLE ROW LEVEL SECURITY`,
|
|
11
|
+
- reference the `public.` schema,
|
|
12
|
+
- set `FORCE ROW LEVEL SECURITY` off.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
ORG_POLICY_NAME = "org_isolation"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ensure_org_rls(op: Any, table_name: str, *, policy_name: str = ORG_POLICY_NAME) -> None:
|
|
23
|
+
"""Enable RLS on `table_name` and install the org-isolation policy.
|
|
24
|
+
|
|
25
|
+
Idempotent: safe to call even if the policy already exists.
|
|
26
|
+
"""
|
|
27
|
+
op.execute(f'ALTER TABLE "{table_name}" ENABLE ROW LEVEL SECURITY')
|
|
28
|
+
op.execute(f'ALTER TABLE "{table_name}" FORCE ROW LEVEL SECURITY')
|
|
29
|
+
op.execute(f'DROP POLICY IF EXISTS "{policy_name}" ON "{table_name}"')
|
|
30
|
+
op.execute(
|
|
31
|
+
f'CREATE POLICY "{policy_name}" ON "{table_name}" '
|
|
32
|
+
f"USING (organization_id = current_setting('app.current_org_id', true)::bigint) "
|
|
33
|
+
f"WITH CHECK (organization_id = current_setting('app.current_org_id', true)::bigint)"
|
|
34
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Plugin-side event subscribers.
|
|
2
|
+
|
|
3
|
+
Plugins declare in-process handlers for platform events by calling
|
|
4
|
+
`subscribe_event(topic, handler)` at import time (typically from the plugin's
|
|
5
|
+
backend entrypoint). The platform's plugin loader picks these up after
|
|
6
|
+
importing the plugin module and registers them with the core event bus.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
from palette_sdk.events import subscribe_event, Event
|
|
11
|
+
|
|
12
|
+
async def on_task_created(event: Event) -> None:
|
|
13
|
+
print(event.payload["task_id"])
|
|
14
|
+
|
|
15
|
+
subscribe_event("task.created", on_task_created)
|
|
16
|
+
|
|
17
|
+
Handlers run in the platform process — they share the asyncio loop with
|
|
18
|
+
request handlers. Do not block; offload heavy work to background tasks or
|
|
19
|
+
emit your own outbound HTTP call.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Awaitable, Callable
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
Handler = Callable[["Event"], Awaitable[None]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Event:
|
|
34
|
+
topic: str
|
|
35
|
+
organization_id: int | None
|
|
36
|
+
payload: dict[str, Any]
|
|
37
|
+
occurred_at: datetime
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Plugin-declared subscribers are captured here during module import; the
|
|
41
|
+
# platform drains the list after loading each plugin and attaches them to the
|
|
42
|
+
# core bus with the plugin's id.
|
|
43
|
+
_pending: list[tuple[str, Handler]] = []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def subscribe_event(topic: str, handler: Handler) -> None:
|
|
47
|
+
_pending.append((topic, handler))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def drain_pending() -> list[tuple[str, Handler]]:
|
|
51
|
+
"""Platform-only. Return + clear the list of subscribers registered since last drain."""
|
|
52
|
+
out = list(_pending)
|
|
53
|
+
_pending.clear()
|
|
54
|
+
return out
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Plugin manifest loader and validator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AgentDefinition(BaseModel):
|
|
13
|
+
name: str
|
|
14
|
+
field: str
|
|
15
|
+
description: str
|
|
16
|
+
long_description: str = ""
|
|
17
|
+
category: str = "general"
|
|
18
|
+
tools: list[str] = Field(default_factory=list)
|
|
19
|
+
icon_name: str = "Robot"
|
|
20
|
+
color: str = "#6366f1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ToolEntry(BaseModel):
|
|
24
|
+
name: str
|
|
25
|
+
description: str = ""
|
|
26
|
+
entry: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FrontendEntry(BaseModel):
|
|
30
|
+
entry: str
|
|
31
|
+
sandbox: bool = True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BackendEntry(BaseModel):
|
|
35
|
+
entry: str
|
|
36
|
+
routes_prefix: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GradientConfig(BaseModel):
|
|
40
|
+
bg: str
|
|
41
|
+
text: str = "#fff"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SdkCompat(BaseModel):
|
|
45
|
+
frontend: str | None = None
|
|
46
|
+
backend: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PlatformCompat(BaseModel):
|
|
50
|
+
min_version: str | None = None
|
|
51
|
+
max_version: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Capabilities(BaseModel):
|
|
55
|
+
frontend: bool = False
|
|
56
|
+
backend: bool = False
|
|
57
|
+
database: bool = False
|
|
58
|
+
webhooks: bool = False
|
|
59
|
+
scheduled_jobs: bool = False
|
|
60
|
+
file_uploads: bool = False
|
|
61
|
+
external_network: list[str] = Field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ScheduledJob(BaseModel):
|
|
65
|
+
name: str
|
|
66
|
+
schedule: str
|
|
67
|
+
handler: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RateLimit(BaseModel):
|
|
71
|
+
per_minute: int = 60
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PluginManifest(BaseModel):
|
|
75
|
+
"""Validated plugin manifest from palette-plugin.json."""
|
|
76
|
+
|
|
77
|
+
manifest_version: Literal["1"] = "1"
|
|
78
|
+
id: str
|
|
79
|
+
name: str
|
|
80
|
+
version: str = "1.0.0"
|
|
81
|
+
developer: str = ""
|
|
82
|
+
category: str = "Productivity"
|
|
83
|
+
tagline: str = ""
|
|
84
|
+
description: str = ""
|
|
85
|
+
icon: str = "Puzzle"
|
|
86
|
+
gradient: GradientConfig = GradientConfig(bg="linear-gradient(135deg, #6366F1, #8B5CF6)")
|
|
87
|
+
sdk: SdkCompat | None = None
|
|
88
|
+
platform: PlatformCompat | None = None
|
|
89
|
+
capabilities: Capabilities = Field(default_factory=Capabilities)
|
|
90
|
+
public_routes: list[str] = Field(default_factory=list)
|
|
91
|
+
scheduled_jobs: list[ScheduledJob] = Field(default_factory=list)
|
|
92
|
+
rate_limit: RateLimit | None = None
|
|
93
|
+
frontend: FrontendEntry | None = None
|
|
94
|
+
backend: BackendEntry | None = None
|
|
95
|
+
agents: list[AgentDefinition] = Field(default_factory=list)
|
|
96
|
+
tools: list[ToolEntry] = Field(default_factory=list)
|
|
97
|
+
permissions: list[str] = Field(default_factory=list)
|
|
98
|
+
rating: float = 0.0
|
|
99
|
+
reviews: int = 0
|
|
100
|
+
featured: bool = False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_manifest(plugin_dir: str | Path) -> PluginManifest:
|
|
104
|
+
"""Load and validate a palette-plugin.json from a plugin directory."""
|
|
105
|
+
manifest_path = Path(plugin_dir) / "palette-plugin.json"
|
|
106
|
+
if not manifest_path.exists():
|
|
107
|
+
raise FileNotFoundError(f"No palette-plugin.json found in {plugin_dir}")
|
|
108
|
+
|
|
109
|
+
with open(manifest_path) as f:
|
|
110
|
+
data = json.load(f)
|
|
111
|
+
|
|
112
|
+
return PluginManifest(**data)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Plugin permission enforcement.
|
|
2
|
+
|
|
3
|
+
Plugins declare required permissions in their manifest. At route level they
|
|
4
|
+
call `require_permission("resource:action")` as a dependency — the platform
|
|
5
|
+
injected `request.state.plugin_permissions` (from the manifest) is checked
|
|
6
|
+
against the required permission. Mismatch → 403.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
12
|
+
|
|
13
|
+
# Known permission vocabulary. Platform code uses this to validate manifests;
|
|
14
|
+
# plugin authors pick from this list. Extending the list is a platform change.
|
|
15
|
+
KNOWN_PERMISSIONS: frozenset[str] = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"tasks:read",
|
|
18
|
+
"tasks:write",
|
|
19
|
+
"data_rooms:read",
|
|
20
|
+
"data_rooms:write",
|
|
21
|
+
"agents:read",
|
|
22
|
+
"agents:write",
|
|
23
|
+
"chat:read",
|
|
24
|
+
"chat:write",
|
|
25
|
+
"routines:read",
|
|
26
|
+
"routines:write",
|
|
27
|
+
"members:read",
|
|
28
|
+
"resources:read",
|
|
29
|
+
"resources:write",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_known_permission(perm: str) -> bool:
|
|
35
|
+
return perm in KNOWN_PERMISSIONS
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
#: Sentinel attribute the platform loader looks for when walking a route's
|
|
39
|
+
#: dependency tree. Its presence on the `_check` closure is what proves the
|
|
40
|
+
#: route is permission-gated.
|
|
41
|
+
_PALETTE_PERMISSION_ATTR = "__palette_permission__"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def require_permission(permission: str):
|
|
45
|
+
"""Return a FastAPI dependency that asserts the plugin was granted `permission`.
|
|
46
|
+
|
|
47
|
+
Usage (inside a plugin's FastAPI router)::
|
|
48
|
+
|
|
49
|
+
@router.get("/my-endpoint", dependencies=[Depends(require_permission("tasks:read"))])
|
|
50
|
+
async def handler(...):
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
Every plugin route MUST be gated by at least one `require_permission` call
|
|
54
|
+
(or be listed in the manifest's `public_routes`). The platform inspects the
|
|
55
|
+
dependency tree at mount time and refuses to load the plugin otherwise.
|
|
56
|
+
|
|
57
|
+
Raises 403 if the plugin's manifest doesn't list the permission. Raises 500
|
|
58
|
+
if the request wasn't dispatched through the plugin loader (no
|
|
59
|
+
plugin_permissions in request.state) — that indicates a misconfigured
|
|
60
|
+
deployment.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
async def _check(request: Request) -> None:
|
|
64
|
+
granted = getattr(request.state, "plugin_permissions", None)
|
|
65
|
+
if granted is None:
|
|
66
|
+
raise HTTPException(
|
|
67
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
68
|
+
detail="plugin permission context missing",
|
|
69
|
+
)
|
|
70
|
+
if permission not in granted:
|
|
71
|
+
raise HTTPException(
|
|
72
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
73
|
+
detail=f"plugin lacks permission: {permission}",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# The loader walks route.dependant recursively and refuses to mount any
|
|
77
|
+
# route without at least one dep whose callable has this attribute set.
|
|
78
|
+
setattr(_check, _PALETTE_PERMISSION_ATTR, permission)
|
|
79
|
+
return Depends(_check)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_permission_dep(callable_: object) -> str | None:
|
|
83
|
+
"""Return the permission string if `callable_` was produced by
|
|
84
|
+
`require_permission(...)`, otherwise None. Used by the platform loader.
|
|
85
|
+
"""
|
|
86
|
+
return getattr(callable_, _PALETTE_PERMISSION_ATTR, None)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = [
|
|
90
|
+
"KNOWN_PERMISSIONS",
|
|
91
|
+
"is_known_permission",
|
|
92
|
+
"require_permission",
|
|
93
|
+
"is_permission_dep",
|
|
94
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Plugin execution context — injected by the platform runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, Request
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class PluginContext:
|
|
14
|
+
"""Context provided to plugin endpoints by the platform.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
db: Async SQLAlchemy session. If the plugin declares a `database` block
|
|
18
|
+
in its manifest, this is a plugin-scoped session: `search_path` is
|
|
19
|
+
set to the plugin's schema and the `app.current_org_id` session
|
|
20
|
+
variable is applied, so row-level security filters queries to the
|
|
21
|
+
caller's organization automatically. Do not add `WHERE
|
|
22
|
+
organization_id = ?` — RLS already does it.
|
|
23
|
+
user_id: UUID of the authenticated user
|
|
24
|
+
organization_id: Current org ID
|
|
25
|
+
org_role: User's role in the org (owner/admin/member)
|
|
26
|
+
plugin_id: The plugin's ID from its manifest
|
|
27
|
+
permissions: List of permissions declared in the manifest
|
|
28
|
+
storage: Storage service for file upload/download
|
|
29
|
+
"""
|
|
30
|
+
db: AsyncSession
|
|
31
|
+
user_id: str
|
|
32
|
+
organization_id: int
|
|
33
|
+
org_role: str | None = None
|
|
34
|
+
plugin_id: str = ""
|
|
35
|
+
permissions: list[str] = field(default_factory=list)
|
|
36
|
+
storage: Any = None # Platform storage service injected at runtime
|
|
37
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def get_plugin_context(request: Request) -> PluginContext:
|
|
41
|
+
"""FastAPI dependency that extracts plugin context from the request.
|
|
42
|
+
|
|
43
|
+
The platform's plugin loader injects the necessary state into
|
|
44
|
+
request.state before the plugin endpoint is called.
|
|
45
|
+
|
|
46
|
+
Usage:
|
|
47
|
+
@router.get("/data")
|
|
48
|
+
async def get_data(ctx: PluginContext = Depends(get_plugin_context)):
|
|
49
|
+
result = await ctx.db.execute(...)
|
|
50
|
+
return result
|
|
51
|
+
"""
|
|
52
|
+
state = request.state
|
|
53
|
+
|
|
54
|
+
return PluginContext(
|
|
55
|
+
db=state.db,
|
|
56
|
+
user_id=str(state.user.id),
|
|
57
|
+
organization_id=state.user.organization_id,
|
|
58
|
+
org_role=getattr(state, "org_role", None),
|
|
59
|
+
plugin_id=getattr(state, "plugin_id", ""),
|
|
60
|
+
permissions=getattr(state, "plugin_permissions", []),
|
|
61
|
+
storage=getattr(state, "storage", None),
|
|
62
|
+
config=getattr(state, "plugin_config", {}),
|
|
63
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Plugin-scoped FastAPI router with automatic prefix and auth."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PluginRouter(APIRouter):
|
|
7
|
+
"""A FastAPI router scoped to a plugin.
|
|
8
|
+
|
|
9
|
+
Routes defined on this router will be automatically mounted
|
|
10
|
+
under /api/v1/plugins/{plugin_id}/ by the platform loader.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
from palette_sdk import PluginRouter
|
|
14
|
+
|
|
15
|
+
router = PluginRouter(tags=["my-plugin"])
|
|
16
|
+
|
|
17
|
+
@router.get("/stats")
|
|
18
|
+
async def get_stats(ctx = Depends(get_plugin_context)):
|
|
19
|
+
# This will be available at /api/v1/plugins/my-plugin/stats
|
|
20
|
+
...
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, **kwargs):
|
|
24
|
+
# Inherit FastAPI's default response class (JSONResponse). The
|
|
25
|
+
# previous code set this to None, which broke both the response
|
|
26
|
+
# pipeline (TypeError at serialization) and OpenAPI generation.
|
|
27
|
+
super().__init__(**kwargs)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Common response schemas for plugin APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SuccessResponse(BaseModel):
|
|
13
|
+
"""Generic success response."""
|
|
14
|
+
success: bool = True
|
|
15
|
+
message: str = "OK"
|
|
16
|
+
data: Any = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ErrorResponse(BaseModel):
|
|
20
|
+
"""Generic error response."""
|
|
21
|
+
success: bool = False
|
|
22
|
+
detail: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PaginatedResponse(BaseModel, Generic[T]):
|
|
26
|
+
"""Paginated list response."""
|
|
27
|
+
items: list[Any]
|
|
28
|
+
total: int
|
|
29
|
+
page: int = 1
|
|
30
|
+
page_size: int = 50
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Testing helpers for Palette backend plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def route_permission_issues(router: APIRouter, public_routes: list[str] | None = None) -> list[str]:
|
|
9
|
+
"""Return route paths that lack `require_permission`.
|
|
10
|
+
|
|
11
|
+
This mirrors the platform loader's mount-time check and lets plugin tests
|
|
12
|
+
fail before publish.
|
|
13
|
+
"""
|
|
14
|
+
from palette_sdk.permissions import is_permission_dep
|
|
15
|
+
|
|
16
|
+
allowed = set(public_routes or [])
|
|
17
|
+
issues: list[str] = []
|
|
18
|
+
|
|
19
|
+
for route in router.routes:
|
|
20
|
+
path = getattr(route, "path", None)
|
|
21
|
+
if path is None or path in allowed:
|
|
22
|
+
continue
|
|
23
|
+
dependant = getattr(route, "dependant", None)
|
|
24
|
+
if dependant is None:
|
|
25
|
+
continue
|
|
26
|
+
|
|
27
|
+
stack = [dependant]
|
|
28
|
+
seen: set[int] = set()
|
|
29
|
+
gated = False
|
|
30
|
+
while stack:
|
|
31
|
+
dep = stack.pop()
|
|
32
|
+
if id(dep) in seen:
|
|
33
|
+
continue
|
|
34
|
+
seen.add(id(dep))
|
|
35
|
+
call = getattr(dep, "call", None)
|
|
36
|
+
if call is not None and is_permission_dep(call):
|
|
37
|
+
gated = True
|
|
38
|
+
break
|
|
39
|
+
stack.extend(getattr(dep, "dependencies", []) or [])
|
|
40
|
+
|
|
41
|
+
if not gated:
|
|
42
|
+
methods = ",".join(sorted(getattr(route, "methods", []) or [])) or "?"
|
|
43
|
+
issues.append(f"{methods} {path}")
|
|
44
|
+
|
|
45
|
+
return issues
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Base class for defining custom agent tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ToolDefinition(ABC):
|
|
10
|
+
"""Base class for custom agent tools provided by plugins.
|
|
11
|
+
|
|
12
|
+
Subclass this to create tools that agents can use in chat conversations.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
class ChartTool(ToolDefinition):
|
|
16
|
+
name = "generate_chart"
|
|
17
|
+
description = "Generate a chart from data"
|
|
18
|
+
input_schema = {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"chart_type": {"type": "string", "enum": ["bar", "line", "pie"]},
|
|
22
|
+
"data": {"type": "array", "items": {"type": "object"}},
|
|
23
|
+
"title": {"type": "string"},
|
|
24
|
+
},
|
|
25
|
+
"required": ["chart_type", "data"],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async def run(self, input_data: dict, context: dict) -> str:
|
|
29
|
+
# Generate chart and return result
|
|
30
|
+
return "Chart generated: https://..."
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
name: str = ""
|
|
34
|
+
description: str = ""
|
|
35
|
+
input_schema: dict[str, Any] = {}
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def run(self, input_data: dict[str, Any], context: dict[str, Any]) -> str:
|
|
39
|
+
"""Execute the tool with the given input.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
input_data: Validated input matching the input_schema
|
|
43
|
+
context: Runtime context with user_id, org_id, db session, etc.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
String result to be sent back to the agent
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def to_langchain_tool(self):
|
|
51
|
+
"""Convert to a LangChain-compatible tool function.
|
|
52
|
+
|
|
53
|
+
Called by the platform when building an agent's tool list.
|
|
54
|
+
Plugin developers don't need to call this directly.
|
|
55
|
+
"""
|
|
56
|
+
from langchain_core.tools import StructuredTool
|
|
57
|
+
|
|
58
|
+
async def _run(**kwargs):
|
|
59
|
+
return await self.run(kwargs, {})
|
|
60
|
+
|
|
61
|
+
return StructuredTool.from_function(
|
|
62
|
+
coroutine=_run,
|
|
63
|
+
name=self.name,
|
|
64
|
+
description=self.description,
|
|
65
|
+
args_schema=None,
|
|
66
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Small webhook utilities for plugin authors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def sign_webhook(secret: str, payload: bytes) -> str:
|
|
10
|
+
"""Return a hex HMAC-SHA256 signature for `payload`."""
|
|
11
|
+
return hmac.new(secret.encode(), payload, sha256).hexdigest()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def verify_webhook_signature(secret: str, payload: bytes, signature: str) -> bool:
|
|
15
|
+
"""Constant-time HMAC-SHA256 verification."""
|
|
16
|
+
expected = sign_webhook(secret, payload)
|
|
17
|
+
return hmac.compare_digest(expected, signature)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "palette-sdk"
|
|
3
|
+
version = "0.1.3"
|
|
4
|
+
description = "Palette Platform SDK for building backend plugins"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Palette Platform" }]
|
|
9
|
+
keywords = ["palette", "plugin", "sdk", "fastapi"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Framework :: FastAPI",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"fastapi>=0.129.0",
|
|
18
|
+
"pydantic>=2.12.0",
|
|
19
|
+
"sqlalchemy>=2.0.47",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/palette-lab/palette-virtual-organization-backend/releases"
|
|
24
|
+
Repository = "https://github.com/palette-lab/palette-virtual-organization-backend"
|
|
25
|
+
Documentation = "https://github.com/palette-lab/palette-virtual-organization-backend/releases"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["setuptools>=75.0"]
|
|
29
|
+
build-backend = "setuptools.build_meta"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
include = ["palette_sdk*"]
|
package/lib/commands/test.js
CHANGED
|
@@ -36,8 +36,11 @@ function reporter(json, results) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
function localBackendSdkPath() {
|
|
39
|
-
const
|
|
40
|
-
|
|
39
|
+
const candidates = [
|
|
40
|
+
path.resolve(__dirname, "..", "..", "..", "backend"),
|
|
41
|
+
path.resolve(__dirname, "..", "..", "backend-sdk"),
|
|
42
|
+
]
|
|
43
|
+
return candidates.find((candidate) => fs.existsSync(path.join(candidate, "palette_sdk"))) || null
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
function localBackendSdkPython() {
|
|
@@ -432,7 +435,8 @@ function backendSdkVersionFromDependency(cwd) {
|
|
|
432
435
|
const constrained = String(dep).match(/^palette-sdk\s*(?:[<>=~!]=?\s*)?(\d+\.\d+\.\d+)/)
|
|
433
436
|
if (constrained) return constrained[1]
|
|
434
437
|
}
|
|
435
|
-
const
|
|
438
|
+
const sdkPath = localBackendSdkPath()
|
|
439
|
+
const localPyproject = sdkPath ? path.join(sdkPath, "pyproject.toml") : ""
|
|
436
440
|
if (fs.existsSync(localPyproject)) {
|
|
437
441
|
const match = fs.readFileSync(localPyproject, "utf8").match(/^version\s*=\s*["']([^"']+)["']/m)
|
|
438
442
|
if (match) return match[1]
|
package/package.json
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@palettelab/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"description": "Developer CLI for building Palette platform plugins — no platform source access required.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pltt": "bin/pltt.js"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
"bin",
|
|
10
|
+
"backend-sdk",
|
|
10
11
|
"lib",
|
|
11
12
|
"platform-dev",
|
|
12
13
|
"template-fallback",
|
|
14
|
+
"!backend-sdk/**/__pycache__",
|
|
15
|
+
"!backend-sdk/**/*.pyc",
|
|
13
16
|
"!template-fallback/**/__pycache__",
|
|
14
17
|
"!template-fallback/**/*.pyc",
|
|
15
18
|
"palette.config.example.json",
|
|
@@ -14,5 +14,6 @@ requires-python = ">=3.12"
|
|
|
14
14
|
dependencies = [
|
|
15
15
|
"fastapi>=0.129.0",
|
|
16
16
|
"sqlalchemy>=2.0.47",
|
|
17
|
-
|
|
17
|
+
# `pltt test` ships the backend SDK on PYTHONPATH. Pin a released
|
|
18
|
+
# palette-sdk package here only if you run backend tests outside the CLI.
|
|
18
19
|
]
|
|
@@ -5,5 +5,6 @@ requires-python = ">=3.12"
|
|
|
5
5
|
dependencies = [
|
|
6
6
|
"fastapi>=0.129.0",
|
|
7
7
|
"pydantic>=2.12.0",
|
|
8
|
-
"
|
|
8
|
+
"sqlalchemy>=2.0.47",
|
|
9
|
+
# `pltt test` ships the backend SDK on PYTHONPATH.
|
|
9
10
|
]
|
|
@@ -4,5 +4,6 @@ version = "1.0.0"
|
|
|
4
4
|
requires-python = ">=3.12"
|
|
5
5
|
dependencies = [
|
|
6
6
|
"fastapi>=0.129.0",
|
|
7
|
-
"
|
|
7
|
+
"sqlalchemy>=2.0.47",
|
|
8
|
+
# `pltt test` ships the backend SDK on PYTHONPATH.
|
|
8
9
|
]
|
|
@@ -6,5 +6,5 @@ dependencies = [
|
|
|
6
6
|
"fastapi>=0.129.0",
|
|
7
7
|
"sqlalchemy>=2.0.47",
|
|
8
8
|
"alembic>=1.17.0",
|
|
9
|
-
|
|
9
|
+
# `pltt test` ships the backend SDK on PYTHONPATH.
|
|
10
10
|
]
|
|
@@ -5,5 +5,6 @@ requires-python = ">=3.12"
|
|
|
5
5
|
dependencies = [
|
|
6
6
|
"fastapi>=0.129.0",
|
|
7
7
|
"httpx>=0.28.0",
|
|
8
|
-
"
|
|
8
|
+
"sqlalchemy>=2.0.47",
|
|
9
|
+
# `pltt test` ships the backend SDK on PYTHONPATH.
|
|
9
10
|
]
|