nself-plugin-sdk 1.2.5__py3-none-any.whl
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.
- nself_plugin/__init__.py +54 -0
- nself_plugin/_manifest.py +96 -0
- nself_plugin/_runner.py +45 -0
- nself_plugin/context/__init__.py +1 -0
- nself_plugin/context/db.py +73 -0
- nself_plugin/context/env.py +57 -0
- nself_plugin/context/hasura.py +97 -0
- nself_plugin/context/http.py +101 -0
- nself_plugin/context/nginx.py +39 -0
- nself_plugin/plugin.py +113 -0
- nself_plugin_sdk-1.2.5.dist-info/METADATA +74 -0
- nself_plugin_sdk-1.2.5.dist-info/RECORD +13 -0
- nself_plugin_sdk-1.2.5.dist-info/WHEEL +4 -0
nself_plugin/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nself-plugin — Python plugin authoring SDK for nSelf.
|
|
3
|
+
|
|
4
|
+
Mirror of plugin-sdk-go v0.1.0: lifecycle hooks (install, start, health,
|
|
5
|
+
migrate, uninstall), PluginContext with env/hasura/nginx/db/http/logger
|
|
6
|
+
helpers, and a plugin.yaml manifest parser.
|
|
7
|
+
|
|
8
|
+
Example::
|
|
9
|
+
|
|
10
|
+
from nself_plugin import Plugin, PluginContext, HealthStatus
|
|
11
|
+
|
|
12
|
+
plugin = Plugin(name="my-notify", version="1.0.0")
|
|
13
|
+
|
|
14
|
+
@plugin.install
|
|
15
|
+
async def install(ctx: PluginContext) -> None:
|
|
16
|
+
ctx.env.require(["SMTP_HOST", "SMTP_PORT"])
|
|
17
|
+
|
|
18
|
+
@plugin.start
|
|
19
|
+
async def start(ctx: PluginContext) -> None:
|
|
20
|
+
app = ctx.http.create_app()
|
|
21
|
+
app.get("/notify/healthz")(lambda: {"status": "ok"})
|
|
22
|
+
await ctx.http.listen(ctx.port)
|
|
23
|
+
|
|
24
|
+
@plugin.health
|
|
25
|
+
async def health(ctx: PluginContext) -> HealthStatus:
|
|
26
|
+
return await ctx.http.ping("/notify/healthz")
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from .plugin import Plugin, PluginInfo, HealthStatus
|
|
30
|
+
from .context.env import EnvHelper
|
|
31
|
+
from .context.hasura import HasuraHelper
|
|
32
|
+
from .context.nginx import NginxHelper
|
|
33
|
+
from .context.db import DatabaseHelper
|
|
34
|
+
from .context.http import HttpHelper
|
|
35
|
+
from ._manifest import PluginManifest, parse_manifest, parse_manifest_string, ManifestValidationError
|
|
36
|
+
from ._runner import PluginContext
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Plugin",
|
|
40
|
+
"PluginInfo",
|
|
41
|
+
"HealthStatus",
|
|
42
|
+
"PluginContext",
|
|
43
|
+
"EnvHelper",
|
|
44
|
+
"HasuraHelper",
|
|
45
|
+
"NginxHelper",
|
|
46
|
+
"DatabaseHelper",
|
|
47
|
+
"HttpHelper",
|
|
48
|
+
"PluginManifest",
|
|
49
|
+
"parse_manifest",
|
|
50
|
+
"parse_manifest_string",
|
|
51
|
+
"ManifestValidationError",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
plugin.yaml manifest parser and validator.
|
|
3
|
+
Mirrors the manifest parser in plugin-sdk-go (config/config.go + plugin.Info).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ManifestValidationError(ValueError):
|
|
16
|
+
"""Raised when the plugin.yaml manifest is missing required fields or malformed."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, field_name: Optional[str] = None) -> None:
|
|
19
|
+
self.field_name = field_name
|
|
20
|
+
super().__init__(f"plugin manifest: {message}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class PluginManifest:
|
|
25
|
+
"""Full plugin manifest shape as declared in plugin.yaml."""
|
|
26
|
+
name: str
|
|
27
|
+
version: str
|
|
28
|
+
tier: str # "free" | "pro"
|
|
29
|
+
description: str = ""
|
|
30
|
+
bundle: str = ""
|
|
31
|
+
min_cli: str = ""
|
|
32
|
+
min_sdk: str = ""
|
|
33
|
+
port: int = 0
|
|
34
|
+
routes: list[dict[str, str]] = field(default_factory=list)
|
|
35
|
+
required_env: list[str] = field(default_factory=list)
|
|
36
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_manifest(file_path: str = "plugin.yaml") -> PluginManifest:
|
|
40
|
+
"""
|
|
41
|
+
Read and validate a plugin.yaml file.
|
|
42
|
+
Raises ManifestValidationError on missing required fields.
|
|
43
|
+
"""
|
|
44
|
+
path = Path(file_path).resolve()
|
|
45
|
+
try:
|
|
46
|
+
raw = path.read_text(encoding="utf-8")
|
|
47
|
+
except OSError as exc:
|
|
48
|
+
raise ManifestValidationError(f"cannot read file: {path}: {exc}") from exc
|
|
49
|
+
return parse_manifest_string(raw)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_manifest_string(content: str) -> PluginManifest:
|
|
53
|
+
"""
|
|
54
|
+
Parse plugin.yaml content from a string. Useful in tests.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
data = yaml.safe_load(content)
|
|
58
|
+
except yaml.YAMLError as exc:
|
|
59
|
+
raise ManifestValidationError(f"YAML parse error: {exc}") from exc
|
|
60
|
+
|
|
61
|
+
return _validate(data)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _validate(data: object) -> PluginManifest:
|
|
65
|
+
if not isinstance(data, dict):
|
|
66
|
+
raise ManifestValidationError("manifest must be a YAML object")
|
|
67
|
+
|
|
68
|
+
name = data.get("name")
|
|
69
|
+
if not name or not isinstance(name, str):
|
|
70
|
+
raise ManifestValidationError("name is required and must be a string", "name")
|
|
71
|
+
|
|
72
|
+
version = data.get("version")
|
|
73
|
+
if not version or not isinstance(version, str):
|
|
74
|
+
# pyyaml parses bare 1.0.0 as a float; coerce to string
|
|
75
|
+
if isinstance(version, (int, float)):
|
|
76
|
+
version = str(version)
|
|
77
|
+
else:
|
|
78
|
+
raise ManifestValidationError("version is required and must be a string", "version")
|
|
79
|
+
|
|
80
|
+
tier = data.get("tier")
|
|
81
|
+
if tier not in ("free", "pro"):
|
|
82
|
+
raise ManifestValidationError('tier must be "free" or "pro"', "tier")
|
|
83
|
+
|
|
84
|
+
return PluginManifest(
|
|
85
|
+
name=name,
|
|
86
|
+
version=version,
|
|
87
|
+
tier=tier,
|
|
88
|
+
description=data.get("description", ""),
|
|
89
|
+
bundle=data.get("bundle", ""),
|
|
90
|
+
min_cli=data.get("minCli", ""),
|
|
91
|
+
min_sdk=data.get("minSdk", ""),
|
|
92
|
+
port=int(data.get("port", 0)),
|
|
93
|
+
routes=data.get("routes") or [],
|
|
94
|
+
required_env=data.get("requiredEnv") or [],
|
|
95
|
+
metadata=data.get("metadata") or {},
|
|
96
|
+
)
|
nself_plugin/_runner.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lifecycle event orchestrator and PluginContext dataclass.
|
|
3
|
+
Mirrors the runner.go concept in plugin-sdk-go.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from .context.env import EnvHelper
|
|
12
|
+
from .context.hasura import HasuraHelper
|
|
13
|
+
from .context.nginx import NginxHelper
|
|
14
|
+
from .context.db import DatabaseHelper
|
|
15
|
+
from .context.http import HttpHelper
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class PluginContext:
|
|
20
|
+
"""
|
|
21
|
+
Runtime context passed to every lifecycle hook.
|
|
22
|
+
Mirrors PluginContext in the spec and the helpers in plugin-sdk-go.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# TCP port the plugin should bind to (from NSELF_PLUGIN_PORT env var).
|
|
26
|
+
port: int
|
|
27
|
+
|
|
28
|
+
# Structured logger keyed to the plugin name.
|
|
29
|
+
logger: logging.Logger
|
|
30
|
+
|
|
31
|
+
# Env var helpers: require(), get(), get_int(), get_bool().
|
|
32
|
+
env: EnvHelper
|
|
33
|
+
|
|
34
|
+
# Hasura metadata API wrapper.
|
|
35
|
+
hasura: HasuraHelper
|
|
36
|
+
|
|
37
|
+
# Nginx route injection helper.
|
|
38
|
+
nginx: NginxHelper
|
|
39
|
+
|
|
40
|
+
# PostgreSQL helper for migration-time DDL only.
|
|
41
|
+
# Not for query-time use — plugins talk to Hasura GraphQL.
|
|
42
|
+
db: DatabaseHelper
|
|
43
|
+
|
|
44
|
+
# FastAPI app factory + httpx health client.
|
|
45
|
+
http: HttpHelper
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# context sub-package — individual helpers imported from their modules
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PostgreSQL helper — migration-time DDL only.
|
|
3
|
+
Mirrors db.Pool in plugin-sdk-go: wraps asyncpg, exposes run_sql().
|
|
4
|
+
NOT for query-time use — plugins must talk to Hasura GraphQL at runtime.
|
|
5
|
+
asyncpg is an optional dependency (extras = ["db"]).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class SqlResult:
|
|
16
|
+
row_count: int
|
|
17
|
+
rows: list[dict[str, Any]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DatabaseHelper:
|
|
21
|
+
"""
|
|
22
|
+
PostgreSQL helper exposed in PluginContext.
|
|
23
|
+
Use only in install/migrate/uninstall hooks, never at request time.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, database_url: str) -> None:
|
|
27
|
+
self._database_url = database_url
|
|
28
|
+
self._pool: Any = None # asyncpg.Pool, typed as Any to avoid hard dep
|
|
29
|
+
|
|
30
|
+
async def _get_pool(self) -> Any:
|
|
31
|
+
if self._pool is not None:
|
|
32
|
+
return self._pool
|
|
33
|
+
try:
|
|
34
|
+
import asyncpg # type: ignore[import-untyped]
|
|
35
|
+
except ImportError as exc:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
"plugin SDK: asyncpg is required for database operations. "
|
|
38
|
+
"Install with: pip install 'nself-plugin[db]'"
|
|
39
|
+
) from exc
|
|
40
|
+
self._pool = await asyncpg.create_pool(self._database_url)
|
|
41
|
+
return self._pool
|
|
42
|
+
|
|
43
|
+
async def run_sql(self, sql: str, *params: Any) -> SqlResult:
|
|
44
|
+
"""Execute raw SQL. Migration-time only."""
|
|
45
|
+
pool = await self._get_pool()
|
|
46
|
+
async with pool.acquire() as conn:
|
|
47
|
+
result = await conn.fetch(sql, *params)
|
|
48
|
+
rows = [dict(r) for r in result]
|
|
49
|
+
return SqlResult(row_count=len(rows), rows=rows)
|
|
50
|
+
|
|
51
|
+
async def close(self) -> None:
|
|
52
|
+
"""Close the connection pool. Called by the runner on shutdown."""
|
|
53
|
+
if self._pool is not None:
|
|
54
|
+
await self._pool.close()
|
|
55
|
+
self._pool = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DatabaseHelperStub(DatabaseHelper):
|
|
59
|
+
"""
|
|
60
|
+
No-op stub for unit tests — no real DB required.
|
|
61
|
+
Usage::
|
|
62
|
+
|
|
63
|
+
ctx = PluginContext(..., db=DatabaseHelperStub())
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
super().__init__(database_url="")
|
|
68
|
+
|
|
69
|
+
async def run_sql(self, sql: str, *params: Any) -> SqlResult:
|
|
70
|
+
return SqlResult(row_count=0, rows=[])
|
|
71
|
+
|
|
72
|
+
async def close(self) -> None:
|
|
73
|
+
pass
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Env var helpers — mirrors config.Env / config.EnvRequired in plugin-sdk-go.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EnvHelper:
|
|
12
|
+
"""
|
|
13
|
+
Env var helper exposed in PluginContext.
|
|
14
|
+
Mirrors config.Env, config.EnvRequired, etc. in plugin-sdk-go.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
|
18
|
+
"""Return the env var value or default."""
|
|
19
|
+
v = os.environ.get(key)
|
|
20
|
+
if v is not None and v != "":
|
|
21
|
+
return v
|
|
22
|
+
return default
|
|
23
|
+
|
|
24
|
+
def require(self, keys: list[str]) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Assert that all named env vars are non-empty.
|
|
27
|
+
Raises ValueError on first missing var — fail-fast at install/start time.
|
|
28
|
+
"""
|
|
29
|
+
for key in keys:
|
|
30
|
+
if not os.environ.get(key):
|
|
31
|
+
raise ValueError(f"plugin SDK: required env var {key} is not set")
|
|
32
|
+
|
|
33
|
+
def get_int(self, key: str, default: int) -> int:
|
|
34
|
+
"""Return the env var parsed as an integer, or default."""
|
|
35
|
+
v = os.environ.get(key)
|
|
36
|
+
if not v:
|
|
37
|
+
return default
|
|
38
|
+
try:
|
|
39
|
+
return int(v)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return default
|
|
42
|
+
|
|
43
|
+
def get_bool(self, key: str, default: bool) -> bool:
|
|
44
|
+
"""Return the env var parsed as boolean."""
|
|
45
|
+
v = (os.environ.get(key) or "").lower()
|
|
46
|
+
if v in ("true", "1", "yes", "y", "on"):
|
|
47
|
+
return True
|
|
48
|
+
if v in ("false", "0", "no", "n", "off"):
|
|
49
|
+
return False
|
|
50
|
+
return default
|
|
51
|
+
|
|
52
|
+
def get_list(self, key: str) -> list[str]:
|
|
53
|
+
"""Split the env var on commas and trim whitespace. Empty → []."""
|
|
54
|
+
v = os.environ.get(key, "")
|
|
55
|
+
if not v:
|
|
56
|
+
return []
|
|
57
|
+
return [s.strip() for s in v.split(",") if s.strip()]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hasura metadata API wrapper for install/migrate/uninstall hooks.
|
|
3
|
+
Mirrors HasuraHelper in the spec. Uses Hasura Metadata API v3 via httpx.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class TrackRelationshipOptions:
|
|
16
|
+
from_table: str
|
|
17
|
+
name: str
|
|
18
|
+
type: str # "object" | "array"
|
|
19
|
+
to_table: str
|
|
20
|
+
from_column: str
|
|
21
|
+
to_column: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HasuraHelper:
|
|
25
|
+
"""
|
|
26
|
+
Hasura metadata API wrapper.
|
|
27
|
+
Async methods — use in async hooks (install, migrate, uninstall).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, admin_secret: str, endpoint: str) -> None:
|
|
31
|
+
self._admin_secret = admin_secret
|
|
32
|
+
self._endpoint = endpoint.rstrip("/")
|
|
33
|
+
|
|
34
|
+
async def _post(self, path: str, body: dict[str, Any]) -> Any:
|
|
35
|
+
url = f"{self._endpoint}{path}"
|
|
36
|
+
async with httpx.AsyncClient() as client:
|
|
37
|
+
res = await client.post(
|
|
38
|
+
url,
|
|
39
|
+
json=body,
|
|
40
|
+
headers={
|
|
41
|
+
"Content-Type": "application/json",
|
|
42
|
+
"x-hasura-admin-secret": self._admin_secret,
|
|
43
|
+
},
|
|
44
|
+
timeout=30,
|
|
45
|
+
)
|
|
46
|
+
if not res.is_success:
|
|
47
|
+
raise RuntimeError(
|
|
48
|
+
f"hasura: POST {path} failed ({res.status_code}): {res.text}"
|
|
49
|
+
)
|
|
50
|
+
return res.json()
|
|
51
|
+
|
|
52
|
+
async def add_table(self, table: str, schema: str = "public") -> None:
|
|
53
|
+
"""Track a table in Hasura metadata."""
|
|
54
|
+
await self._post("/v1/metadata", {
|
|
55
|
+
"type": "pg_track_table",
|
|
56
|
+
"args": {"source": "default", "schema": schema, "name": table},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
async def remove_table(self, table: str, schema: str = "public") -> None:
|
|
60
|
+
"""Untrack a table from Hasura metadata."""
|
|
61
|
+
await self._post("/v1/metadata", {
|
|
62
|
+
"type": "pg_untrack_table",
|
|
63
|
+
"args": {"table": {"schema": schema, "name": table}, "cascade": False},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
async def track_relationship(self, opts: TrackRelationshipOptions) -> None:
|
|
67
|
+
"""Add a relationship between two tracked tables."""
|
|
68
|
+
api_type = (
|
|
69
|
+
"pg_create_object_relationship"
|
|
70
|
+
if opts.type == "object"
|
|
71
|
+
else "pg_create_array_relationship"
|
|
72
|
+
)
|
|
73
|
+
await self._post("/v1/metadata", {
|
|
74
|
+
"type": api_type,
|
|
75
|
+
"args": {
|
|
76
|
+
"source": "default",
|
|
77
|
+
"table": opts.from_table,
|
|
78
|
+
"name": opts.name,
|
|
79
|
+
"using": {
|
|
80
|
+
"foreign_key_constraint_on": {
|
|
81
|
+
"table": opts.to_table,
|
|
82
|
+
"columns": [opts.to_column],
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
async def run_metadata(self, type_: str, args: dict[str, Any]) -> Any:
|
|
89
|
+
"""Raw Hasura metadata API request (escape hatch)."""
|
|
90
|
+
return await self._post("/v1/metadata", {"type": type_, "args": args})
|
|
91
|
+
|
|
92
|
+
async def run_sql(self, sql: str) -> None:
|
|
93
|
+
"""Run a SQL migration via Hasura's run_sql API."""
|
|
94
|
+
await self._post("/v2/query", {
|
|
95
|
+
"type": "run_sql",
|
|
96
|
+
"args": {"source": "default", "sql": sql},
|
|
97
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP helper: FastAPI app factory + httpx health client.
|
|
3
|
+
Mirrors server.New() in plugin-sdk-go: auto-mounts /healthz, /readyz,
|
|
4
|
+
/version on every plugin server.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any, Callable, Optional, cast
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from fastapi import FastAPI
|
|
14
|
+
|
|
15
|
+
from ..plugin import HealthStatus
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HttpHelper:
|
|
19
|
+
"""HTTP helper exposed in PluginContext."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
plugin_name: str,
|
|
24
|
+
plugin_version: str,
|
|
25
|
+
port: int,
|
|
26
|
+
health_check: Optional[Callable[[], Any]] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._plugin_name = plugin_name
|
|
29
|
+
self._plugin_version = plugin_version
|
|
30
|
+
self._port = port
|
|
31
|
+
self._health_check = health_check
|
|
32
|
+
self._app: Optional[FastAPI] = None
|
|
33
|
+
self._logger = logging.getLogger(f"nself.{plugin_name}.http")
|
|
34
|
+
|
|
35
|
+
def create_app(self) -> FastAPI:
|
|
36
|
+
"""
|
|
37
|
+
Return a FastAPI application pre-configured with:
|
|
38
|
+
- GET /healthz → liveness (always 200)
|
|
39
|
+
- GET /readyz → delegates to health_check
|
|
40
|
+
- GET /version → plugin name + version JSON
|
|
41
|
+
"""
|
|
42
|
+
if self._app is not None:
|
|
43
|
+
return self._app
|
|
44
|
+
|
|
45
|
+
app = FastAPI(title=self._plugin_name, version=self._plugin_version)
|
|
46
|
+
|
|
47
|
+
plugin_name = self._plugin_name
|
|
48
|
+
plugin_version = self._plugin_version
|
|
49
|
+
health_check = self._health_check
|
|
50
|
+
|
|
51
|
+
@app.get("/healthz")
|
|
52
|
+
async def healthz() -> dict[str, str]:
|
|
53
|
+
return {"status": "ok", "plugin": plugin_name, "version": plugin_version}
|
|
54
|
+
|
|
55
|
+
@app.get("/readyz")
|
|
56
|
+
async def readyz() -> dict[str, Any]:
|
|
57
|
+
if health_check is not None:
|
|
58
|
+
result = await health_check()
|
|
59
|
+
if isinstance(result, HealthStatus):
|
|
60
|
+
return {"status": result.status, "message": result.message}
|
|
61
|
+
return cast(dict[str, Any], result)
|
|
62
|
+
return {"status": "ready"}
|
|
63
|
+
|
|
64
|
+
@app.get("/version")
|
|
65
|
+
async def version() -> dict[str, str]:
|
|
66
|
+
return {
|
|
67
|
+
"plugin": plugin_name,
|
|
68
|
+
"version": plugin_version,
|
|
69
|
+
"sdk": "nself-plugin",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
self._app = app
|
|
73
|
+
return app
|
|
74
|
+
|
|
75
|
+
async def listen(self, port: int) -> None:
|
|
76
|
+
"""Start the uvicorn server on the given port. Blocks until shutdown."""
|
|
77
|
+
import uvicorn
|
|
78
|
+
|
|
79
|
+
app = self.create_app()
|
|
80
|
+
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="info")
|
|
81
|
+
server = uvicorn.Server(config)
|
|
82
|
+
self._logger.info("plugin listening", extra={"port": port})
|
|
83
|
+
await server.serve()
|
|
84
|
+
|
|
85
|
+
async def ping(self, path: str) -> HealthStatus:
|
|
86
|
+
"""
|
|
87
|
+
Send GET to path on localhost and return a HealthStatus.
|
|
88
|
+
Used in the health hook to check this plugin's own /healthz endpoint.
|
|
89
|
+
"""
|
|
90
|
+
url = f"http://127.0.0.1:{self._port}{path}"
|
|
91
|
+
try:
|
|
92
|
+
async with httpx.AsyncClient() as client:
|
|
93
|
+
res = await client.get(url, timeout=5.0)
|
|
94
|
+
if res.is_success:
|
|
95
|
+
return HealthStatus(status="ok")
|
|
96
|
+
return HealthStatus(
|
|
97
|
+
status="degraded",
|
|
98
|
+
message=f"GET {path} returned HTTP {res.status_code}",
|
|
99
|
+
)
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
return HealthStatus(status="unavailable", message=str(exc))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nginx route injection helper.
|
|
3
|
+
Mirrors NginxHelper in the spec. Routes are declared in plugin.yaml;
|
|
4
|
+
this helper is used in install hooks to validate / extend them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class RouteOptions:
|
|
14
|
+
location: str # e.g. "/notify/"
|
|
15
|
+
upstream: str # e.g. "127.0.0.1:3850"
|
|
16
|
+
extra_directives: list[str] = field(default_factory=list)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NginxHelper:
|
|
20
|
+
"""Nginx route injection helper exposed in PluginContext."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._routes: list[RouteOptions] = []
|
|
24
|
+
|
|
25
|
+
def add_route(self, opts: RouteOptions) -> None:
|
|
26
|
+
"""Register a proxy_pass route to the plugin's upstream."""
|
|
27
|
+
if not opts.location.startswith("/"):
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"nginx: location must start with '/', got {opts.location!r}"
|
|
30
|
+
)
|
|
31
|
+
self._routes.append(opts)
|
|
32
|
+
|
|
33
|
+
def remove_route(self, location: str) -> None:
|
|
34
|
+
"""Remove a previously registered route (used in uninstall)."""
|
|
35
|
+
self._routes = [r for r in self._routes if r.location != location]
|
|
36
|
+
|
|
37
|
+
def get_routes(self) -> list[RouteOptions]:
|
|
38
|
+
"""Returns all routes registered in this session."""
|
|
39
|
+
return list(self._routes)
|
nself_plugin/plugin.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core Plugin class and related types.
|
|
3
|
+
Mirrors plugin.Plugin interface and plugin.Info struct in plugin-sdk-go.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Awaitable, Callable, Optional, TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ._runner import PluginContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class PluginInfo:
|
|
17
|
+
"""Identity metadata for a plugin — mirrors plugin-sdk-go plugin.Info."""
|
|
18
|
+
name: str
|
|
19
|
+
version: str
|
|
20
|
+
description: str = ""
|
|
21
|
+
tier: str = "free" # "free" | "pro"
|
|
22
|
+
bundle: str = ""
|
|
23
|
+
min_cli: str = ""
|
|
24
|
+
min_sdk: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class HealthStatus:
|
|
29
|
+
"""Health check result returned by the health hook."""
|
|
30
|
+
status: str # "ok" | "degraded" | "unavailable"
|
|
31
|
+
message: str = ""
|
|
32
|
+
checks: dict[str, bool] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Type aliases for hook callables
|
|
36
|
+
HookFn = Callable[["PluginContext"], Awaitable[None]]
|
|
37
|
+
HealthFn = Callable[["PluginContext"], Awaitable[HealthStatus]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Plugin:
|
|
41
|
+
"""
|
|
42
|
+
Plugin is the primary authoring entry point for Python nSelf plugins.
|
|
43
|
+
Mirrors the definePlugin() function in @nself/plugin-sdk (TypeScript) and
|
|
44
|
+
the Plugin interface in plugin-sdk-go.
|
|
45
|
+
|
|
46
|
+
Usage::
|
|
47
|
+
|
|
48
|
+
plugin = Plugin(name="notify", version="1.0.0")
|
|
49
|
+
|
|
50
|
+
@plugin.install
|
|
51
|
+
async def install(ctx: PluginContext) -> None:
|
|
52
|
+
ctx.env.require(["SMTP_HOST"])
|
|
53
|
+
|
|
54
|
+
@plugin.start
|
|
55
|
+
async def start(ctx: PluginContext) -> None:
|
|
56
|
+
app = ctx.http.create_app()
|
|
57
|
+
await ctx.http.listen(ctx.port)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
name: str,
|
|
63
|
+
version: str,
|
|
64
|
+
description: str = "",
|
|
65
|
+
tier: str = "free",
|
|
66
|
+
bundle: str = "",
|
|
67
|
+
) -> None:
|
|
68
|
+
if not name:
|
|
69
|
+
raise ValueError("plugin SDK: name is required")
|
|
70
|
+
if not version:
|
|
71
|
+
raise ValueError("plugin SDK: version is required")
|
|
72
|
+
if tier not in ("free", "pro"):
|
|
73
|
+
raise ValueError(f"plugin SDK: tier must be 'free' or 'pro', got {tier!r}")
|
|
74
|
+
|
|
75
|
+
self.info = PluginInfo(
|
|
76
|
+
name=name,
|
|
77
|
+
version=version,
|
|
78
|
+
description=description,
|
|
79
|
+
tier=tier,
|
|
80
|
+
bundle=bundle,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
self._install: Optional[HookFn] = None
|
|
84
|
+
self._start: Optional[HookFn] = None
|
|
85
|
+
self._health: Optional[HealthFn] = None
|
|
86
|
+
self._migrate: Optional[HookFn] = None
|
|
87
|
+
self._uninstall: Optional[HookFn] = None
|
|
88
|
+
|
|
89
|
+
# Decorator-based hook registration
|
|
90
|
+
def install(self, fn: HookFn) -> HookFn:
|
|
91
|
+
"""Register the install hook (runs on `nself plugin install`)."""
|
|
92
|
+
self._install = fn
|
|
93
|
+
return fn
|
|
94
|
+
|
|
95
|
+
def start(self, fn: HookFn) -> HookFn:
|
|
96
|
+
"""Register the start hook (runs when `nself start` brings the container up)."""
|
|
97
|
+
self._start = fn
|
|
98
|
+
return fn
|
|
99
|
+
|
|
100
|
+
def health(self, fn: HealthFn) -> HealthFn:
|
|
101
|
+
"""Register the health hook (polled by `nself doctor`)."""
|
|
102
|
+
self._health = fn
|
|
103
|
+
return fn
|
|
104
|
+
|
|
105
|
+
def migrate(self, fn: HookFn) -> HookFn:
|
|
106
|
+
"""Register the migrate hook (runs Hasura + SQL migrations on start)."""
|
|
107
|
+
self._migrate = fn
|
|
108
|
+
return fn
|
|
109
|
+
|
|
110
|
+
def uninstall(self, fn: HookFn) -> HookFn:
|
|
111
|
+
"""Register the uninstall hook (runs on `nself plugin remove`)."""
|
|
112
|
+
self._uninstall = fn
|
|
113
|
+
return fn
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nself-plugin-sdk
|
|
3
|
+
Version: 1.2.5
|
|
4
|
+
Summary: Python plugin authoring SDK for nSelf. Parity with the Go SDK at github.com/nself-org/cli/sdk/go.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: nself,plugin,sdk,self-hosted
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: fastapi>=0.110
|
|
13
|
+
Requires-Dist: httpx>=0.27
|
|
14
|
+
Requires-Dist: pyyaml>=6
|
|
15
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
16
|
+
Provides-Extra: db
|
|
17
|
+
Requires-Dist: asyncpg>=0.29; extra == 'db'
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: asyncpg>=0.29; extra == 'dev'
|
|
20
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
24
|
+
Requires-Dist: types-pyyaml>=6; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# nself-plugin
|
|
28
|
+
|
|
29
|
+
Python plugin authoring SDK for [nSelf](https://nself.org). Parity with
|
|
30
|
+
[plugin-sdk-go](https://github.com/nself-org/plugin-sdk-go) v0.1.0.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install nself-plugin
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
For database migrations:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install 'nself-plugin[db]'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from nself_plugin import Plugin, PluginContext, HealthStatus
|
|
48
|
+
|
|
49
|
+
plugin = Plugin(name="my-notify", version="1.0.0")
|
|
50
|
+
|
|
51
|
+
@plugin.install
|
|
52
|
+
async def install(ctx: PluginContext) -> None:
|
|
53
|
+
ctx.env.require(["SMTP_HOST", "SMTP_PORT"])
|
|
54
|
+
|
|
55
|
+
@plugin.start
|
|
56
|
+
async def start(ctx: PluginContext) -> None:
|
|
57
|
+
app = ctx.http.create_app()
|
|
58
|
+
app.post("/notify/send")(handler)
|
|
59
|
+
await ctx.http.listen(ctx.port)
|
|
60
|
+
|
|
61
|
+
@plugin.health
|
|
62
|
+
async def health(ctx: PluginContext) -> HealthStatus:
|
|
63
|
+
return await ctx.http.ping("/notify/healthz")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Scaffold a plugin
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
nself plugin new my-plugin --lang python
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
nself_plugin/__init__.py,sha256=aCmP7y_8MyTHqAehuo7TghIOGOf9ZiVsQBqOUoDdomM,1541
|
|
2
|
+
nself_plugin/_manifest.py,sha256=Q3ykW2I9A6f7vc_muPafpNtR8P1e74oWEl__5Q8BV4Q,3037
|
|
3
|
+
nself_plugin/_runner.py,sha256=1KvuhZ-Hrgq1Io4679OH0hSF4Salx7265_j24QuhHoQ,1152
|
|
4
|
+
nself_plugin/plugin.py,sha256=4GQdzgQS3gZrAgfvll1RrdhLAvWXRxhPWcdwdgPlf1w,3325
|
|
5
|
+
nself_plugin/context/__init__.py,sha256=mwI1USnbmEeP9VjcDyYzTwaJiy96Ny4_GMuX2YlAhVQ,73
|
|
6
|
+
nself_plugin/context/db.py,sha256=VpOXoOEz6dtuzrLPrngglK3KpPDdqYC9oZNEsHl-dvs,2247
|
|
7
|
+
nself_plugin/context/env.py,sha256=hxFX48xr5A4Zv5WfL9EUC8gcnMdWyO1JONdMlaJExiw,1794
|
|
8
|
+
nself_plugin/context/hasura.py,sha256=Db8_WGaNKBmZ0dM3IRheRrKB6lX_DFBtcaV1h0CsqRw,3177
|
|
9
|
+
nself_plugin/context/http.py,sha256=3gyDK3mciKPIWYyzpJ4cM-7bXY4wAMHrJp7BWfW0g2A,3381
|
|
10
|
+
nself_plugin/context/nginx.py,sha256=E61_hugpmsSbs_C-g7gLpCmBvthGlxnV1SbQMe1xZc0,1258
|
|
11
|
+
nself_plugin_sdk-1.2.5.dist-info/METADATA,sha256=jhiEC8OBZZlYsg-P-3dWiph7S7V53d97ZGWsQ5h93tk,1868
|
|
12
|
+
nself_plugin_sdk-1.2.5.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
13
|
+
nself_plugin_sdk-1.2.5.dist-info/RECORD,,
|