igni-extension 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.
- igni_extension/__init__.py +78 -0
- igni_extension/caller.py +226 -0
- igni_extension/channel.py +295 -0
- igni_extension/clock.py +12 -0
- igni_extension/environment.py +194 -0
- igni_extension/errors.py +65 -0
- igni_extension/extension.py +450 -0
- igni_extension/hints.py +89 -0
- igni_extension/hooks.py +295 -0
- igni_extension/host.py +17 -0
- igni_extension/host_model.py +2044 -0
- igni_extension/manifest.py +67 -0
- igni_extension/members.py +164 -0
- igni_extension/notifications.py +153 -0
- igni_extension/prompt.py +62 -0
- igni_extension/py.typed +0 -0
- igni_extension/service.py +135 -0
- igni_extension/service_events.py +114 -0
- igni_extension/session.py +71 -0
- igni_extension/signals.py +86 -0
- igni_extension/testing.py +706 -0
- igni_extension/threads.py +61 -0
- igni_extension/tokens.py +197 -0
- igni_extension-0.1.0.dist-info/METADATA +789 -0
- igni_extension-0.1.0.dist-info/RECORD +28 -0
- igni_extension-0.1.0.dist-info/WHEEL +4 -0
- igni_extension-0.1.0.dist-info/licenses/LICENSE +202 -0
- igni_extension-0.1.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""igni-extension: an extension's process, as its author writes it.
|
|
2
|
+
|
|
3
|
+
ext = IgniExtension("acme.example")
|
|
4
|
+
|
|
5
|
+
@ext.tool(hints=ToolHints(read_only=True, destructive=False, idempotent=True, open_world=False))
|
|
6
|
+
async def do_thing() -> str: ...
|
|
7
|
+
|
|
8
|
+
ext.run()
|
|
9
|
+
|
|
10
|
+
See the package README for the whole surface.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from igni_extension.caller import Caller, CallerKind, NoCaller
|
|
14
|
+
from igni_extension.environment import (
|
|
15
|
+
Assets,
|
|
16
|
+
ExtensionEnvironment,
|
|
17
|
+
HostEndpoint,
|
|
18
|
+
Secrets,
|
|
19
|
+
Workspace,
|
|
20
|
+
WorkspaceMode,
|
|
21
|
+
)
|
|
22
|
+
from igni_extension.errors import (
|
|
23
|
+
HostError,
|
|
24
|
+
HostErrorCode,
|
|
25
|
+
HostUnavailable,
|
|
26
|
+
IgniExtensionError,
|
|
27
|
+
NotConfigured,
|
|
28
|
+
NotSignedIn,
|
|
29
|
+
)
|
|
30
|
+
from igni_extension.extension import IgniExtension, ToolNames
|
|
31
|
+
from igni_extension.hints import ToolHints, ToolHintsMissing
|
|
32
|
+
from igni_extension.hooks import Allow, HookEvent, Hooks, Modify, Reject
|
|
33
|
+
from igni_extension.host import Host, Notifications
|
|
34
|
+
from igni_extension.host_model import SCHEMA_VERSION
|
|
35
|
+
from igni_extension.members import Property, Signal
|
|
36
|
+
from igni_extension.notifications import LocalNotification, Target
|
|
37
|
+
from igni_extension.prompt import PromptConditions
|
|
38
|
+
from igni_extension.service import ServiceClient
|
|
39
|
+
from igni_extension.service_events import ServiceEvent
|
|
40
|
+
from igni_extension.tokens import SignedIn
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"Allow",
|
|
44
|
+
"Assets",
|
|
45
|
+
"Caller",
|
|
46
|
+
"CallerKind",
|
|
47
|
+
"ExtensionEnvironment",
|
|
48
|
+
"Host",
|
|
49
|
+
"HostEndpoint",
|
|
50
|
+
"HostError",
|
|
51
|
+
"HostErrorCode",
|
|
52
|
+
"HookEvent",
|
|
53
|
+
"Hooks",
|
|
54
|
+
"HostUnavailable",
|
|
55
|
+
"IgniExtension",
|
|
56
|
+
"IgniExtensionError",
|
|
57
|
+
"LocalNotification",
|
|
58
|
+
"Modify",
|
|
59
|
+
"NoCaller",
|
|
60
|
+
"NotConfigured",
|
|
61
|
+
"NotSignedIn",
|
|
62
|
+
"Notifications",
|
|
63
|
+
"PromptConditions",
|
|
64
|
+
"Reject",
|
|
65
|
+
"Property",
|
|
66
|
+
"SCHEMA_VERSION",
|
|
67
|
+
"Secrets",
|
|
68
|
+
"ServiceClient",
|
|
69
|
+
"ServiceEvent",
|
|
70
|
+
"Signal",
|
|
71
|
+
"SignedIn",
|
|
72
|
+
"Target",
|
|
73
|
+
"ToolNames",
|
|
74
|
+
"ToolHints",
|
|
75
|
+
"ToolHintsMissing",
|
|
76
|
+
"Workspace",
|
|
77
|
+
"WorkspaceMode",
|
|
78
|
+
]
|
igni_extension/caller.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Who a call is for: `Caller`.
|
|
2
|
+
|
|
3
|
+
Every request igni sends this process — a tool call, a page's call, a
|
|
4
|
+
hook — names the producer it is for: a person (with their organisation
|
|
5
|
+
and the one group they belong to) or an app. A tool or hook handler that wants it declares a
|
|
6
|
+
parameter typed `Caller`, and the SDK passes it in; anything running
|
|
7
|
+
inside that call can also ask `Caller.current()`.
|
|
8
|
+
|
|
9
|
+
@ext.tool(hints=ToolHints(read_only=True, destructive=False, idempotent=True, open_world=False))
|
|
10
|
+
async def mine(caller: Caller) -> list[str]:
|
|
11
|
+
response = await ext.service.for_(caller).get("/v1/things")
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
A tool typed `caller: Caller` fails when igni sent no caller; one typed
|
|
15
|
+
`caller: Caller | None` runs with `None` instead.
|
|
16
|
+
|
|
17
|
+
The extension never decides who may call it: igni only forwards a call
|
|
18
|
+
the producer may make. Its job is to serve the caller it is handed — and
|
|
19
|
+
to keep callers apart, which is why the caller lives in the request, not
|
|
20
|
+
in a global. Concurrent calls each see their own (`contextvars`), and a
|
|
21
|
+
plain `def` running in a worker thread sees its call's caller too.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import inspect
|
|
28
|
+
from collections.abc import Callable, Iterator
|
|
29
|
+
from contextvars import ContextVar
|
|
30
|
+
from enum import StrEnum
|
|
31
|
+
from functools import wraps
|
|
32
|
+
from typing import Any, ClassVar, get_type_hints
|
|
33
|
+
|
|
34
|
+
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
|
|
35
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CallerKind(StrEnum):
|
|
39
|
+
PERSON = "person"
|
|
40
|
+
APP = "app"
|
|
41
|
+
#: One end user of an app, a producer of its own.
|
|
42
|
+
SPACE = "space"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NoCaller(LookupError):
|
|
46
|
+
"""Asked for the caller outside a call igni made: in `on_start` work, say."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Caller(BaseModel):
|
|
50
|
+
"""The producer one call is for."""
|
|
51
|
+
|
|
52
|
+
model_config = ConfigDict(frozen=True)
|
|
53
|
+
|
|
54
|
+
#: Where the host puts it: the MCP request's `_meta` key, and the hook frame's field.
|
|
55
|
+
META_KEY: ClassVar[str] = "igni/principal"
|
|
56
|
+
#: How a token request names whom the token is for.
|
|
57
|
+
TOKEN_HEADER: ClassVar[str] = "X-Igni-Principal" # noqa: S105 — a header's name, not a secret
|
|
58
|
+
|
|
59
|
+
kind: CallerKind
|
|
60
|
+
#: The person's or the app's id on igni server.
|
|
61
|
+
id: str
|
|
62
|
+
#: Their organisation.
|
|
63
|
+
org: str | None = None
|
|
64
|
+
#: The one group a person belongs to in `org`; an app has none.
|
|
65
|
+
group: str | None = None
|
|
66
|
+
#: The igni session the call comes from, when there is one.
|
|
67
|
+
session: str | None = None
|
|
68
|
+
#: A space's app (its id); `None` for a person or an app.
|
|
69
|
+
app: str | None = None
|
|
70
|
+
#: The app's own name for a space's end user.
|
|
71
|
+
external_id: str | None = None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def ref(self) -> str:
|
|
75
|
+
"""`person:<id>` — how the host's token endpoint is told whom a token is for."""
|
|
76
|
+
return f"{self.kind.value}:{self.id}"
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def of(cls, raw: Any) -> Caller | None:
|
|
80
|
+
"""A caller from the wire, or `None` when there is none or it is malformed."""
|
|
81
|
+
if not isinstance(raw, dict):
|
|
82
|
+
return None
|
|
83
|
+
try:
|
|
84
|
+
return cls.model_validate(raw)
|
|
85
|
+
except ValidationError:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def current(cls) -> Caller | None:
|
|
90
|
+
"""The caller of the call being served now, or `None` outside one."""
|
|
91
|
+
scoped = _SCOPE.get()
|
|
92
|
+
if scoped is not None:
|
|
93
|
+
return scoped
|
|
94
|
+
meta = RequestMeta.current()
|
|
95
|
+
return cls.of(meta.get(cls.META_KEY)) if meta is not None else None
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def required(cls) -> Caller:
|
|
99
|
+
"""The current caller; `NoCaller` outside a call igni made."""
|
|
100
|
+
caller = cls.current()
|
|
101
|
+
if caller is None:
|
|
102
|
+
raise NoCaller(
|
|
103
|
+
"no caller: this runs outside a call igni made, or igni sent none. "
|
|
104
|
+
"Work with no caller uses the service as the extension itself."
|
|
105
|
+
)
|
|
106
|
+
return caller
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
#: The caller of the call being served, where the SDK set it itself (a hook).
|
|
110
|
+
_SCOPE: ContextVar[Caller | None] = ContextVar("igni_caller", default=None)
|
|
111
|
+
|
|
112
|
+
#: The `_meta` of the MCP request being served, set by `RequestMeta.middleware`.
|
|
113
|
+
_META: ContextVar[dict[str, Any] | None] = ContextVar("igni_request_meta", default=None)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class RequestMeta:
|
|
117
|
+
"""The `_meta` of the MCP request this code is serving, wherever it runs.
|
|
118
|
+
|
|
119
|
+
Set for the length of each request by `middleware`, which the
|
|
120
|
+
extension's server runs on every message igni sends, so a tool — and
|
|
121
|
+
anything it calls, on the loop or in a worker thread — reads the
|
|
122
|
+
request it serves: concurrent calls each see their own.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def current() -> dict[str, Any] | None:
|
|
127
|
+
"""The current request's `_meta`, or `None` outside a request (or when it had none)."""
|
|
128
|
+
return _META.get()
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
async def middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
|
|
132
|
+
"""An MCP server middleware: the request's `_meta` is current while it is served."""
|
|
133
|
+
token = _META.set(dict(ctx.meta) if ctx.meta else None)
|
|
134
|
+
try:
|
|
135
|
+
return await call_next(ctx)
|
|
136
|
+
finally:
|
|
137
|
+
_META.reset(token)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class CallerScope:
|
|
141
|
+
"""Sets the caller for the call being served, and finds where a function wants it."""
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
@contextlib.contextmanager
|
|
145
|
+
def of(caller: Caller | None) -> Iterator[None]:
|
|
146
|
+
token = _SCOPE.set(caller)
|
|
147
|
+
try:
|
|
148
|
+
yield
|
|
149
|
+
finally:
|
|
150
|
+
_SCOPE.reset(token)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def parameter(fn: Callable[..., Any]) -> str | None:
|
|
154
|
+
"""The name of `fn`'s parameter typed `Caller` (or `Caller | None`), if it has one."""
|
|
155
|
+
try:
|
|
156
|
+
hints = get_type_hints(fn)
|
|
157
|
+
except (NameError, TypeError):
|
|
158
|
+
hints = getattr(fn, "__annotations__", {})
|
|
159
|
+
for name, annotation in hints.items():
|
|
160
|
+
if name != "return" and annotation in (Caller, Caller | None):
|
|
161
|
+
return name
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def optional(fn: Callable[..., Any], name: str) -> bool:
|
|
166
|
+
"""Whether `fn` takes `Caller | None` there: it runs without one, rather than failing."""
|
|
167
|
+
try:
|
|
168
|
+
return bool(get_type_hints(fn).get(name) == Caller | None)
|
|
169
|
+
except (NameError, TypeError):
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def inject(cls, fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
174
|
+
"""`fn` with its `Caller` parameter filled from the call, and hidden from its schema.
|
|
175
|
+
|
|
176
|
+
The parameter is not something igni or a model supplies, so it
|
|
177
|
+
is not part of the tool's input: the wrapper's signature leaves
|
|
178
|
+
it out, and the schema derived from it does too.
|
|
179
|
+
"""
|
|
180
|
+
name = cls.parameter(fn)
|
|
181
|
+
if name is None:
|
|
182
|
+
return fn
|
|
183
|
+
find: Callable[[], Caller | None] = (
|
|
184
|
+
Caller.current if cls.optional(fn, name) else Caller.required
|
|
185
|
+
)
|
|
186
|
+
signature = inspect.signature(fn)
|
|
187
|
+
# Resolved now, against the author's module: the wrapper lives in
|
|
188
|
+
# this one, and a string annotation evaluated here would not find
|
|
189
|
+
# the author's types.
|
|
190
|
+
try:
|
|
191
|
+
resolved = get_type_hints(fn)
|
|
192
|
+
except (NameError, TypeError):
|
|
193
|
+
resolved = dict(getattr(fn, "__annotations__", {}))
|
|
194
|
+
visible = [
|
|
195
|
+
p.replace(annotation=resolved.get(p.name, p.annotation))
|
|
196
|
+
for p in signature.parameters.values()
|
|
197
|
+
if p.name != name
|
|
198
|
+
]
|
|
199
|
+
hints = {k: v for k, v in resolved.items() if k != name}
|
|
200
|
+
return_annotation = resolved.get("return", signature.return_annotation)
|
|
201
|
+
|
|
202
|
+
if inspect.iscoroutinefunction(fn):
|
|
203
|
+
|
|
204
|
+
@wraps(fn)
|
|
205
|
+
async def with_caller_async(*args: Any, **kwargs: Any) -> Any:
|
|
206
|
+
return await fn(*args, **kwargs, **{name: find()})
|
|
207
|
+
|
|
208
|
+
wrapper: Callable[..., Any] = with_caller_async
|
|
209
|
+
else:
|
|
210
|
+
|
|
211
|
+
@wraps(fn)
|
|
212
|
+
def with_caller(*args: Any, **kwargs: Any) -> Any:
|
|
213
|
+
return fn(*args, **kwargs, **{name: find()})
|
|
214
|
+
|
|
215
|
+
wrapper = with_caller
|
|
216
|
+
wrapper.__signature__ = signature.replace( # type: ignore[attr-defined]
|
|
217
|
+
parameters=visible, return_annotation=return_annotation
|
|
218
|
+
)
|
|
219
|
+
wrapper.__annotations__ = hints
|
|
220
|
+
# `wraps` points `__wrapped__` at `fn`, and `inspect.signature`
|
|
221
|
+
# follows it back to the hidden parameter.
|
|
222
|
+
del wrapper.__wrapped__ # type: ignore[attr-defined]
|
|
223
|
+
return wrapper
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
__all__ = ["Caller", "CallerKind", "CallerScope", "NoCaller", "RequestMeta"]
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""The socket to the igni host: how `ext.host` reaches igni.
|
|
2
|
+
|
|
3
|
+
igni starts an extension's process with `IGNI_HOST_URL` and
|
|
4
|
+
`IGNI_HOST_SECRET`: a WebSocket on the host's loopback interface and the
|
|
5
|
+
secret that says which process this is. Every `get`, `set` and `call` on
|
|
6
|
+
`ext.host` is one request on it, and every signal the extension handles
|
|
7
|
+
arrives on it. The protocol is igni's `docs/HOST_MODEL_PROTOCOL.md`; an
|
|
8
|
+
extension author never sees it.
|
|
9
|
+
|
|
10
|
+
Started without those variables — run outside igni, or by an igni older
|
|
11
|
+
than the host model — `ext.host` raises `NotConfigured`, except
|
|
12
|
+
`ext.host.notifications.notify`, which then travels as the MCP
|
|
13
|
+
notification igni has always understood.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import contextlib
|
|
20
|
+
import itertools
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
27
|
+
from websockets.exceptions import WebSocketException
|
|
28
|
+
|
|
29
|
+
from igni_extension.environment import HostEndpoint
|
|
30
|
+
from igni_extension.errors import HostError, HostUnavailable, NotConfigured
|
|
31
|
+
from igni_extension.hooks import HookHandlers
|
|
32
|
+
from igni_extension.notifications import LocalNotification, NotifyNotification
|
|
33
|
+
from igni_extension.service_events import SERVICE_EVENT, ServiceEventHandlers
|
|
34
|
+
from igni_extension.session import HostSession
|
|
35
|
+
from igni_extension.signals import SignalHandlers
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
#: The one member that still works without the socket.
|
|
40
|
+
NOTIFY = "notifications.notify"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
#: Members whose answer is the person's, so no timeout applies to them.
|
|
44
|
+
WAITS_ON_THE_PERSON = frozenset({"ui.show_dialog"})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HostConnection:
|
|
48
|
+
"""One extension process's connection to the igni host."""
|
|
49
|
+
|
|
50
|
+
#: How long a request waits for its answer.
|
|
51
|
+
TIMEOUT = 120.0
|
|
52
|
+
#: Between attempts to reach a host that is not answering yet.
|
|
53
|
+
RETRY_SECONDS = 2.0
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
endpoint: HostEndpoint,
|
|
58
|
+
signals: SignalHandlers,
|
|
59
|
+
session: HostSession | None = None,
|
|
60
|
+
hooks: HookHandlers | None = None,
|
|
61
|
+
service_events: ServiceEventHandlers | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
self._endpoint = endpoint
|
|
64
|
+
self._signals = signals
|
|
65
|
+
self._hooks = hooks if hooks is not None else HookHandlers()
|
|
66
|
+
self._service_events = (
|
|
67
|
+
service_events if service_events is not None else ServiceEventHandlers()
|
|
68
|
+
)
|
|
69
|
+
self._session = session
|
|
70
|
+
self._socket: ClientConnection | None = None
|
|
71
|
+
self._reader: asyncio.Task[None] | None = None
|
|
72
|
+
self._lock = asyncio.Lock()
|
|
73
|
+
self._ids = itertools.count(1)
|
|
74
|
+
self._waiting: dict[int, asyncio.Future[Any]] = {}
|
|
75
|
+
self._hello: asyncio.Future[dict[str, Any]] | None = None
|
|
76
|
+
self._tasks: set[asyncio.Task[Any]] = set()
|
|
77
|
+
#: What the host said this extension holds, after connecting.
|
|
78
|
+
self.granted: list[str] = []
|
|
79
|
+
self.schema_version = ""
|
|
80
|
+
signals.when_new(self._new_signal)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def configured(self) -> bool:
|
|
84
|
+
return self._endpoint.configured
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def connected(self) -> bool:
|
|
88
|
+
return self._socket is not None
|
|
89
|
+
|
|
90
|
+
# ── HostLink ──────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
async def request(
|
|
93
|
+
self,
|
|
94
|
+
op: str,
|
|
95
|
+
path: str,
|
|
96
|
+
*,
|
|
97
|
+
window: str | None = None,
|
|
98
|
+
session: str | None = None,
|
|
99
|
+
params: dict[str, Any] | None = None,
|
|
100
|
+
value: Any = None,
|
|
101
|
+
) -> Any:
|
|
102
|
+
if not self.configured:
|
|
103
|
+
if op == "call" and path == NOTIFY and self._session is not None:
|
|
104
|
+
notification = LocalNotification.model_validate((params or {})["notification"])
|
|
105
|
+
await self._session.send(NotifyNotification.of(notification))
|
|
106
|
+
return None
|
|
107
|
+
raise NotConfigured(
|
|
108
|
+
f"host.{path}: igni did not give this process the host's address "
|
|
109
|
+
"(IGNI_HOST_URL, IGNI_HOST_SECRET) — it is not running inside igni"
|
|
110
|
+
)
|
|
111
|
+
await self.connect()
|
|
112
|
+
body: dict[str, Any] = {"op": op, "path": path}
|
|
113
|
+
if window is not None:
|
|
114
|
+
body["window"] = window
|
|
115
|
+
if session is not None:
|
|
116
|
+
body["session"] = session
|
|
117
|
+
if params:
|
|
118
|
+
body["params"] = params
|
|
119
|
+
if op == "set":
|
|
120
|
+
body["value"] = value
|
|
121
|
+
return await self._send(body)
|
|
122
|
+
|
|
123
|
+
# ── The socket ────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def _headers(self) -> dict[str, str]:
|
|
126
|
+
"""The secret, and — where there is one — this process's group.
|
|
127
|
+
|
|
128
|
+
igni ends the group if the process hangs and it has to restart
|
|
129
|
+
it; it checks that it started the group's leader first.
|
|
130
|
+
"""
|
|
131
|
+
headers = {"Authorization": f"Bearer {self._endpoint.secret}"}
|
|
132
|
+
if hasattr(os, "getpgid"):
|
|
133
|
+
headers["X-Igni-Process-Group"] = str(os.getpgid(0))
|
|
134
|
+
return headers
|
|
135
|
+
|
|
136
|
+
async def connect(self) -> None:
|
|
137
|
+
"""Connect and say which signals are handled. Idempotent."""
|
|
138
|
+
async with self._lock:
|
|
139
|
+
if self._socket is not None:
|
|
140
|
+
return
|
|
141
|
+
try:
|
|
142
|
+
socket = await connect(
|
|
143
|
+
self._endpoint.url,
|
|
144
|
+
additional_headers=self._headers(),
|
|
145
|
+
max_size=8 * 1024 * 1024,
|
|
146
|
+
compression=None,
|
|
147
|
+
)
|
|
148
|
+
except (OSError, WebSocketException) as exc:
|
|
149
|
+
raise HostUnavailable(f"the igni host did not answer: {exc}") from exc
|
|
150
|
+
self._socket = socket
|
|
151
|
+
loop = asyncio.get_running_loop()
|
|
152
|
+
self._hello = loop.create_future()
|
|
153
|
+
self._reader = asyncio.create_task(self._read(socket))
|
|
154
|
+
try:
|
|
155
|
+
hello = await asyncio.wait_for(asyncio.shield(self._hello), self.TIMEOUT)
|
|
156
|
+
except TimeoutError as exc:
|
|
157
|
+
await self._drop()
|
|
158
|
+
raise HostUnavailable("the igni host did not say hello") from exc
|
|
159
|
+
self.granted = list(hello.get("granted") or [])
|
|
160
|
+
self.schema_version = str(hello.get("schema_version") or "")
|
|
161
|
+
wires = self._signals.wires()
|
|
162
|
+
if wires:
|
|
163
|
+
await self._subscribe(wires)
|
|
164
|
+
|
|
165
|
+
async def serve_forever(self) -> None:
|
|
166
|
+
"""Stay connected while the process runs, so signals keep arriving."""
|
|
167
|
+
if not self.configured:
|
|
168
|
+
return
|
|
169
|
+
while True:
|
|
170
|
+
try:
|
|
171
|
+
await self.connect()
|
|
172
|
+
except HostUnavailable as exc:
|
|
173
|
+
logger.debug("igni: %s; trying again", exc)
|
|
174
|
+
await asyncio.sleep(self.RETRY_SECONDS)
|
|
175
|
+
continue
|
|
176
|
+
reader = self._reader
|
|
177
|
+
if reader is not None:
|
|
178
|
+
with contextlib.suppress(Exception):
|
|
179
|
+
await asyncio.shield(reader)
|
|
180
|
+
await asyncio.sleep(self.RETRY_SECONDS)
|
|
181
|
+
|
|
182
|
+
async def close(self) -> None:
|
|
183
|
+
await self._drop()
|
|
184
|
+
for task in list(self._tasks):
|
|
185
|
+
task.cancel()
|
|
186
|
+
|
|
187
|
+
async def _send(self, body: dict[str, Any]) -> Any:
|
|
188
|
+
socket = self._socket
|
|
189
|
+
if socket is None:
|
|
190
|
+
raise HostUnavailable("not connected to the igni host")
|
|
191
|
+
request_id = next(self._ids)
|
|
192
|
+
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
|
|
193
|
+
self._waiting[request_id] = future
|
|
194
|
+
try:
|
|
195
|
+
await socket.send(json.dumps({"id": request_id, **body}))
|
|
196
|
+
# A dialog waits on the person, for as long as they take.
|
|
197
|
+
limit = None if body.get("path") in WAITS_ON_THE_PERSON else self.TIMEOUT
|
|
198
|
+
reply = await asyncio.wait_for(future, limit)
|
|
199
|
+
except (OSError, WebSocketException) as exc:
|
|
200
|
+
raise HostUnavailable(f"the igni host went away: {exc}") from exc
|
|
201
|
+
except TimeoutError as exc:
|
|
202
|
+
raise HostUnavailable(f"host.{body.get('path')} got no answer") from exc
|
|
203
|
+
finally:
|
|
204
|
+
self._waiting.pop(request_id, None)
|
|
205
|
+
if reply.get("ok"):
|
|
206
|
+
return reply.get("result")
|
|
207
|
+
error = reply.get("error") or {}
|
|
208
|
+
raise HostError(str(error.get("code") or "failed"), str(error.get("message") or "refused"))
|
|
209
|
+
|
|
210
|
+
async def _subscribe(self, wires: list[str]) -> None:
|
|
211
|
+
outcome = await self._send({"op": "subscribe", "signals": wires})
|
|
212
|
+
if isinstance(outcome, dict):
|
|
213
|
+
for wire in outcome.get("not_granted") or []:
|
|
214
|
+
logger.warning(
|
|
215
|
+
"igni: %s will never arrive — its object is not in permissions.host", wire
|
|
216
|
+
)
|
|
217
|
+
for wire in outcome.get("unknown") or []:
|
|
218
|
+
logger.warning("igni: this igni has no signal %s", wire)
|
|
219
|
+
|
|
220
|
+
def _new_signal(self, wire: str) -> None:
|
|
221
|
+
if self._socket is None:
|
|
222
|
+
return # sent with the rest on connect
|
|
223
|
+
self._spawn(self._subscribe([wire]))
|
|
224
|
+
|
|
225
|
+
async def _read(self, socket: ClientConnection) -> None:
|
|
226
|
+
try:
|
|
227
|
+
async for raw in socket:
|
|
228
|
+
try:
|
|
229
|
+
message = json.loads(raw)
|
|
230
|
+
except ValueError:
|
|
231
|
+
continue
|
|
232
|
+
op = message.get("op")
|
|
233
|
+
if op == "hello" and self._hello is not None and not self._hello.done():
|
|
234
|
+
self._hello.set_result(message)
|
|
235
|
+
elif op == "reply":
|
|
236
|
+
waiter = self._waiting.get(message.get("id"))
|
|
237
|
+
if waiter is not None and not waiter.done():
|
|
238
|
+
waiter.set_result(message)
|
|
239
|
+
elif op == "signal":
|
|
240
|
+
# A task, not an await: a handler that reads the model
|
|
241
|
+
# needs this loop free to receive the answer.
|
|
242
|
+
self._spawn(
|
|
243
|
+
self._signals.deliver(str(message.get("signal")), message.get("value"))
|
|
244
|
+
)
|
|
245
|
+
elif op == "hook":
|
|
246
|
+
# Off the read loop, as a signal is handled; igni
|
|
247
|
+
# waits on a deciding one, against a deadline.
|
|
248
|
+
hook_id = message.get("id")
|
|
249
|
+
name = str(message.get("event"))
|
|
250
|
+
principal = message.get("principal")
|
|
251
|
+
if hook_id is None:
|
|
252
|
+
self._spawn(self._hooks.tell(name, message.get("value"), principal))
|
|
253
|
+
else:
|
|
254
|
+
self._spawn(
|
|
255
|
+
self._answer(str(hook_id), name, message.get("value"), principal)
|
|
256
|
+
)
|
|
257
|
+
except WebSocketException:
|
|
258
|
+
pass
|
|
259
|
+
finally:
|
|
260
|
+
if self._socket is socket:
|
|
261
|
+
self._socket = None
|
|
262
|
+
for waiter in self._waiting.values():
|
|
263
|
+
if not waiter.done():
|
|
264
|
+
waiter.set_exception(HostUnavailable("the igni host went away"))
|
|
265
|
+
|
|
266
|
+
async def _answer(self, hook_id: str, event: str, value: Any, principal: Any = None) -> None:
|
|
267
|
+
if event == SERVICE_EVENT:
|
|
268
|
+
params = await self._service_events.answer(hook_id, value, principal)
|
|
269
|
+
else:
|
|
270
|
+
params = await self._hooks.answer(hook_id, event, value, principal)
|
|
271
|
+
try:
|
|
272
|
+
await self._send({"op": "answer", "params": params})
|
|
273
|
+
except (HostError, HostUnavailable) as exc:
|
|
274
|
+
# Too late, or the host went away: the event went ahead or not
|
|
275
|
+
# without this answer, as igni decides for a silence.
|
|
276
|
+
logger.warning("igni: the answer to %s did not count: %s", event, exc)
|
|
277
|
+
|
|
278
|
+
async def _drop(self) -> None:
|
|
279
|
+
socket, self._socket = self._socket, None
|
|
280
|
+
if socket is not None:
|
|
281
|
+
with contextlib.suppress(Exception):
|
|
282
|
+
await socket.close()
|
|
283
|
+
if self._reader is not None:
|
|
284
|
+
self._reader.cancel()
|
|
285
|
+
with contextlib.suppress(BaseException):
|
|
286
|
+
await self._reader
|
|
287
|
+
self._reader = None
|
|
288
|
+
|
|
289
|
+
def _spawn(self, coro: Any) -> None:
|
|
290
|
+
task = asyncio.create_task(coro)
|
|
291
|
+
self._tasks.add(task)
|
|
292
|
+
task.add_done_callback(self._tasks.discard)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
__all__ = ["HostConnection"]
|
igni_extension/clock.py
ADDED