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 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
+ )
@@ -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
+ )