piilot-sdk 0.2.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.
- piilot/__init__.py +0 -0
- piilot/sdk/__init__.py +40 -0
- piilot/sdk/_runtime.py +40 -0
- piilot/sdk/access.py +44 -0
- piilot/sdk/cache.py +83 -0
- piilot/sdk/connectors.py +245 -0
- piilot/sdk/context.py +233 -0
- piilot/sdk/crypto.py +44 -0
- piilot/sdk/db.py +142 -0
- piilot/sdk/events.py +49 -0
- piilot/sdk/handlers.py +47 -0
- piilot/sdk/http.py +194 -0
- piilot/sdk/i18n.py +37 -0
- piilot/sdk/knowledge.py +47 -0
- piilot/sdk/migrations.py +44 -0
- piilot/sdk/modules.py +64 -0
- piilot/sdk/plugin.py +194 -0
- piilot/sdk/rate_limiter.py +103 -0
- piilot/sdk/scheduler.py +174 -0
- piilot/sdk/session.py +91 -0
- piilot/sdk/storage.py +77 -0
- piilot/sdk/templates.py +74 -0
- piilot/sdk/tools.py +118 -0
- piilot_sdk-0.2.0.dist-info/METADATA +121 -0
- piilot_sdk-0.2.0.dist-info/RECORD +28 -0
- piilot_sdk-0.2.0.dist-info/WHEEL +5 -0
- piilot_sdk-0.2.0.dist-info/licenses/LICENSE +201 -0
- piilot_sdk-0.2.0.dist-info/top_level.txt +1 -0
piilot/__init__.py
ADDED
|
File without changes
|
piilot/sdk/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Piilot SDK — public contract for plugins.
|
|
2
|
+
|
|
3
|
+
A plugin MUST only import from ``piilot.sdk.*``. Any import from
|
|
4
|
+
``backend.*`` is refused at boot by the loader's AST check (best-effort
|
|
5
|
+
honor system — see T04).
|
|
6
|
+
|
|
7
|
+
Surface exposed here:
|
|
8
|
+
|
|
9
|
+
* :class:`Plugin`, :func:`load_manifest`, :class:`Context` — bootstrap primitives
|
|
10
|
+
* ``MANIFEST_SCHEMA``, ``MANIFEST_SCHEMA_VERSION``, ``RESERVED_NAMESPACES`` — contract metadata
|
|
11
|
+
* ``__version__`` — SDK version, semver
|
|
12
|
+
|
|
13
|
+
All other symbols live in the dedicated submodules (``piilot.sdk.handlers``,
|
|
14
|
+
``piilot.sdk.tools``, etc.).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from piilot.sdk.context import Context
|
|
20
|
+
from piilot.sdk.plugin import (
|
|
21
|
+
MANIFEST_SCHEMA,
|
|
22
|
+
MANIFEST_SCHEMA_VERSION,
|
|
23
|
+
RESERVED_NAMESPACES,
|
|
24
|
+
Plugin,
|
|
25
|
+
PluginManifestInvalid,
|
|
26
|
+
load_manifest,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__version__ = "0.2.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"Context",
|
|
33
|
+
"MANIFEST_SCHEMA",
|
|
34
|
+
"MANIFEST_SCHEMA_VERSION",
|
|
35
|
+
"Plugin",
|
|
36
|
+
"PluginManifestInvalid",
|
|
37
|
+
"RESERVED_NAMESPACES",
|
|
38
|
+
"__version__",
|
|
39
|
+
"load_manifest",
|
|
40
|
+
]
|
piilot/sdk/_runtime.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""SDK-internal runtime state shared between the public surface and the host wiring.
|
|
2
|
+
|
|
3
|
+
The underscore prefix marks this module as **internal**. Plugin authors should
|
|
4
|
+
not import from here — the module is not part of the stable public contract
|
|
5
|
+
and may be reshuffled between minor versions pre-v1.0.
|
|
6
|
+
|
|
7
|
+
Why the contextvar lives here rather than in ``backend.*``
|
|
8
|
+
----------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
The SDK is a self-contained package destined for PyPI distribution
|
|
11
|
+
(``piilot-sdk``). Importing ``backend.*`` from within the SDK would:
|
|
12
|
+
|
|
13
|
+
1. Break ``pip install piilot-sdk`` standalone (the package would carry a
|
|
14
|
+
phantom dependency).
|
|
15
|
+
2. Defeat the plugin loader's AST check that refuses ``from backend.*`` in
|
|
16
|
+
plugin source — the check relies on the SDK itself being backend-free.
|
|
17
|
+
|
|
18
|
+
So the contextvar is defined here, and the host backend imports *from the
|
|
19
|
+
SDK*. This inverts the dependency in the right direction: the SDK owns its
|
|
20
|
+
contract, the host plugs in behind.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from contextvars import ContextVar
|
|
26
|
+
|
|
27
|
+
# Holds the namespace of the plugin currently executing in this async task.
|
|
28
|
+
# ``None`` outside a plugin entry point.
|
|
29
|
+
#
|
|
30
|
+
# Who sets it:
|
|
31
|
+
# * The plugin loader (``backend.services.plugins``), around
|
|
32
|
+
# ``plugin.register(ctx)`` at boot.
|
|
33
|
+
# * The ``plugin_gate`` middleware, at the start of every
|
|
34
|
+
# ``/plugins/{namespace}/*`` HTTP request (Lot 4).
|
|
35
|
+
# * The scheduler dispatcher, around each sync/job handler call (Lot 5).
|
|
36
|
+
#
|
|
37
|
+
# Plugins MUST NOT set this themselves — it's managed by the host. Reading
|
|
38
|
+
# via ``.get()`` is allowed (e.g. for logging), but the typed accessor
|
|
39
|
+
# :func:`piilot.sdk.plugin.current_namespace` is the public way.
|
|
40
|
+
current_plugin: ContextVar[str | None] = ContextVar("current_plugin", default=None)
|
piilot/sdk/access.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Access-grant checks for plugin-owned resources.
|
|
2
|
+
|
|
3
|
+
A plugin that creates resources users can access (modules, agents,
|
|
4
|
+
knowledge bases) often needs to gate reads/writes based on who the
|
|
5
|
+
caller is. The core ``access_grants_repo.has_access`` function handles
|
|
6
|
+
the multi-level grant resolution (direct user / group / role / project);
|
|
7
|
+
this module is a thin passthrough.
|
|
8
|
+
|
|
9
|
+
v0.2 — only ``check_grant`` is exposed. A ``require_grant`` FastAPI
|
|
10
|
+
dependency decorator is planned for v0.3+ once a plugin builds enough
|
|
11
|
+
gated routes to justify the wrapper. In v0.2, plugins can assemble the
|
|
12
|
+
same pattern by combining ``check_grant`` with ``Depends(require_user)``
|
|
13
|
+
from ``piilot.sdk.http``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def check_grant(
|
|
22
|
+
company_id: str,
|
|
23
|
+
resource_type: str,
|
|
24
|
+
resource_id: str,
|
|
25
|
+
user_id: str,
|
|
26
|
+
) -> bool:
|
|
27
|
+
"""Return True if ``user_id`` has access to the resource.
|
|
28
|
+
|
|
29
|
+
``resource_type`` is a free-form string; the core reserves
|
|
30
|
+
``"module"``, ``"agent"``, and ``"knowledge_base"`` — plugins are
|
|
31
|
+
free to introduce their own (e.g. ``"myplug.dashboard"``) as long
|
|
32
|
+
as they stay consistent across their ``access_grants.create`` /
|
|
33
|
+
``access_grants.delete`` / ``check_grant`` calls.
|
|
34
|
+
|
|
35
|
+
Resolution order (first match wins):
|
|
36
|
+
|
|
37
|
+
1. No grants exist for the resource → **True** (backward-compatible
|
|
38
|
+
default: a resource without any grant is visible to everyone).
|
|
39
|
+
2. Direct user grant.
|
|
40
|
+
3. Group grant (user is member of a company group that has a grant).
|
|
41
|
+
4. Role grant (user's role matches a role grant).
|
|
42
|
+
5. Project grant (user is member of a project that has a grant).
|
|
43
|
+
"""
|
|
44
|
+
raise NotImplementedError("check_grant — wired by loader at boot")
|
piilot/sdk/cache.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Redis-backed cache for plugins — progress tracking, short-lived state, counters.
|
|
2
|
+
|
|
3
|
+
All primitives are **async**. Keys are automatically prefixed with the
|
|
4
|
+
plugin's namespace (``plugin:{namespace}:{key}``) so plugins cannot
|
|
5
|
+
accidentally read or write each other's data.
|
|
6
|
+
|
|
7
|
+
Values are JSON-serializable (dict, list, str, int, float, bool, None) —
|
|
8
|
+
serialization is handled transparently by the core cache backend.
|
|
9
|
+
|
|
10
|
+
Under the hood: the wired implementations delegate to the core
|
|
11
|
+
``CacheBackend`` (MemoryBackend in dev, RedisBackend in prod; see
|
|
12
|
+
``backend/services/cache.py``). Plugins SHOULD treat the cache as
|
|
13
|
+
best-effort: a Redis outage logs and falls through rather than raising.
|
|
14
|
+
|
|
15
|
+
Plugin context requirement
|
|
16
|
+
--------------------------
|
|
17
|
+
|
|
18
|
+
Every primitive resolves the active plugin's namespace from
|
|
19
|
+
``piilot.sdk._runtime.current_plugin``. Calling these from outside a
|
|
20
|
+
plugin entry point (not inside ``plugin.register()``, not inside a
|
|
21
|
+
request handler, not inside a scheduled job) raises ``RuntimeError``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any, Optional
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def get(key: str) -> Any | None:
|
|
30
|
+
"""Retrieve a value. Returns ``None`` if the key is absent or expired.
|
|
31
|
+
|
|
32
|
+
The effective Redis key is ``plugin:{namespace}:{key}`` — namespace
|
|
33
|
+
prefixing is applied transparently by the wiring.
|
|
34
|
+
"""
|
|
35
|
+
raise NotImplementedError("cache.get() — wired by loader at boot")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def set(key: str, value: Any, ttl: Optional[int] = None) -> None:
|
|
39
|
+
"""Store a value. Value must be JSON-serializable.
|
|
40
|
+
|
|
41
|
+
``ttl`` in seconds. ``None`` means no expiration (discouraged — prefer
|
|
42
|
+
an explicit TTL so plugin data doesn't accumulate indefinitely).
|
|
43
|
+
"""
|
|
44
|
+
raise NotImplementedError("cache.set() — wired by loader at boot")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def delete(key: str) -> None:
|
|
48
|
+
"""Remove a key. No-op if the key doesn't exist (idempotent)."""
|
|
49
|
+
raise NotImplementedError("cache.delete() — wired by loader at boot")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def incr(
|
|
53
|
+
key: str,
|
|
54
|
+
amount: int = 1,
|
|
55
|
+
ttl: Optional[int] = None,
|
|
56
|
+
) -> int:
|
|
57
|
+
"""Atomic increment. Initializes the counter to 0 on first access.
|
|
58
|
+
|
|
59
|
+
Returns the new value. ``ttl`` (in seconds) is applied **only on the
|
|
60
|
+
first creation** of the key — matching Redis ``INCR`` + ``EXPIRE``
|
|
61
|
+
semantics. Subsequent calls don't reset the TTL.
|
|
62
|
+
|
|
63
|
+
Useful for: request counters, rate-limiting windows, per-company
|
|
64
|
+
usage quotas.
|
|
65
|
+
"""
|
|
66
|
+
raise NotImplementedError("cache.incr() — wired by loader at boot")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Reserved — v0.3+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_redis() -> Any:
|
|
75
|
+
"""Return the raw async Redis client.
|
|
76
|
+
|
|
77
|
+
Reserved for v0.3+. In v0.2 this remains a stub — direct Redis access
|
|
78
|
+
would bypass the namespace isolation, so we don't expose it yet.
|
|
79
|
+
Use the high-level ``get`` / ``set`` / ``delete`` / ``incr`` instead.
|
|
80
|
+
"""
|
|
81
|
+
raise NotImplementedError(
|
|
82
|
+
"get_redis() — reserved for v0.3+. Use the high-level cache primitives."
|
|
83
|
+
)
|
piilot/sdk/connectors.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Settings integrations — declaration + CRUD runtime (SDK §3 line 7).
|
|
2
|
+
|
|
3
|
+
Spike Pennylane + ESMS (21/04/2026) revealed that a plugin needs not
|
|
4
|
+
only to **declare** its connector in the manifest, but also to
|
|
5
|
+
**read/write** its live connections and sync logs at runtime. Both
|
|
6
|
+
concerns are exposed here in a single module (no separate
|
|
7
|
+
``piilot.sdk.connections`` — keeps the surface tight).
|
|
8
|
+
|
|
9
|
+
Declaration at ``plugin.register()``::
|
|
10
|
+
|
|
11
|
+
from piilot.sdk.connectors import register_connector
|
|
12
|
+
|
|
13
|
+
register_connector({
|
|
14
|
+
"id": "pennylane.api",
|
|
15
|
+
"auth_type": "api_key",
|
|
16
|
+
"credentials_schema": [
|
|
17
|
+
{"name": "api_key", "type": "secret", "required": True},
|
|
18
|
+
],
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
Runtime CRUD (called from handlers / tools / jobs)::
|
|
22
|
+
|
|
23
|
+
from piilot.sdk.connectors import (
|
|
24
|
+
list_connections, get_connection, save_connection,
|
|
25
|
+
update_sync_state, delete_connection,
|
|
26
|
+
list_connections_due_for_sync, log_sync,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
Credentials auto-encryption
|
|
30
|
+
---------------------------
|
|
31
|
+
|
|
32
|
+
When a plugin calls :func:`save_connection`, each field of the
|
|
33
|
+
``credentials`` dict is compared against the plugin's declared
|
|
34
|
+
``credentials_schema`` (from its ``register_connector`` call). Fields
|
|
35
|
+
marked ``{"type": "secret"}`` are **transparently encrypted** before
|
|
36
|
+
being persisted. Non-secret fields (``token_expires_at``, ``scopes``,
|
|
37
|
+
``external_account_id``, …) are stored in clear in the connection row.
|
|
38
|
+
|
|
39
|
+
If a plugin's schema declares a field that doesn't map to a dedicated
|
|
40
|
+
connection column (``access_token_encrypted``, ``refresh_token_encrypted``,
|
|
41
|
+
``token_expires_at``, ``scopes``, ``external_account_id``), the extra
|
|
42
|
+
field ends up in the JSONB ``config`` column. Secrets in ``config`` are
|
|
43
|
+
also encrypted.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import logging
|
|
49
|
+
from typing import Any, Optional
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
_REGISTERED: list[dict[str, Any]] = []
|
|
54
|
+
|
|
55
|
+
# Maps plugin namespace → aggregated credentials_schema (list of field dicts
|
|
56
|
+
# across all connectors registered by that plugin). Populated by
|
|
57
|
+
# :func:`register_connector` (which reads the ``current_plugin`` contextvar)
|
|
58
|
+
# and consumed at runtime by the wired :func:`save_connection` to decide
|
|
59
|
+
# which fields to auto-encrypt.
|
|
60
|
+
_SCHEMA_BY_NAMESPACE: dict[str, list[dict[str, Any]]] = {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def register_connector(spec: dict[str, Any]) -> None:
|
|
64
|
+
"""Declare a connector. Called during ``plugin.register()``.
|
|
65
|
+
|
|
66
|
+
Expected spec shape::
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
"id": "pennylane.api", # MUST be namespaced
|
|
70
|
+
"auth_type": "api_key" | "oauth2" | "basic" | "custom",
|
|
71
|
+
"credentials_schema": [
|
|
72
|
+
{"name": "api_key", "type": "secret", "required": True},
|
|
73
|
+
{"name": "token_expires_at", "type": "datetime"},
|
|
74
|
+
...
|
|
75
|
+
],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
The ``credentials_schema`` drives the auto-encryption of secrets in
|
|
79
|
+
:func:`save_connection` — any field with ``type == "secret"`` is
|
|
80
|
+
encrypted transparently before reaching the DB.
|
|
81
|
+
"""
|
|
82
|
+
if "id" not in spec or "auth_type" not in spec:
|
|
83
|
+
raise ValueError("connector spec must contain 'id' and 'auth_type'")
|
|
84
|
+
_REGISTERED.append(spec)
|
|
85
|
+
|
|
86
|
+
# Index the credentials_schema by plugin namespace so save_connection
|
|
87
|
+
# can lookup which fields to auto-encrypt.
|
|
88
|
+
# The host (loader / middleware / scheduler) sets ``current_plugin``
|
|
89
|
+
# contextvar around any plugin entry point — see piilot.sdk._runtime.
|
|
90
|
+
from piilot.sdk._runtime import current_plugin
|
|
91
|
+
ns = current_plugin.get()
|
|
92
|
+
|
|
93
|
+
if ns is not None:
|
|
94
|
+
schema = spec.get("credentials_schema") or []
|
|
95
|
+
bucket = _SCHEMA_BY_NAMESPACE.setdefault(ns, [])
|
|
96
|
+
# Merge (last-wins on duplicate names)
|
|
97
|
+
existing_names = {f.get("name") for f in bucket}
|
|
98
|
+
for field in schema:
|
|
99
|
+
name = field.get("name")
|
|
100
|
+
if name in existing_names:
|
|
101
|
+
# Replace existing field — last declaration wins
|
|
102
|
+
bucket[:] = [f for f in bucket if f.get("name") != name]
|
|
103
|
+
bucket.append(field)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _drain() -> list[dict[str, Any]]:
|
|
107
|
+
collected = list(_REGISTERED)
|
|
108
|
+
_REGISTERED.clear()
|
|
109
|
+
return collected
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _get_schema_for_namespace(namespace: str) -> list[dict[str, Any]]:
|
|
113
|
+
"""Internal — returns the aggregated credentials_schema for a plugin.
|
|
114
|
+
|
|
115
|
+
Used by the wired :func:`save_connection` to decide which fields to
|
|
116
|
+
auto-encrypt. Returns an empty list if the plugin hasn't called
|
|
117
|
+
:func:`register_connector`.
|
|
118
|
+
"""
|
|
119
|
+
return list(_SCHEMA_BY_NAMESPACE.get(namespace, []))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _reset_schema_registry_for_tests() -> None:
|
|
123
|
+
"""Test helper — clear the schema registry between tests."""
|
|
124
|
+
_SCHEMA_BY_NAMESPACE.clear()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Runtime CRUD — wired by the loader via ``sdk_wiring._wire_connectors()``.
|
|
129
|
+
# Signatures below are the authoritative contract; implementations live in
|
|
130
|
+
# ``backend/services/plugin_runtime/sdk_wiring.py``.
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def list_connections(
|
|
135
|
+
company_id: str,
|
|
136
|
+
*,
|
|
137
|
+
provider: Optional[str] = None,
|
|
138
|
+
) -> list[dict[str, Any]]:
|
|
139
|
+
"""List active connections of the tenant.
|
|
140
|
+
|
|
141
|
+
When ``provider`` is given, restricts to that provider.
|
|
142
|
+
"""
|
|
143
|
+
raise NotImplementedError("list_connections() — wired by loader at boot")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_connection(connection_id: str) -> Optional[dict[str, Any]]:
|
|
147
|
+
"""Fetch a connection by id. Fields marked ``type: secret`` are returned encrypted —
|
|
148
|
+
call ``piilot.sdk.crypto.decrypt`` explicitly to read the plaintext."""
|
|
149
|
+
raise NotImplementedError("get_connection() — wired by loader at boot")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def save_connection(
|
|
153
|
+
company_id: str,
|
|
154
|
+
provider: str,
|
|
155
|
+
auth_type: str,
|
|
156
|
+
label: str,
|
|
157
|
+
credentials: dict[str, Any],
|
|
158
|
+
config: Optional[dict[str, Any]] = None,
|
|
159
|
+
*,
|
|
160
|
+
connection_id: Optional[str] = None,
|
|
161
|
+
) -> dict[str, Any]:
|
|
162
|
+
"""Create or update a connection.
|
|
163
|
+
|
|
164
|
+
Auto-encryption contract: each field of ``credentials`` is compared
|
|
165
|
+
against the plugin's declared ``credentials_schema`` (from its
|
|
166
|
+
``register_connector`` call). Fields with ``type == "secret"`` are
|
|
167
|
+
transparently encrypted via :func:`piilot.sdk.crypto.encrypt` before
|
|
168
|
+
being persisted. Plugins pass plaintext values.
|
|
169
|
+
|
|
170
|
+
Known mapping for non-secret fields (go to dedicated columns):
|
|
171
|
+
|
|
172
|
+
- ``access_token`` (secret) → ``access_token_encrypted`` column
|
|
173
|
+
- ``refresh_token`` (secret) → ``refresh_token_encrypted`` column
|
|
174
|
+
- ``api_key`` (secret) → ``access_token_encrypted`` column
|
|
175
|
+
- ``token_expires_at`` → dedicated column (not encrypted)
|
|
176
|
+
- ``scopes`` → dedicated column (not encrypted)
|
|
177
|
+
- ``external_account_id`` → dedicated column (not encrypted)
|
|
178
|
+
|
|
179
|
+
Any other field ends up in the JSONB ``config`` column. Secrets
|
|
180
|
+
declared there are also encrypted. The ``config`` parameter merges
|
|
181
|
+
with fields routed to config from ``credentials``.
|
|
182
|
+
|
|
183
|
+
``connection_id``: when ``None``, creates. When provided, updates
|
|
184
|
+
that specific connection (within the tenant).
|
|
185
|
+
|
|
186
|
+
Returns the persisted connection row.
|
|
187
|
+
"""
|
|
188
|
+
raise NotImplementedError("save_connection() — wired by loader at boot")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def update_sync_state(
|
|
192
|
+
connection_id: str,
|
|
193
|
+
*,
|
|
194
|
+
last_sync_at: Any = None,
|
|
195
|
+
next_sync_at: Any = None,
|
|
196
|
+
status: Optional[str] = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Idempotent update of the sync-tracking fields on a connection."""
|
|
199
|
+
raise NotImplementedError("update_sync_state() — wired by loader at boot")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def delete_connection(connection_id: str) -> bool:
|
|
203
|
+
"""Delete a connection. Returns True if a row was removed.
|
|
204
|
+
|
|
205
|
+
Hard delete — cascades to ``integrations_master.sync_logs`` via FK.
|
|
206
|
+
"""
|
|
207
|
+
raise NotImplementedError("delete_connection() — wired by loader at boot")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def list_connections_due_for_sync(
|
|
211
|
+
provider: Optional[str] = None,
|
|
212
|
+
) -> list[dict[str, Any]]:
|
|
213
|
+
"""List all active connections whose ``next_sync_at`` has passed.
|
|
214
|
+
|
|
215
|
+
When ``provider`` is given, restricts to that provider. When ``None``,
|
|
216
|
+
returns due connections across all providers (used by the generic
|
|
217
|
+
scheduler dispatcher — see Lot 5). Includes encrypted credentials
|
|
218
|
+
fields, ready for the sync handler to decrypt and use.
|
|
219
|
+
"""
|
|
220
|
+
raise NotImplementedError(
|
|
221
|
+
"list_connections_due_for_sync() — wired by loader at boot"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def log_sync(
|
|
226
|
+
connection_id: str,
|
|
227
|
+
company_id: str,
|
|
228
|
+
sync_type: str,
|
|
229
|
+
status: str,
|
|
230
|
+
started_at: Any,
|
|
231
|
+
completed_at: Any,
|
|
232
|
+
*,
|
|
233
|
+
items_synced: int = 0,
|
|
234
|
+
items_failed: int = 0,
|
|
235
|
+
error_message: Optional[str] = None,
|
|
236
|
+
s3_path: Optional[str] = None,
|
|
237
|
+
) -> dict[str, Any]:
|
|
238
|
+
"""Append a run entry to ``integrations_master.sync_logs``.
|
|
239
|
+
|
|
240
|
+
``sync_type`` values: ``"full"``, ``"firm_full"``, ``"incremental"``,
|
|
241
|
+
``"manual"``. ``status`` values: ``"success"``, ``"partial"``, ``"error"``.
|
|
242
|
+
|
|
243
|
+
Returns the created sync_log row (with auto-generated ``id``).
|
|
244
|
+
"""
|
|
245
|
+
raise NotImplementedError("log_sync() — wired by loader at boot")
|
piilot/sdk/context.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Runtime context injected into ``plugin.register(ctx)`` and into handlers/tools.
|
|
2
|
+
|
|
3
|
+
Carries the database handle, the current company and user, a logger, and
|
|
4
|
+
a bundle of registries the plugin uses to expose primitives (handlers,
|
|
5
|
+
tools, templates, migrations, i18n, connectors, scheduler, events).
|
|
6
|
+
|
|
7
|
+
The loader (T04) is responsible for wiring real implementations into the
|
|
8
|
+
registries. Stubs here raise :class:`NotImplementedError` with a clear
|
|
9
|
+
"wired by loader at boot" message — that way a unit test importing the
|
|
10
|
+
Context fails loudly if it forgets to inject a fake.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CompanyRef(BaseModel):
|
|
21
|
+
"""Minimal shape of the active company passed to plugin code."""
|
|
22
|
+
|
|
23
|
+
id: str
|
|
24
|
+
name: str
|
|
25
|
+
slug: Optional[str] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UserRef(BaseModel):
|
|
29
|
+
"""Minimal shape of the acting user. Absent at boot / scheduler runs."""
|
|
30
|
+
|
|
31
|
+
id: str
|
|
32
|
+
email: Optional[str] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Registry stubs — the loader (T04) replaces these with real implementations
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _RegistryStub:
|
|
41
|
+
"""Base stub that raises a uniform error when used before wiring."""
|
|
42
|
+
|
|
43
|
+
_NAME: str = "Registry"
|
|
44
|
+
|
|
45
|
+
def _not_wired(self, method: str) -> "NotImplementedError":
|
|
46
|
+
return NotImplementedError(
|
|
47
|
+
f"{self._NAME}.{method}() is a stub — wired by loader at boot (T04)"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class HandlerRegistry(_RegistryStub):
|
|
52
|
+
_NAME = "HandlerRegistry"
|
|
53
|
+
|
|
54
|
+
def register(self, handler_id: str, fn: Any) -> None:
|
|
55
|
+
raise self._not_wired("register")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ToolRegistry(_RegistryStub):
|
|
59
|
+
_NAME = "ToolRegistry"
|
|
60
|
+
|
|
61
|
+
def register(self, spec: dict[str, Any]) -> None:
|
|
62
|
+
raise self._not_wired("register")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TemplateRegistry(_RegistryStub):
|
|
66
|
+
_NAME = "TemplateRegistry"
|
|
67
|
+
|
|
68
|
+
def register(self, template: dict[str, Any]) -> None:
|
|
69
|
+
raise self._not_wired("register")
|
|
70
|
+
|
|
71
|
+
def load_dir(self, path: str) -> None:
|
|
72
|
+
raise self._not_wired("load_dir")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class MigrationRegistry(_RegistryStub):
|
|
76
|
+
_NAME = "MigrationRegistry"
|
|
77
|
+
|
|
78
|
+
def register_schema(self, namespace: str, pyfile: str, rel_dir: str) -> None:
|
|
79
|
+
raise self._not_wired("register_schema")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class I18nRegistry(_RegistryStub):
|
|
83
|
+
_NAME = "I18nRegistry"
|
|
84
|
+
|
|
85
|
+
def register_locales(self, namespace: str, pyfile: str, rel_dir: str) -> None:
|
|
86
|
+
raise self._not_wired("register_locales")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ConnectorRegistry(_RegistryStub):
|
|
90
|
+
"""Connectors declaration + runtime CRUD (SDK §3 line 7, spike 21/04)."""
|
|
91
|
+
|
|
92
|
+
_NAME = "ConnectorRegistry"
|
|
93
|
+
|
|
94
|
+
# Declaration (at register() time)
|
|
95
|
+
def register_connector(self, spec: dict[str, Any]) -> None:
|
|
96
|
+
raise self._not_wired("register_connector")
|
|
97
|
+
|
|
98
|
+
# Runtime CRUD (called from handlers / tools / jobs)
|
|
99
|
+
def list_connections(self, company_id: str) -> list[dict[str, Any]]:
|
|
100
|
+
raise self._not_wired("list_connections")
|
|
101
|
+
|
|
102
|
+
def get_connection(self, connection_id: str) -> Optional[dict[str, Any]]:
|
|
103
|
+
raise self._not_wired("get_connection")
|
|
104
|
+
|
|
105
|
+
def save_connection(
|
|
106
|
+
self,
|
|
107
|
+
company_id: str,
|
|
108
|
+
label: str,
|
|
109
|
+
credentials: dict[str, Any],
|
|
110
|
+
config: Optional[dict[str, Any]] = None,
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
raise self._not_wired("save_connection")
|
|
113
|
+
|
|
114
|
+
def update_sync_state(
|
|
115
|
+
self,
|
|
116
|
+
connection_id: str,
|
|
117
|
+
*,
|
|
118
|
+
last_sync_at: Any = None,
|
|
119
|
+
next_sync_at: Any = None,
|
|
120
|
+
status: Optional[str] = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
raise self._not_wired("update_sync_state")
|
|
123
|
+
|
|
124
|
+
def log_sync(
|
|
125
|
+
self,
|
|
126
|
+
connection_id: str,
|
|
127
|
+
status: str,
|
|
128
|
+
items_count: int,
|
|
129
|
+
duration_ms: int,
|
|
130
|
+
error: Optional[str] = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
raise self._not_wired("log_sync")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class SchedulerRegistry(_RegistryStub):
|
|
136
|
+
_NAME = "SchedulerRegistry"
|
|
137
|
+
|
|
138
|
+
def register_job(self, job_id: str, handler: Any, schedule: str) -> None:
|
|
139
|
+
raise self._not_wired("register_job")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class EventBus(_RegistryStub):
|
|
143
|
+
_NAME = "EventBus"
|
|
144
|
+
|
|
145
|
+
def on(self, event_name: str, callback: Any) -> None:
|
|
146
|
+
raise self._not_wired("on")
|
|
147
|
+
|
|
148
|
+
def emit(self, event_name: str, payload: dict[str, Any]) -> None:
|
|
149
|
+
raise self._not_wired("emit")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Context itself
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Context(BaseModel):
|
|
158
|
+
"""Runtime context passed to every plugin entry point.
|
|
159
|
+
|
|
160
|
+
⚠️ God-object surveillance (T02 / SDK_CHANGELOG): 10 sub-registries
|
|
161
|
+
in v0.1. Split into sub-namespaces if we cross 12 in v0.2/v0.3.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
165
|
+
|
|
166
|
+
db: Any
|
|
167
|
+
company: Optional[CompanyRef] = None # None at register() time (boot), set at runtime
|
|
168
|
+
user: Optional[UserRef] = None
|
|
169
|
+
logger: Any
|
|
170
|
+
|
|
171
|
+
# Registries: typed as Any in v0.1 so the loader can inject either the
|
|
172
|
+
# stub classes declared below or the concrete implementations from
|
|
173
|
+
# ``backend.services.plugin_runtime.registries``. Plugins should rely
|
|
174
|
+
# on the duck-typed public methods documented in SDK_SPEC §3, not on
|
|
175
|
+
# the Python class identity. If we ever want strict typing for IDE
|
|
176
|
+
# autocomplete, we'll expose Protocols in v0.2.
|
|
177
|
+
handlers: Any
|
|
178
|
+
tools: Any
|
|
179
|
+
templates: Any
|
|
180
|
+
migrations: Any
|
|
181
|
+
i18n: Any
|
|
182
|
+
connectors: Any
|
|
183
|
+
scheduler: Any
|
|
184
|
+
events: Any
|
|
185
|
+
|
|
186
|
+
# Session scope (PLT-8 generic plumbing — used by multi-connection plugins
|
|
187
|
+
# like Pennylane Firm/Company). In v0.1 these are stubs; the loader wires
|
|
188
|
+
# them to ``backend.agents.session.engine.session_manager``.
|
|
189
|
+
def set_scope(
|
|
190
|
+
self,
|
|
191
|
+
plugin: str,
|
|
192
|
+
*,
|
|
193
|
+
connection_id: Optional[str] = None,
|
|
194
|
+
connection_label: Optional[str] = None,
|
|
195
|
+
scope_id: Optional[str] = None,
|
|
196
|
+
scope_label: Optional[str] = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
raise NotImplementedError("Context.set_scope() — wired by loader at boot (T04)")
|
|
199
|
+
|
|
200
|
+
def get_scope(self) -> Optional[dict[str, Any]]:
|
|
201
|
+
raise NotImplementedError("Context.get_scope() — wired by loader at boot (T04)")
|
|
202
|
+
|
|
203
|
+
def clear_scope(self) -> None:
|
|
204
|
+
raise NotImplementedError("Context.clear_scope() — wired by loader at boot (T04)")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Free helpers — thin re-exports kept here so plugins can do
|
|
209
|
+
# ``from piilot.sdk.context import get_db, get_current_company``
|
|
210
|
+
# The loader replaces these at boot with context-var backed implementations.
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_db() -> Any:
|
|
215
|
+
raise NotImplementedError("piilot.sdk.context.get_db() — wired by loader at boot (T04)")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def get_current_company() -> CompanyRef:
|
|
219
|
+
raise NotImplementedError(
|
|
220
|
+
"piilot.sdk.context.get_current_company() — wired by loader at boot (T04)"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def get_current_user() -> Optional[UserRef]:
|
|
225
|
+
raise NotImplementedError(
|
|
226
|
+
"piilot.sdk.context.get_current_user() — wired by loader at boot (T04)"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def get_logger() -> Any:
|
|
231
|
+
raise NotImplementedError(
|
|
232
|
+
"piilot.sdk.context.get_logger() — wired by loader at boot (T04)"
|
|
233
|
+
)
|