imperal-sdk 0.1.0__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.
- imperal_sdk/__init__.py +15 -0
- imperal_sdk/ai/__init__.py +4 -0
- imperal_sdk/ai/client.py +28 -0
- imperal_sdk/auth/__init__.py +6 -0
- imperal_sdk/auth/client.py +77 -0
- imperal_sdk/auth/middleware.py +36 -0
- imperal_sdk/auth/user.py +25 -0
- imperal_sdk/billing/__init__.py +4 -0
- imperal_sdk/billing/client.py +53 -0
- imperal_sdk/cli/__init__.py +2 -0
- imperal_sdk/cli/main.py +117 -0
- imperal_sdk/context.py +74 -0
- imperal_sdk/db/__init__.py +4 -0
- imperal_sdk/db/client.py +36 -0
- imperal_sdk/extension.py +96 -0
- imperal_sdk/http/__init__.py +4 -0
- imperal_sdk/http/client.py +30 -0
- imperal_sdk/manifest.py +72 -0
- imperal_sdk/notify/__init__.py +4 -0
- imperal_sdk/notify/client.py +15 -0
- imperal_sdk/skeleton/__init__.py +4 -0
- imperal_sdk/skeleton/client.py +25 -0
- imperal_sdk/storage/__init__.py +4 -0
- imperal_sdk/storage/client.py +38 -0
- imperal_sdk/store/__init__.py +4 -0
- imperal_sdk/store/client.py +74 -0
- imperal_sdk-0.1.0.dist-info/METADATA +31 -0
- imperal_sdk-0.1.0.dist-info/RECORD +31 -0
- imperal_sdk-0.1.0.dist-info/WHEEL +4 -0
- imperal_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- imperal_sdk-0.1.0.dist-info/licenses/LICENSE +669 -0
imperal_sdk/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
|
|
2
|
+
# Licensed under the AGPL-3.0 License. See LICENSE file for details.
|
|
3
|
+
"""Imperal Cloud SDK — build extensions for the Imperal platform."""
|
|
4
|
+
from imperal_sdk.extension import Extension, ToolDef, SignalDef, ScheduleDef
|
|
5
|
+
from imperal_sdk.context import Context
|
|
6
|
+
from imperal_sdk.auth import ImperalAuth, AuthError, User
|
|
7
|
+
from imperal_sdk.manifest import generate_manifest, save_manifest
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Extension", "ToolDef", "SignalDef", "ScheduleDef",
|
|
13
|
+
"Context", "ImperalAuth", "AuthError", "User",
|
|
14
|
+
"generate_manifest", "save_manifest",
|
|
15
|
+
]
|
imperal_sdk/ai/client.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
|
|
2
|
+
# Licensed under the AGPL-3.0 License. See LICENSE file for details.
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class CompletionResult:
|
|
10
|
+
text: str
|
|
11
|
+
tokens_used: int = 0
|
|
12
|
+
model: str = ""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AIClient:
|
|
16
|
+
"""AI completion client. Usage auto-metered by platform."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, gateway_url: str, auth_token: str, extension_id: str):
|
|
19
|
+
self._gateway_url = gateway_url.rstrip("/")
|
|
20
|
+
self._auth_token = auth_token
|
|
21
|
+
self._extension_id = extension_id
|
|
22
|
+
|
|
23
|
+
async def complete(self, prompt: str, model: str = "claude-sonnet", **kwargs) -> CompletionResult:
|
|
24
|
+
async with httpx.AsyncClient() as client:
|
|
25
|
+
resp = await client.post(f"{self._gateway_url}/v1/internal/ai/complete", json={"prompt": prompt, "model": model, "extension_id": self._extension_id, **kwargs}, headers={"Authorization": f"Bearer {self._auth_token}"}, timeout=120)
|
|
26
|
+
resp.raise_for_status()
|
|
27
|
+
data = resp.json()
|
|
28
|
+
return CompletionResult(text=data.get("text", ""), tokens_used=data.get("tokens_used", 0), model=data.get("model", model))
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
|
|
2
|
+
# Licensed under the AGPL-3.0 License. See LICENSE file for details.
|
|
3
|
+
from imperal_sdk.auth.client import ImperalAuth, AuthError
|
|
4
|
+
from imperal_sdk.auth.user import User
|
|
5
|
+
|
|
6
|
+
__all__ = ["ImperalAuth", "AuthError", "User"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
|
|
2
|
+
# Licensed under the AGPL-3.0 License. See LICENSE file for details.
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import time
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import jwt
|
|
9
|
+
from jwt import PyJWKClient
|
|
10
|
+
|
|
11
|
+
from imperal_sdk.auth.user import User
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ImperalAuth:
|
|
21
|
+
def __init__(self, gateway_url: str = "https://auth.imperal.io"):
|
|
22
|
+
self._gateway_url = gateway_url.rstrip("/")
|
|
23
|
+
self._jwks_client: PyJWKClient | None = None
|
|
24
|
+
self._jwks_last_refresh: float = 0
|
|
25
|
+
self._jwks_cache_seconds: int = 3600
|
|
26
|
+
|
|
27
|
+
def _get_jwks_client(self) -> PyJWKClient:
|
|
28
|
+
now = time.time()
|
|
29
|
+
if self._jwks_client is None or (now - self._jwks_last_refresh) > self._jwks_cache_seconds:
|
|
30
|
+
jwks_url = f"{self._gateway_url}/v1/auth/.well-known/jwks.json"
|
|
31
|
+
self._jwks_client = PyJWKClient(jwks_url)
|
|
32
|
+
self._jwks_last_refresh = now
|
|
33
|
+
return self._jwks_client
|
|
34
|
+
|
|
35
|
+
def verify(self, token: str) -> User:
|
|
36
|
+
try:
|
|
37
|
+
jwks_client = self._get_jwks_client()
|
|
38
|
+
signing_key = jwks_client.get_signing_key_from_jwt(token)
|
|
39
|
+
payload = jwt.decode(
|
|
40
|
+
token, signing_key.key, algorithms=["RS256"],
|
|
41
|
+
options={"require": ["sub", "exp", "iat"]},
|
|
42
|
+
)
|
|
43
|
+
return User(
|
|
44
|
+
id=payload["sub"],
|
|
45
|
+
tenant_id=payload.get("tenant_id", "default"),
|
|
46
|
+
org_id=payload.get("org_id"),
|
|
47
|
+
role=payload.get("role", "user"),
|
|
48
|
+
scopes=payload.get("scopes", []),
|
|
49
|
+
)
|
|
50
|
+
except jwt.ExpiredSignatureError:
|
|
51
|
+
raise AuthError("Token expired")
|
|
52
|
+
except jwt.InvalidTokenError as e:
|
|
53
|
+
raise AuthError(f"Invalid token: {e}")
|
|
54
|
+
except Exception as e:
|
|
55
|
+
self._jwks_client = None
|
|
56
|
+
try:
|
|
57
|
+
jwks_client = self._get_jwks_client()
|
|
58
|
+
signing_key = jwks_client.get_signing_key_from_jwt(token)
|
|
59
|
+
payload = jwt.decode(token, signing_key.key, algorithms=["RS256"])
|
|
60
|
+
return User(
|
|
61
|
+
id=payload["sub"],
|
|
62
|
+
tenant_id=payload.get("tenant_id", "default"),
|
|
63
|
+
org_id=payload.get("org_id"),
|
|
64
|
+
role=payload.get("role", "user"),
|
|
65
|
+
scopes=payload.get("scopes", []),
|
|
66
|
+
)
|
|
67
|
+
except Exception:
|
|
68
|
+
raise AuthError(f"Token verification failed: {e}")
|
|
69
|
+
|
|
70
|
+
async def get_user_info(self, token: str) -> dict:
|
|
71
|
+
async with httpx.AsyncClient() as client:
|
|
72
|
+
resp = await client.get(
|
|
73
|
+
f"{self._gateway_url}/v1/auth/me",
|
|
74
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
75
|
+
)
|
|
76
|
+
resp.raise_for_status()
|
|
77
|
+
return resp.json()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
|
|
2
|
+
# Licensed under the AGPL-3.0 License. See LICENSE file for details.
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from fastapi import Header, HTTPException
|
|
5
|
+
from imperal_sdk.auth.client import ImperalAuth, AuthError
|
|
6
|
+
from imperal_sdk.auth.user import User
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def require_auth(auth: ImperalAuth | None = None, gateway_url: str = "https://auth.imperal.io"):
|
|
10
|
+
_auth = auth or ImperalAuth(gateway_url)
|
|
11
|
+
|
|
12
|
+
async def dependency(authorization: str | None = Header(None)) -> User:
|
|
13
|
+
if not authorization:
|
|
14
|
+
raise HTTPException(status_code=401, detail="Missing Authorization header")
|
|
15
|
+
if not authorization.startswith("Bearer "):
|
|
16
|
+
raise HTTPException(status_code=401, detail="Invalid Authorization header")
|
|
17
|
+
token = authorization.removeprefix("Bearer ")
|
|
18
|
+
try:
|
|
19
|
+
return _auth.verify(token)
|
|
20
|
+
except AuthError as e:
|
|
21
|
+
raise HTTPException(status_code=401, detail=str(e))
|
|
22
|
+
|
|
23
|
+
return dependency
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def require_scope(*required_scopes: str, auth: ImperalAuth | None = None, gateway_url: str = "https://auth.imperal.io"):
|
|
27
|
+
_auth_dep = require_auth(auth=auth, gateway_url=gateway_url)
|
|
28
|
+
|
|
29
|
+
async def dependency(authorization: str | None = Header(None)) -> User:
|
|
30
|
+
user = await _auth_dep(authorization)
|
|
31
|
+
for scope in required_scopes:
|
|
32
|
+
if not user.has_scope(scope):
|
|
33
|
+
raise HTTPException(status_code=403, detail=f"Missing required scope: {scope}")
|
|
34
|
+
return user
|
|
35
|
+
|
|
36
|
+
return dependency
|
imperal_sdk/auth/user.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
|
|
2
|
+
# Licensed under the AGPL-3.0 License. See LICENSE file for details.
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class User:
|
|
8
|
+
id: str
|
|
9
|
+
email: str = ""
|
|
10
|
+
tenant_id: str = "default"
|
|
11
|
+
org_id: str | None = None
|
|
12
|
+
role: str = "user"
|
|
13
|
+
scopes: list[str] = field(default_factory=list)
|
|
14
|
+
|
|
15
|
+
def has_scope(self, scope: str) -> bool:
|
|
16
|
+
if "*" in self.scopes:
|
|
17
|
+
return True
|
|
18
|
+
for s in self.scopes:
|
|
19
|
+
if s == scope:
|
|
20
|
+
return True
|
|
21
|
+
if s.endswith(".*"):
|
|
22
|
+
prefix = s[:-2]
|
|
23
|
+
if scope.startswith(prefix + ".") or scope == prefix:
|
|
24
|
+
return True
|
|
25
|
+
return False
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
|
|
2
|
+
# Licensed under the AGPL-3.0 License. See LICENSE file for details.
|
|
3
|
+
from imperal_sdk.billing.client import BillingClient, LimitsResult, SubscriptionInfo
|
|
4
|
+
__all__ = ["BillingClient", "LimitsResult", "SubscriptionInfo"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
|
|
2
|
+
# Licensed under the AGPL-3.0 License. See LICENSE file for details.
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class LimitsResult:
|
|
11
|
+
plan: str
|
|
12
|
+
usage: dict[str, int]
|
|
13
|
+
limits: dict[str, int]
|
|
14
|
+
exceeded: list[str]
|
|
15
|
+
|
|
16
|
+
def is_exceeded(self, meter: str) -> bool:
|
|
17
|
+
return meter in self.exceeded
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def any_exceeded(self) -> bool:
|
|
21
|
+
return len(self.exceeded) > 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SubscriptionInfo:
|
|
26
|
+
plan: str
|
|
27
|
+
status: str
|
|
28
|
+
started_at: str | None = None
|
|
29
|
+
expires_at: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BillingClient:
|
|
33
|
+
"""Read-only billing client for extensions."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, gateway_url: str, auth_token: str):
|
|
36
|
+
self._gateway_url = gateway_url.rstrip("/")
|
|
37
|
+
self._auth_token = auth_token
|
|
38
|
+
|
|
39
|
+
def _headers(self) -> dict:
|
|
40
|
+
return {"Authorization": f"Bearer {self._auth_token}"}
|
|
41
|
+
|
|
42
|
+
async def check_limits(self, user: Any = None) -> LimitsResult:
|
|
43
|
+
async with httpx.AsyncClient() as client:
|
|
44
|
+
resp = await client.get(f"{self._gateway_url}/v1/billing/usage", headers=self._headers(), timeout=10)
|
|
45
|
+
resp.raise_for_status()
|
|
46
|
+
data = resp.json()
|
|
47
|
+
return LimitsResult(plan=data["plan"], usage=data["usage"], limits=data["limits"], exceeded=data["exceeded"])
|
|
48
|
+
|
|
49
|
+
async def get_subscription(self, user: Any = None) -> SubscriptionInfo:
|
|
50
|
+
async with httpx.AsyncClient() as client:
|
|
51
|
+
resp = await client.get(f"{self._gateway_url}/v1/billing/subscription", headers=self._headers(), timeout=10)
|
|
52
|
+
resp.raise_for_status()
|
|
53
|
+
return SubscriptionInfo(**resp.json())
|
imperal_sdk/cli/main.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
|
|
2
|
+
# Licensed under the AGPL-3.0 License. See LICENSE file for details.
|
|
3
|
+
"""Imperal Cloud SDK CLI."""
|
|
4
|
+
import os
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
@click.version_option(version="0.1.0")
|
|
10
|
+
def cli():
|
|
11
|
+
"""Imperal Cloud SDK — build extensions for the Imperal platform."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@cli.command()
|
|
16
|
+
@click.argument("name")
|
|
17
|
+
def init(name: str):
|
|
18
|
+
"""Scaffold a new extension project."""
|
|
19
|
+
os.makedirs(name, exist_ok=True)
|
|
20
|
+
os.makedirs(f"{name}/tests", exist_ok=True)
|
|
21
|
+
|
|
22
|
+
with open(f"{name}/main.py", "w") as f:
|
|
23
|
+
f.write(f'''from imperal_sdk import Extension
|
|
24
|
+
|
|
25
|
+
ext = Extension("{name}", version="0.1.0")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@ext.tool("hello", description="Say hello")
|
|
29
|
+
async def hello(ctx, name: str = "World"):
|
|
30
|
+
"""Say hello."""
|
|
31
|
+
return {{"message": f"Hello, {{name}}!"}}
|
|
32
|
+
''')
|
|
33
|
+
|
|
34
|
+
with open(f"{name}/requirements.txt", "w") as f:
|
|
35
|
+
f.write("imperal-sdk>=0.1.0\n")
|
|
36
|
+
|
|
37
|
+
with open(f"{name}/tests/__init__.py", "w") as f:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
with open(f"{name}/tests/test_hello.py", "w") as f:
|
|
41
|
+
f.write(f'''import pytest
|
|
42
|
+
from main import ext
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_hello_tool_registered():
|
|
46
|
+
assert "hello" in ext.tools
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.asyncio
|
|
50
|
+
async def test_hello_tool():
|
|
51
|
+
result = await ext.call_tool("hello", ctx=None, name="Imperal")
|
|
52
|
+
assert result == {{"message": "Hello, Imperal!"}}
|
|
53
|
+
''')
|
|
54
|
+
|
|
55
|
+
with open(f"{name}/.gitignore", "w") as f:
|
|
56
|
+
f.write("venv/\n__pycache__/\n*.pyc\n.env\nmanifest.json\n")
|
|
57
|
+
|
|
58
|
+
click.echo(f"Extension '{name}' created!")
|
|
59
|
+
click.echo(f" cd {name}")
|
|
60
|
+
click.echo(f" pip install imperal-sdk")
|
|
61
|
+
click.echo(f" imperal dev")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@cli.command()
|
|
65
|
+
def dev():
|
|
66
|
+
"""Run local development server with hot reload."""
|
|
67
|
+
import sys
|
|
68
|
+
sys.path.insert(0, ".")
|
|
69
|
+
try:
|
|
70
|
+
from main import ext
|
|
71
|
+
from imperal_sdk.manifest import generate_manifest
|
|
72
|
+
manifest = generate_manifest(ext)
|
|
73
|
+
click.echo(f"Extension: {ext.app_id} v{ext.version}")
|
|
74
|
+
click.echo(f"Tools: {', '.join(ext.tools.keys()) or 'none'}")
|
|
75
|
+
click.echo(f"Signals: {', '.join(ext.signals.keys()) or 'none'}")
|
|
76
|
+
click.echo(f"Schedules: {', '.join(ext.schedules.keys()) or 'none'}")
|
|
77
|
+
click.echo("Dev server ready. Ctrl+C to stop.")
|
|
78
|
+
except ImportError:
|
|
79
|
+
click.echo("Error: No main.py found with 'ext' Extension object.", err=True)
|
|
80
|
+
raise SystemExit(1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@cli.command()
|
|
84
|
+
def test():
|
|
85
|
+
"""Run extension tests."""
|
|
86
|
+
import subprocess
|
|
87
|
+
result = subprocess.run(["python", "-m", "pytest", "tests/", "-v"])
|
|
88
|
+
raise SystemExit(result.returncode)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@cli.command()
|
|
92
|
+
def deploy():
|
|
93
|
+
"""Deploy extension to Imperal Cloud."""
|
|
94
|
+
import sys
|
|
95
|
+
sys.path.insert(0, ".")
|
|
96
|
+
try:
|
|
97
|
+
from main import ext
|
|
98
|
+
from imperal_sdk.manifest import generate_manifest, save_manifest
|
|
99
|
+
save_manifest(ext)
|
|
100
|
+
click.echo(f"Manifest generated: manifest.json")
|
|
101
|
+
click.echo(f"Extension: {ext.app_id} v{ext.version}")
|
|
102
|
+
click.echo("Deploy to Imperal Cloud...")
|
|
103
|
+
click.echo("(Not yet implemented — will push to Registry)")
|
|
104
|
+
except ImportError:
|
|
105
|
+
click.echo("Error: No main.py found.", err=True)
|
|
106
|
+
raise SystemExit(1)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@cli.command()
|
|
110
|
+
def logs():
|
|
111
|
+
"""Tail production logs."""
|
|
112
|
+
click.echo("Connecting to Imperal Cloud logs...")
|
|
113
|
+
click.echo("(Not yet implemented — will stream from SigNoz)")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
cli()
|
imperal_sdk/context.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
|
|
2
|
+
# Licensed under the AGPL-3.0 License. See LICENSE file for details.
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class StoreProtocol(Protocol):
|
|
10
|
+
async def create(self, collection: str, data: dict) -> Any: ...
|
|
11
|
+
async def get(self, collection: str, doc_id: str) -> Any: ...
|
|
12
|
+
async def query(self, collection: str, where: dict | None = None, order_by: str | None = None, limit: int = 100) -> list: ...
|
|
13
|
+
async def update(self, collection: str, doc_id: str, data: dict) -> Any: ...
|
|
14
|
+
async def delete(self, collection: str, doc_id: str) -> bool: ...
|
|
15
|
+
async def count(self, collection: str, where: dict | None = None) -> int: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class DBProtocol(Protocol):
|
|
20
|
+
async def acquire(self): ...
|
|
21
|
+
async def session(self): ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class AIProtocol(Protocol):
|
|
26
|
+
async def complete(self, prompt: str, model: str = "claude-sonnet", **kwargs) -> Any: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@runtime_checkable
|
|
30
|
+
class SkeletonProtocol(Protocol):
|
|
31
|
+
async def get(self, section: str) -> Any: ...
|
|
32
|
+
async def update(self, section: str, data: Any) -> None: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class BillingProtocol(Protocol):
|
|
37
|
+
async def check_limits(self, user: Any = None) -> Any: ...
|
|
38
|
+
async def get_subscription(self, user: Any = None) -> Any: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@runtime_checkable
|
|
42
|
+
class NotifyProtocol(Protocol):
|
|
43
|
+
async def __call__(self, message: str, **kwargs) -> None: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@runtime_checkable
|
|
47
|
+
class StorageProtocol(Protocol):
|
|
48
|
+
async def upload(self, path: str, data: bytes, content_type: str = "application/octet-stream") -> str: ...
|
|
49
|
+
async def download(self, path: str) -> bytes: ...
|
|
50
|
+
async def delete(self, path: str) -> bool: ...
|
|
51
|
+
async def list(self, prefix: str = "") -> list[str]: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@runtime_checkable
|
|
55
|
+
class HTTPProtocol(Protocol):
|
|
56
|
+
async def get(self, url: str, **kwargs) -> Any: ...
|
|
57
|
+
async def post(self, url: str, **kwargs) -> Any: ...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Context:
|
|
62
|
+
"""The context object passed to every extension tool/signal/schedule call."""
|
|
63
|
+
user: Any
|
|
64
|
+
tenant: Any = None
|
|
65
|
+
store: StoreProtocol | None = None
|
|
66
|
+
db: DBProtocol | None = None
|
|
67
|
+
ai: AIProtocol | None = None
|
|
68
|
+
skeleton: SkeletonProtocol | None = None
|
|
69
|
+
billing: BillingProtocol | None = None
|
|
70
|
+
notify: NotifyProtocol | None = None
|
|
71
|
+
storage: StorageProtocol | None = None
|
|
72
|
+
http: HTTPProtocol | None = None
|
|
73
|
+
_extension_id: str = ""
|
|
74
|
+
_metadata: dict = field(default_factory=dict)
|
imperal_sdk/db/client.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
|
|
2
|
+
# Licensed under the AGPL-3.0 License. See LICENSE file for details.
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DBClient:
|
|
9
|
+
"""Tier 2: Dedicated schema access."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, connection_factory):
|
|
12
|
+
self._factory = connection_factory
|
|
13
|
+
|
|
14
|
+
@asynccontextmanager
|
|
15
|
+
async def acquire(self):
|
|
16
|
+
conn = await self._factory.acquire()
|
|
17
|
+
try:
|
|
18
|
+
yield conn
|
|
19
|
+
finally:
|
|
20
|
+
await self._factory.release(conn)
|
|
21
|
+
|
|
22
|
+
@asynccontextmanager
|
|
23
|
+
async def session(self):
|
|
24
|
+
session = await self._factory.create_session()
|
|
25
|
+
try:
|
|
26
|
+
yield session
|
|
27
|
+
await session.commit()
|
|
28
|
+
except Exception:
|
|
29
|
+
await session.rollback()
|
|
30
|
+
raise
|
|
31
|
+
finally:
|
|
32
|
+
await session.close()
|
|
33
|
+
|
|
34
|
+
async def raw(self, query: str, params: tuple | None = None) -> list[dict[str, Any]]:
|
|
35
|
+
async with self.acquire() as conn:
|
|
36
|
+
return await conn.execute(query, params)
|
imperal_sdk/extension.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
|
|
2
|
+
# Licensed under the AGPL-3.0 License. See LICENSE file for details.
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import inspect
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ToolDef:
|
|
11
|
+
name: str
|
|
12
|
+
func: Callable
|
|
13
|
+
scopes: list[str] = field(default_factory=list)
|
|
14
|
+
description: str = ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SignalDef:
|
|
19
|
+
name: str
|
|
20
|
+
func: Callable
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ScheduleDef:
|
|
25
|
+
name: str
|
|
26
|
+
func: Callable
|
|
27
|
+
cron: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Extension:
|
|
31
|
+
"""Imperal Cloud Extension."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
app_id: str,
|
|
36
|
+
version: str = "0.1.0",
|
|
37
|
+
capabilities: list[str] | None = None,
|
|
38
|
+
migrations_dir: str | None = None,
|
|
39
|
+
):
|
|
40
|
+
self.app_id = app_id
|
|
41
|
+
self.version = version
|
|
42
|
+
self.capabilities = capabilities or []
|
|
43
|
+
self.migrations_dir = migrations_dir
|
|
44
|
+
self._tools: dict[str, ToolDef] = {}
|
|
45
|
+
self._signals: dict[str, SignalDef] = {}
|
|
46
|
+
self._schedules: dict[str, ScheduleDef] = {}
|
|
47
|
+
|
|
48
|
+
def tool(self, name: str, scopes: list[str] | None = None, description: str = ""):
|
|
49
|
+
"""Register a tool that the AI assistant can call."""
|
|
50
|
+
def decorator(func: Callable) -> Callable:
|
|
51
|
+
self._tools[name] = ToolDef(
|
|
52
|
+
name=name,
|
|
53
|
+
func=func,
|
|
54
|
+
scopes=scopes or [],
|
|
55
|
+
description=description or func.__doc__ or "",
|
|
56
|
+
)
|
|
57
|
+
return func
|
|
58
|
+
return decorator
|
|
59
|
+
|
|
60
|
+
def signal(self, name: str):
|
|
61
|
+
"""Register a signal handler for platform events."""
|
|
62
|
+
def decorator(func: Callable) -> Callable:
|
|
63
|
+
self._signals[name] = SignalDef(name=name, func=func)
|
|
64
|
+
return func
|
|
65
|
+
return decorator
|
|
66
|
+
|
|
67
|
+
def schedule(self, name: str, cron: str):
|
|
68
|
+
"""Register a scheduled task."""
|
|
69
|
+
def decorator(func: Callable) -> Callable:
|
|
70
|
+
self._schedules[name] = ScheduleDef(name=name, func=func, cron=cron)
|
|
71
|
+
return func
|
|
72
|
+
return decorator
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def tools(self) -> dict[str, ToolDef]:
|
|
76
|
+
return self._tools
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def signals(self) -> dict[str, SignalDef]:
|
|
80
|
+
return self._signals
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def schedules(self) -> dict[str, ScheduleDef]:
|
|
84
|
+
return self._schedules
|
|
85
|
+
|
|
86
|
+
async def call_tool(self, name: str, ctx: Any, **kwargs) -> Any:
|
|
87
|
+
"""Call a registered tool with context."""
|
|
88
|
+
if name not in self._tools:
|
|
89
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
90
|
+
return await self._tools[name].func(ctx, **kwargs)
|
|
91
|
+
|
|
92
|
+
async def call_signal(self, name: str, ctx: Any, **kwargs) -> Any:
|
|
93
|
+
"""Call a registered signal handler."""
|
|
94
|
+
if name not in self._signals:
|
|
95
|
+
raise ValueError(f"Unknown signal: {name}")
|
|
96
|
+
return await self._signals[name].func(ctx, **kwargs)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
|
|
2
|
+
# Licensed under the AGPL-3.0 License. See LICENSE file for details.
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HTTPClient:
|
|
8
|
+
def __init__(self, timeout: int = 30, max_redirects: int = 5):
|
|
9
|
+
self._timeout = timeout
|
|
10
|
+
self._max_redirects = max_redirects
|
|
11
|
+
|
|
12
|
+
async def get(self, url: str, **kwargs) -> httpx.Response:
|
|
13
|
+
async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True, max_redirects=self._max_redirects) as client:
|
|
14
|
+
return await client.get(url, **kwargs)
|
|
15
|
+
|
|
16
|
+
async def post(self, url: str, **kwargs) -> httpx.Response:
|
|
17
|
+
async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True, max_redirects=self._max_redirects) as client:
|
|
18
|
+
return await client.post(url, **kwargs)
|
|
19
|
+
|
|
20
|
+
async def put(self, url: str, **kwargs) -> httpx.Response:
|
|
21
|
+
async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True, max_redirects=self._max_redirects) as client:
|
|
22
|
+
return await client.put(url, **kwargs)
|
|
23
|
+
|
|
24
|
+
async def patch(self, url: str, **kwargs) -> httpx.Response:
|
|
25
|
+
async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True, max_redirects=self._max_redirects) as client:
|
|
26
|
+
return await client.patch(url, **kwargs)
|
|
27
|
+
|
|
28
|
+
async def delete(self, url: str, **kwargs) -> httpx.Response:
|
|
29
|
+
async with httpx.AsyncClient(timeout=self._timeout, follow_redirects=True, max_redirects=self._max_redirects) as client:
|
|
30
|
+
return await client.delete(url, **kwargs)
|