Python SDK for shellbound Django applications. Provides ShellApp, ShardContext, ShellboundMiddleware. Emits Chronicle events to stdout in dev mode. Includes fix for IndexError in apps.py when DJANGO_SETTINGS_MODULE has no dots (e.g. instance_settings). Shard name now falls back safely without eager default argument parsing. Implements SHELLBOUND-APP-0001 §4 (dev mode). Wired into entropyopposition as of 2026-03-18.
149 lines
4.7 KiB
Python
149 lines
4.7 KiB
Python
"""ShellApp — the main SDK entry point.
|
|
|
|
Usage:
|
|
from substrate_sdk import ShellApp
|
|
|
|
app = ShellApp(shard_name="my-app", version="1.0.0", capabilities={"network": True})
|
|
context = app.register()
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import socket
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from .chronicle import LAYER_APPLICATION, ShardEmitter, emit_event
|
|
from .context import ShardContext
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SHELL_SOCKET = os.environ.get("SUBSTRATE_SHELL_SOCKET", "/run/bascule/shard.sock")
|
|
REGISTER_TIMEOUT = float(os.environ.get("SUBSTRATE_REGISTER_TIMEOUT", "5.0"))
|
|
|
|
|
|
class ShellApp:
|
|
"""A shellbound application instance.
|
|
|
|
Non-fatal everywhere: shell unavailable → dev mode. Emit fails → logged.
|
|
Never raises due to shell issues. Never blocks startup >5 seconds.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
shard_name: str,
|
|
version: str = "0.0.0",
|
|
capabilities: Optional[dict] = None,
|
|
language: str = "python",
|
|
):
|
|
self.shard_name = shard_name
|
|
self.version = version
|
|
self.capabilities = capabilities or {}
|
|
self.language = language
|
|
self._context: Optional[ShardContext] = None
|
|
self._emitter: Optional[ShardEmitter] = None
|
|
self._socket: Optional[socket.socket] = None
|
|
|
|
def register(self) -> ShardContext:
|
|
"""Register with bascule shell. Falls back to dev mode if unavailable."""
|
|
context = ShardContext(
|
|
shard_name=self.shard_name,
|
|
shard_id=str(uuid.uuid4()),
|
|
language=self.language,
|
|
version=self.version,
|
|
)
|
|
|
|
if os.path.exists(SHELL_SOCKET):
|
|
try:
|
|
context = self._register_with_shell(context)
|
|
except Exception as e:
|
|
logger.warning("[substrate-sdk] Shell registration failed (dev mode): %s", e)
|
|
else:
|
|
logger.info("[substrate-sdk] %s: running without shell — development mode", self.shard_name)
|
|
|
|
self._context = context
|
|
self._emitter = ShardEmitter(context)
|
|
|
|
self._emitter.emit(
|
|
"APP_SHARD_STARTED",
|
|
LAYER_APPLICATION,
|
|
{
|
|
"shard_name": self.shard_name,
|
|
"shard_id": context.shard_id,
|
|
"shard_did": context.shard_did,
|
|
"language": self.language,
|
|
"version": self.version,
|
|
"capabilities": self.capabilities,
|
|
"ffc_did": context.ffc_did,
|
|
"governed": context.is_governed,
|
|
},
|
|
)
|
|
|
|
return context
|
|
|
|
def shutdown(self, reason: str = "normal") -> None:
|
|
"""Deregister from shell. Emits APP_SHARD_STOPPED."""
|
|
if self._emitter and self._context:
|
|
self._emitter.emit(
|
|
"APP_SHARD_STOPPED",
|
|
LAYER_APPLICATION,
|
|
{"shard_name": self.shard_name, "shard_id": self._context.shard_id, "reason": reason},
|
|
)
|
|
|
|
if self._socket:
|
|
try:
|
|
msg = json.dumps({
|
|
"type": "SHARD_DEREGISTER",
|
|
"shard_id": self._context.shard_id if self._context else "",
|
|
"reason": reason,
|
|
})
|
|
self._socket.sendall((msg + "\n").encode())
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
self._socket.close()
|
|
|
|
@property
|
|
def emitter(self) -> Optional[ShardEmitter]:
|
|
return self._emitter
|
|
|
|
@property
|
|
def context(self) -> Optional[ShardContext]:
|
|
return self._context
|
|
|
|
def _register_with_shell(self, context: ShardContext) -> ShardContext:
|
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
s.settimeout(REGISTER_TIMEOUT)
|
|
s.connect(SHELL_SOCKET)
|
|
|
|
msg = json.dumps({
|
|
"type": "SHARD_REGISTER",
|
|
"shard_name": self.shard_name,
|
|
"version": self.version,
|
|
"pid": os.getpid(),
|
|
"capabilities": self.capabilities,
|
|
"sdk_version": "0.1.0",
|
|
"language": self.language,
|
|
})
|
|
s.sendall((msg + "\n").encode())
|
|
|
|
buf = b""
|
|
while b"\n" not in buf:
|
|
chunk = s.recv(4096)
|
|
if not chunk:
|
|
break
|
|
buf += chunk
|
|
|
|
response = json.loads(buf.split(b"\n")[0])
|
|
|
|
if response.get("type") == "SHARD_REGISTERED":
|
|
context.shard_did = response.get("shard_did", "")
|
|
context.svid = response.get("svid", "")
|
|
context.accord_hash = response.get("accord_hash", "")
|
|
context.ffc_did = response.get("ffc_did", "")
|
|
context.is_governed = True
|
|
self._socket = s
|
|
logger.info("[substrate-sdk] %s registered. DID: %s", self.shard_name, context.shard_did)
|
|
|
|
return context
|