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.
@@ -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
+ ]
@@ -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.ai.client import AIClient, CompletionResult
4
+ __all__ = ["AIClient", "CompletionResult"]
@@ -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
@@ -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())
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026 Imperal, Inc., Valentin Scerbacov, and contributors
2
+ # Licensed under the AGPL-3.0 License. See LICENSE file for details.
@@ -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)
@@ -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.db.client import DBClient
4
+ __all__ = ["DBClient"]
@@ -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)
@@ -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,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.http.client import HTTPClient
4
+ __all__ = ["HTTPClient"]
@@ -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)