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.
- nself_plugin_sdk-1.2.5/.github/workflows/sdk-py-publish.yml +99 -0
- nself_plugin_sdk-1.2.5/.gitignore +10 -0
- nself_plugin_sdk-1.2.5/PKG-INFO +74 -0
- nself_plugin_sdk-1.2.5/README.md +48 -0
- nself_plugin_sdk-1.2.5/nself_plugin/__init__.py +54 -0
- nself_plugin_sdk-1.2.5/nself_plugin/_manifest.py +96 -0
- nself_plugin_sdk-1.2.5/nself_plugin/_runner.py +45 -0
- nself_plugin_sdk-1.2.5/nself_plugin/context/__init__.py +1 -0
- nself_plugin_sdk-1.2.5/nself_plugin/context/db.py +73 -0
- nself_plugin_sdk-1.2.5/nself_plugin/context/env.py +57 -0
- nself_plugin_sdk-1.2.5/nself_plugin/context/hasura.py +97 -0
- nself_plugin_sdk-1.2.5/nself_plugin/context/http.py +101 -0
- nself_plugin_sdk-1.2.5/nself_plugin/context/nginx.py +39 -0
- nself_plugin_sdk-1.2.5/nself_plugin/plugin.py +113 -0
- nself_plugin_sdk-1.2.5/pyproject.toml +50 -0
- nself_plugin_sdk-1.2.5/templates/main.py +51 -0
- nself_plugin_sdk-1.2.5/templates/plugin.yaml +11 -0
- nself_plugin_sdk-1.2.5/tests/__init__.py +0 -0
- nself_plugin_sdk-1.2.5/tests/test_env.py +57 -0
- nself_plugin_sdk-1.2.5/tests/test_manifest.py +64 -0
- nself_plugin_sdk-1.2.5/tests/test_plugin.py +99 -0
|
@@ -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,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"
|