nself-plugin-sdk 1.2.5__tar.gz

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.
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: Publish Python SDK
3
+
4
+ # Triggers on:
5
+ # - sdk-scoped tag (sdk-py/v*) -- publishes nself-plugin-sdk to PyPI
6
+ # - workflow_dispatch -- manual publish with version input
7
+ #
8
+ # Publishes to https://pypi.org/project/nself-plugin-sdk/
9
+ # Uses PyPI Trusted Publisher (OIDC) -- no API token required.
10
+ # Configure the trusted publisher at https://pypi.org/manage/project/nself-plugin-sdk/settings/publishing/
11
+
12
+ "on":
13
+ push:
14
+ tags:
15
+ - 'sdk-py/v*'
16
+ workflow_dispatch:
17
+ inputs:
18
+ version:
19
+ description: 'Publish version (e.g. 2.0.0). Used for release notes only.'
20
+ required: false
21
+ type: string
22
+
23
+ permissions:
24
+ id-token: write # required for PyPI Trusted Publisher (OIDC)
25
+ contents: write # required to create GitHub release
26
+
27
+ jobs:
28
+ publish:
29
+ name: Publish nself-plugin-sdk
30
+ runs-on: ubuntu-latest
31
+ timeout-minutes: 15
32
+
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+
36
+ - name: Set up Python
37
+ uses: actions/setup-python@v5
38
+ with:
39
+ python-version: '3.12'
40
+
41
+ - name: Derive version from tag
42
+ id: version
43
+ run: |
44
+ if [[ "${GITHUB_REF}" == refs/tags/sdk-py/* ]]; then
45
+ echo "tag=${GITHUB_REF#refs/tags/sdk-py/}" >> "$GITHUB_OUTPUT"
46
+ else
47
+ echo "tag=v${{ inputs.version }}" >> "$GITHUB_OUTPUT"
48
+ fi
49
+
50
+ - name: Install build tools
51
+ run: pip install build twine
52
+
53
+ - name: Install dev dependencies
54
+ working-directory: sdk/py
55
+ run: pip install -e ".[dev]"
56
+
57
+ - name: Lint
58
+ working-directory: sdk/py
59
+ run: ruff check .
60
+
61
+ - name: Type-check
62
+ working-directory: sdk/py
63
+ run: mypy nself_plugin
64
+
65
+ - name: Test
66
+ working-directory: sdk/py
67
+ run: pytest -v
68
+
69
+ - name: Verify package version matches tag
70
+ working-directory: sdk/py
71
+ run: |
72
+ TAG="${{ steps.version.outputs.tag }}"
73
+ PKG_VERSION=$(python -c "import tomllib; f=open('pyproject.toml','rb'); d=tomllib.load(f); print(d['project']['version'])")
74
+ if [ "v${PKG_VERSION}" != "${TAG}" ]; then
75
+ echo "ERROR: pyproject.toml version 'v${PKG_VERSION}' does not match tag '${TAG}'"
76
+ exit 1
77
+ fi
78
+ echo "Version check passed: ${TAG}"
79
+
80
+ - name: Build distribution
81
+ working-directory: sdk/py
82
+ run: python -m build
83
+
84
+ - name: Publish to PyPI
85
+ uses: pypa/gh-action-pypi-publish@release/v1
86
+ with:
87
+ packages-dir: sdk/py/dist/
88
+
89
+ - name: Create GitHub Release
90
+ env:
91
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
92
+ run: |
93
+ TAG="${{ steps.version.outputs.tag }}"
94
+ FULL_TAG="sdk-py/${TAG}"
95
+ NOTES=$(git tag -l --format='%(contents)' "${FULL_TAG}" 2>/dev/null || echo "Python SDK ${TAG} release.")
96
+ gh release create "${FULL_TAG}" \
97
+ --title "Python SDK ${TAG}" \
98
+ --notes "${NOTES}" \
99
+ --verify-tag
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .env
8
+ .DS_Store
9
+ .mypy_cache/
10
+ .pytest_cache/
@@ -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,48 @@
1
+ # nself-plugin
2
+
3
+ Python plugin authoring SDK for [nSelf](https://nself.org). Parity with
4
+ [plugin-sdk-go](https://github.com/nself-org/plugin-sdk-go) v0.1.0.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install nself-plugin
10
+ ```
11
+
12
+ For database migrations:
13
+
14
+ ```bash
15
+ pip install 'nself-plugin[db]'
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ ```python
21
+ from nself_plugin import Plugin, PluginContext, HealthStatus
22
+
23
+ plugin = Plugin(name="my-notify", version="1.0.0")
24
+
25
+ @plugin.install
26
+ async def install(ctx: PluginContext) -> None:
27
+ ctx.env.require(["SMTP_HOST", "SMTP_PORT"])
28
+
29
+ @plugin.start
30
+ async def start(ctx: PluginContext) -> None:
31
+ app = ctx.http.create_app()
32
+ app.post("/notify/send")(handler)
33
+ await ctx.http.listen(ctx.port)
34
+
35
+ @plugin.health
36
+ async def health(ctx: PluginContext) -> HealthStatus:
37
+ return await ctx.http.ping("/notify/healthz")
38
+ ```
39
+
40
+ ## Scaffold a plugin
41
+
42
+ ```bash
43
+ nself plugin new my-plugin --lang python
44
+ ```
45
+
46
+ ## License
47
+
48
+ MIT
@@ -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
+ )
@@ -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)
@@ -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,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "nself-plugin-sdk"
7
+ version = "1.2.5"
8
+ description = "Python plugin authoring SDK for nSelf. Parity with the Go SDK at github.com/nself-org/cli/sdk/go."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ keywords = ["nself", "plugin", "sdk", "self-hosted"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "License :: OSI Approved :: MIT License",
18
+ ]
19
+ dependencies = [
20
+ "fastapi>=0.110",
21
+ "uvicorn[standard]>=0.29",
22
+ "pyyaml>=6",
23
+ "httpx>=0.27",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ db = ["asyncpg>=0.29"]
28
+ dev = [
29
+ "pytest>=8",
30
+ "pytest-asyncio>=0.23",
31
+ "mypy>=1.10",
32
+ "ruff>=0.4",
33
+ "asyncpg>=0.29",
34
+ "types-PyYAML>=6",
35
+ ]
36
+
37
+ [tool.pytest.ini_options]
38
+ asyncio_mode = "auto"
39
+ testpaths = ["tests"]
40
+
41
+ [tool.mypy]
42
+ strict = true
43
+ python_version = "3.11"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["nself_plugin"]
47
+
48
+ [tool.ruff]
49
+ line-length = 100
50
+ target-version = "py311"
@@ -0,0 +1,51 @@
1
+ """
2
+ my-plugin — nSelf plugin built with the Python SDK.
3
+ Generated by: nself plugin new my-plugin --lang python
4
+ """
5
+
6
+ from nself_plugin import Plugin, PluginContext, HealthStatus
7
+
8
+ plugin = Plugin(name="my-plugin", version="1.0.0")
9
+
10
+
11
+ @plugin.install
12
+ async def install(ctx: PluginContext) -> None:
13
+ ctx.logger.info("installing my-plugin")
14
+ # Verify required env vars
15
+ ctx.env.require([])
16
+ # Register Nginx route
17
+ from nself_plugin.context.nginx import RouteOptions
18
+ ctx.nginx.add_route(RouteOptions(
19
+ location="/my-plugin/",
20
+ upstream=f"127.0.0.1:{ctx.port}",
21
+ ))
22
+
23
+
24
+ @plugin.start
25
+ async def start(ctx: PluginContext) -> None:
26
+ ctx.logger.info(f"starting my-plugin on port {ctx.port}")
27
+ app = ctx.http.create_app()
28
+
29
+ # Add your routes here
30
+ @app.get("/my-plugin/hello")
31
+ async def hello() -> dict[str, str]:
32
+ return {"message": "Hello from my-plugin!"}
33
+
34
+ await ctx.http.listen(ctx.port)
35
+
36
+
37
+ @plugin.health
38
+ async def health(ctx: PluginContext) -> HealthStatus:
39
+ return await ctx.http.ping("/my-plugin/healthz")
40
+
41
+
42
+ @plugin.migrate
43
+ async def migrate(_ctx: PluginContext) -> None:
44
+ # Run Hasura metadata or SQL migrations here
45
+ pass
46
+
47
+
48
+ @plugin.uninstall
49
+ async def uninstall(ctx: PluginContext) -> None:
50
+ ctx.logger.info("uninstalling my-plugin")
51
+ ctx.nginx.remove_route("/my-plugin/")
@@ -0,0 +1,11 @@
1
+ name: my-plugin
2
+ version: "1.0.0"
3
+ description: My nSelf plugin built with nself-plugin (Python SDK)
4
+ tier: free
5
+ port: 3851
6
+
7
+ # Required environment variables (checked at install time)
8
+ requiredEnv: []
9
+
10
+ # Nginx routes this plugin exposes (populated by the CLI at build time)
11
+ routes: []
File without changes
@@ -0,0 +1,57 @@
1
+ """Tests for EnvHelper."""
2
+
3
+ import pytest
4
+
5
+ from nself_plugin.context.env import EnvHelper
6
+
7
+
8
+ @pytest.fixture()
9
+ def env() -> EnvHelper:
10
+ return EnvHelper()
11
+
12
+
13
+ def test_get_returns_value(monkeypatch: pytest.MonkeyPatch, env: EnvHelper) -> None:
14
+ monkeypatch.setenv("TEST_VAR", "hello")
15
+ assert env.get("TEST_VAR") == "hello"
16
+
17
+
18
+ def test_get_returns_default_when_missing(env: EnvHelper) -> None:
19
+ assert env.get("MISSING_VAR_XYZ", "default") == "default"
20
+
21
+
22
+ def test_require_raises_on_missing(env: EnvHelper) -> None:
23
+ with pytest.raises(ValueError, match="REQUIRED_VAR"):
24
+ env.require(["REQUIRED_VAR"])
25
+
26
+
27
+ def test_require_passes_when_all_set(monkeypatch: pytest.MonkeyPatch, env: EnvHelper) -> None:
28
+ monkeypatch.setenv("VAR_A", "a")
29
+ monkeypatch.setenv("VAR_B", "b")
30
+ env.require(["VAR_A", "VAR_B"]) # should not raise
31
+
32
+
33
+ def test_get_int_parses(monkeypatch: pytest.MonkeyPatch, env: EnvHelper) -> None:
34
+ monkeypatch.setenv("MY_PORT", "3850")
35
+ assert env.get_int("MY_PORT", 3000) == 3850
36
+
37
+
38
+ def test_get_int_returns_default_on_non_numeric(
39
+ monkeypatch: pytest.MonkeyPatch, env: EnvHelper
40
+ ) -> None:
41
+ monkeypatch.setenv("MY_PORT", "abc")
42
+ assert env.get_int("MY_PORT", 3000) == 3000
43
+
44
+
45
+ def test_get_bool_true_variants(monkeypatch: pytest.MonkeyPatch, env: EnvHelper) -> None:
46
+ for v in ("true", "1", "yes", "y", "on"):
47
+ monkeypatch.setenv("BOOL_VAR", v)
48
+ assert env.get_bool("BOOL_VAR", False) is True
49
+
50
+
51
+ def test_get_list_splits_commas(monkeypatch: pytest.MonkeyPatch, env: EnvHelper) -> None:
52
+ monkeypatch.setenv("LIST_VAR", "a, b, c")
53
+ assert env.get_list("LIST_VAR") == ["a", "b", "c"]
54
+
55
+
56
+ def test_get_list_empty(env: EnvHelper) -> None:
57
+ assert env.get_list("UNSET_LIST_VAR") == []
@@ -0,0 +1,64 @@
1
+ """Tests for plugin.yaml manifest parser."""
2
+
3
+ import pytest
4
+
5
+ from nself_plugin import parse_manifest_string, ManifestValidationError
6
+
7
+ VALID_YAML = """
8
+ name: test-plugin
9
+ version: "1.0.0"
10
+ description: A test plugin
11
+ tier: free
12
+ port: 3850
13
+ requiredEnv:
14
+ - DATABASE_URL
15
+ """
16
+
17
+ PRO_YAML = """
18
+ name: claw-plugin
19
+ version: "2.1.0"
20
+ tier: pro
21
+ bundle: nClaw
22
+ minCli: "1.0.0"
23
+ """
24
+
25
+
26
+ def test_parses_valid_free_manifest() -> None:
27
+ m = parse_manifest_string(VALID_YAML)
28
+ assert m.name == "test-plugin"
29
+ assert m.version == "1.0.0"
30
+ assert m.tier == "free"
31
+ assert m.port == 3850
32
+ assert m.required_env == ["DATABASE_URL"]
33
+
34
+
35
+ def test_parses_valid_pro_manifest() -> None:
36
+ m = parse_manifest_string(PRO_YAML)
37
+ assert m.name == "claw-plugin"
38
+ assert m.tier == "pro"
39
+ assert m.bundle == "nClaw"
40
+
41
+
42
+ def test_raises_on_missing_name() -> None:
43
+ with pytest.raises(ManifestValidationError, match="name"):
44
+ parse_manifest_string("version: '1.0.0'\ntier: free\n")
45
+
46
+
47
+ def test_raises_on_missing_version() -> None:
48
+ with pytest.raises(ManifestValidationError, match="version"):
49
+ parse_manifest_string("name: x\ntier: free\n")
50
+
51
+
52
+ def test_raises_on_invalid_tier() -> None:
53
+ with pytest.raises(ManifestValidationError, match="tier"):
54
+ parse_manifest_string("name: x\nversion: '1.0.0'\ntier: enterprise\n")
55
+
56
+
57
+ def test_raises_on_invalid_yaml() -> None:
58
+ with pytest.raises(ManifestValidationError):
59
+ parse_manifest_string("name: [unclosed")
60
+
61
+
62
+ def test_raises_on_non_object() -> None:
63
+ with pytest.raises(ManifestValidationError, match="YAML object"):
64
+ parse_manifest_string("- item1\n- item2\n")
@@ -0,0 +1,99 @@
1
+ """Tests for Plugin class — lifecycle hook registration and validation."""
2
+
3
+ import pytest
4
+
5
+ from nself_plugin import Plugin, PluginContext, HealthStatus
6
+ from nself_plugin.context.env import EnvHelper
7
+ from nself_plugin.context.nginx import NginxHelper
8
+ from nself_plugin.context.db import DatabaseHelperStub
9
+ from nself_plugin.context.http import HttpHelper
10
+
11
+
12
+ def make_ctx(port: int = 3850) -> PluginContext:
13
+ """Build a minimal PluginContext for testing."""
14
+ import logging
15
+ return PluginContext(
16
+ port=port,
17
+ logger=logging.getLogger("test"),
18
+ env=EnvHelper(),
19
+ hasura=None, # type: ignore[arg-type]
20
+ nginx=NginxHelper(),
21
+ db=DatabaseHelperStub(),
22
+ http=HttpHelper("test-plugin", "1.0.0", port),
23
+ )
24
+
25
+
26
+ def test_plugin_stores_info() -> None:
27
+ p = Plugin(name="test", version="1.0.0", tier="free")
28
+ assert p.info.name == "test"
29
+ assert p.info.version == "1.0.0"
30
+ assert p.info.tier == "free"
31
+
32
+
33
+ def test_plugin_requires_name() -> None:
34
+ with pytest.raises(ValueError, match="name is required"):
35
+ Plugin(name="", version="1.0.0")
36
+
37
+
38
+ def test_plugin_requires_version() -> None:
39
+ with pytest.raises(ValueError, match="version is required"):
40
+ Plugin(name="test", version="")
41
+
42
+
43
+ def test_plugin_rejects_invalid_tier() -> None:
44
+ with pytest.raises(ValueError, match="tier"):
45
+ Plugin(name="test", version="1.0.0", tier="enterprise")
46
+
47
+
48
+ async def test_install_hook_called() -> None:
49
+ plugin = Plugin(name="notify", version="1.0.0")
50
+ called: list[bool] = []
51
+
52
+ @plugin.install
53
+ async def install(ctx: PluginContext) -> None:
54
+ called.append(True)
55
+
56
+ ctx = make_ctx()
57
+ assert plugin._install is not None
58
+ await plugin._install(ctx)
59
+ assert called == [True]
60
+
61
+
62
+ async def test_health_hook_returns_status() -> None:
63
+ plugin = Plugin(name="notify", version="1.0.0")
64
+
65
+ @plugin.health
66
+ async def health(ctx: PluginContext) -> HealthStatus:
67
+ return HealthStatus(status="ok")
68
+
69
+ ctx = make_ctx()
70
+ assert plugin._health is not None
71
+ result = await plugin._health(ctx)
72
+ assert result.status == "ok"
73
+
74
+
75
+ async def test_all_hooks_can_be_registered() -> None:
76
+ plugin = Plugin(name="full", version="2.0.0", tier="pro", bundle="nClaw")
77
+
78
+ @plugin.install
79
+ async def install(ctx: PluginContext) -> None: pass
80
+
81
+ @plugin.start
82
+ async def start(ctx: PluginContext) -> None: pass
83
+
84
+ @plugin.health
85
+ async def health(ctx: PluginContext) -> HealthStatus:
86
+ return HealthStatus(status="ok")
87
+
88
+ @plugin.migrate
89
+ async def migrate(ctx: PluginContext) -> None: pass
90
+
91
+ @plugin.uninstall
92
+ async def uninstall(ctx: PluginContext) -> None: pass
93
+
94
+ assert plugin._install is not None
95
+ assert plugin._start is not None
96
+ assert plugin._health is not None
97
+ assert plugin._migrate is not None
98
+ assert plugin._uninstall is not None
99
+ assert plugin.info.bundle == "nClaw"