arkitekt-lsp 1.0.0__tar.gz
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.
- arkitekt_lsp-1.0.0/.gitignore +7 -0
- arkitekt_lsp-1.0.0/PKG-INFO +68 -0
- arkitekt_lsp-1.0.0/README.md +53 -0
- arkitekt_lsp-1.0.0/arkitekt_lsp/__init__.py +4 -0
- arkitekt_lsp-1.0.0/arkitekt_lsp/__main__.py +5 -0
- arkitekt_lsp-1.0.0/arkitekt_lsp/connected.py +308 -0
- arkitekt_lsp-1.0.0/arkitekt_lsp/live.py +100 -0
- arkitekt_lsp-1.0.0/arkitekt_lsp/placement.py +162 -0
- arkitekt_lsp-1.0.0/arkitekt_lsp/regions.py +212 -0
- arkitekt_lsp-1.0.0/arkitekt_lsp/rekuest_read.py +74 -0
- arkitekt_lsp-1.0.0/arkitekt_lsp/scopes.py +83 -0
- arkitekt_lsp-1.0.0/arkitekt_lsp/server.py +712 -0
- arkitekt_lsp-1.0.0/pyproject.toml +107 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: arkitekt-lsp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: the Arkitekt language server: your app's own `arkitekt check`, underlined in the BSX of every blok while you write it
|
|
5
|
+
Author-email: jhnnsrs <jhnnsrs@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: arkitekt-spec>=6.0
|
|
9
|
+
Requires-Dist: arkitekt>=11.2
|
|
10
|
+
Requires-Dist: fakts>=6.1
|
|
11
|
+
Requires-Dist: koil>=3.3.6
|
|
12
|
+
Requires-Dist: pygls>=2.1
|
|
13
|
+
Requires-Dist: rath>=4
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# arkitekt-lsp
|
|
17
|
+
|
|
18
|
+
The Arkitekt language server. It shows what `arkitekt check` finds in your app, underlined in
|
|
19
|
+
the BSX of the blok it is about, in any editor that speaks LSP.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv add --dev arkitekt-lsp # in the app's project: the server imports the app with its environment
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The editor starts `arkitekt-lsp` (stdio) from that environment. Then:
|
|
26
|
+
|
|
27
|
+
- **on save** of a Python file it runs `arkitekt check --json-out` for the app the file belongs
|
|
28
|
+
to, in a child process, and publishes every finding at its blok's string literal: the node's
|
|
29
|
+
tag, or the line and column of a parse error. Findings follow their blok as the code moves;
|
|
30
|
+
a blok edited since the check drops them until the next save, and is checked as you type;
|
|
31
|
+
- **as you type** what its BSX says, against what the last check said the blok declares: its
|
|
32
|
+
`deps.*`, `args.*` and locals. A blok no check has seen yet is judged by the language alone, and
|
|
33
|
+
a query's value always waits for the next save.
|
|
34
|
+
|
|
35
|
+
All of this is local: the server talks to nothing.
|
|
36
|
+
|
|
37
|
+
## Who would satisfy your dependencies (`--connect`)
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
arkitekt-lsp --connect # or the setting arkitekt.server.connect
|
|
41
|
+
arkitekt-lsp --server https://my.hub # or arkitekt.server.url; the default is https://go.arkitekt.live
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A flag overrides what the editor configures.
|
|
45
|
+
|
|
46
|
+
With the flag, the server also logs in, as an app of its own, with a token that can only read
|
|
47
|
+
(`rekuest_read`). It registers no agent. For each dependency protocol your app declares it asks who
|
|
48
|
+
would satisfy it, nothing registered, so a protocol you just wrote is asked about on save:
|
|
49
|
+
|
|
50
|
+
- above the protocol, a count: `3 agents · 2 connected`, and the slots nobody offers;
|
|
51
|
+
- a warning (information, for an optional one) when too few do. Never an error: what is missing
|
|
52
|
+
may simply not be running yet;
|
|
53
|
+
- on hover of the protocol, or of `deps.<key>` in a blok, the agents and the count per slot.
|
|
54
|
+
|
|
55
|
+
A login nobody approves or a server that is gone leaves the server local, and says so.
|
|
56
|
+
|
|
57
|
+
Which app: the saved file when it declares one (`App(`), else the `arkitekt.target` setting, else
|
|
58
|
+
the project's default (`ARKITEKT_APP`, or `app.py`). A project is the nearest directory with a
|
|
59
|
+
`pyproject.toml`.
|
|
60
|
+
|
|
61
|
+
Settings (initialization options, or `workspace/didChangeConfiguration` under `arkitekt`):
|
|
62
|
+
`check.onSave` (default true), `check.timeout` (seconds, default 120), `target`.
|
|
63
|
+
|
|
64
|
+
Besides the standard protocol it answers `bsx/regions` (`{textDocument: {uri}}` →
|
|
65
|
+
`{regions: [{start, end, value, root, context, kind, exact}]}`), the blok literals of a document,
|
|
66
|
+
which the VS Code extension uses to complete BSX inside Python strings. It notifies
|
|
67
|
+
`arkitekt/status` (`{state: checking|ok|failed, app, errors?, warnings?, message?}`) and takes the
|
|
68
|
+
notification `arkitekt/check` (`{textDocument: {uri}}`) to check now.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# arkitekt-lsp
|
|
2
|
+
|
|
3
|
+
The Arkitekt language server. It shows what `arkitekt check` finds in your app, underlined in
|
|
4
|
+
the BSX of the blok it is about, in any editor that speaks LSP.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
uv add --dev arkitekt-lsp # in the app's project: the server imports the app with its environment
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The editor starts `arkitekt-lsp` (stdio) from that environment. Then:
|
|
11
|
+
|
|
12
|
+
- **on save** of a Python file it runs `arkitekt check --json-out` for the app the file belongs
|
|
13
|
+
to, in a child process, and publishes every finding at its blok's string literal: the node's
|
|
14
|
+
tag, or the line and column of a parse error. Findings follow their blok as the code moves;
|
|
15
|
+
a blok edited since the check drops them until the next save, and is checked as you type;
|
|
16
|
+
- **as you type** what its BSX says, against what the last check said the blok declares: its
|
|
17
|
+
`deps.*`, `args.*` and locals. A blok no check has seen yet is judged by the language alone, and
|
|
18
|
+
a query's value always waits for the next save.
|
|
19
|
+
|
|
20
|
+
All of this is local: the server talks to nothing.
|
|
21
|
+
|
|
22
|
+
## Who would satisfy your dependencies (`--connect`)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
arkitekt-lsp --connect # or the setting arkitekt.server.connect
|
|
26
|
+
arkitekt-lsp --server https://my.hub # or arkitekt.server.url; the default is https://go.arkitekt.live
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
A flag overrides what the editor configures.
|
|
30
|
+
|
|
31
|
+
With the flag, the server also logs in, as an app of its own, with a token that can only read
|
|
32
|
+
(`rekuest_read`). It registers no agent. For each dependency protocol your app declares it asks who
|
|
33
|
+
would satisfy it, nothing registered, so a protocol you just wrote is asked about on save:
|
|
34
|
+
|
|
35
|
+
- above the protocol, a count: `3 agents · 2 connected`, and the slots nobody offers;
|
|
36
|
+
- a warning (information, for an optional one) when too few do. Never an error: what is missing
|
|
37
|
+
may simply not be running yet;
|
|
38
|
+
- on hover of the protocol, or of `deps.<key>` in a blok, the agents and the count per slot.
|
|
39
|
+
|
|
40
|
+
A login nobody approves or a server that is gone leaves the server local, and says so.
|
|
41
|
+
|
|
42
|
+
Which app: the saved file when it declares one (`App(`), else the `arkitekt.target` setting, else
|
|
43
|
+
the project's default (`ARKITEKT_APP`, or `app.py`). A project is the nearest directory with a
|
|
44
|
+
`pyproject.toml`.
|
|
45
|
+
|
|
46
|
+
Settings (initialization options, or `workspace/didChangeConfiguration` under `arkitekt`):
|
|
47
|
+
`check.onSave` (default true), `check.timeout` (seconds, default 120), `target`.
|
|
48
|
+
|
|
49
|
+
Besides the standard protocol it answers `bsx/regions` (`{textDocument: {uri}}` →
|
|
50
|
+
`{regions: [{start, end, value, root, context, kind, exact}]}`), the blok literals of a document,
|
|
51
|
+
which the VS Code extension uses to complete BSX inside Python strings. It notifies
|
|
52
|
+
`arkitekt/status` (`{state: checking|ok|failed, app, errors?, warnings?, message?}`) and takes the
|
|
53
|
+
notification `arkitekt/check` (`{textDocument: {uri}}`) to check now.
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""The flagged part of the server: who would satisfy a dependency, asked of rekuest, read-only.
|
|
2
|
+
|
|
3
|
+
Everything else the server does is local. Only with ``--connect`` (or ``arkitekt.server.connect``)
|
|
4
|
+
does it log in, at ``https://go.arkitekt.live`` unless told otherwise (``--server``), as an app of
|
|
5
|
+
its own: not the app being checked, and with a client that asks for ``rekuest_read`` and nothing
|
|
6
|
+
else. It registers no agent. A dependency needs nothing registered either: what the check says the
|
|
7
|
+
app declares (the report's ``protocols``, a blok's ``scope``) is a set of demands, and
|
|
8
|
+
``agents(filters: {actionDemands, stateDemands})`` answers them. An agent has to meet every demand,
|
|
9
|
+
each possibly with a different implementation, which is what a dependency asks for.
|
|
10
|
+
|
|
11
|
+
Whatever goes wrong here (a login nobody approves, a server that is gone) leaves the server local.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
from arkitekt_spec.actions import ActionDemandInput, StateDemandInput
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from arkitekt import DeviceCodeChallenge
|
|
27
|
+
|
|
28
|
+
from arkitekt_lsp.rekuest_read import RekuestRead
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
DEFAULT_URL = "https://go.arkitekt.live"
|
|
33
|
+
"""The coordination server asked when none is named."""
|
|
34
|
+
LOGIN_TIMEOUT = 300.0
|
|
35
|
+
QUERY_TIMEOUT = 10.0
|
|
36
|
+
FRESH_FOR = 30.0
|
|
37
|
+
"""How long an answer about a dependency holds; a manual refresh drops it."""
|
|
38
|
+
AGENT_LIMIT = 100
|
|
39
|
+
"""How many agents are listed per dependency: a count of this many reads as "at least"."""
|
|
40
|
+
|
|
41
|
+
# -- what to ask
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _demand(demand: Any, model: Any) -> dict[str, Any]: # noqa: ANN401
|
|
45
|
+
"""A report's demand (snake_case) as the server's input spells it."""
|
|
46
|
+
return model.model_validate(demand).model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _identity(dependency: Mapping[str, Any]) -> dict[str, Any]:
|
|
50
|
+
filters: dict[str, Any] = {"distinct": True}
|
|
51
|
+
if dependency.get("app"):
|
|
52
|
+
filters["appIdentifier"] = dependency["app"]
|
|
53
|
+
# As an assign resolves it: no version, an empty one or `*` is any version, else exactly that.
|
|
54
|
+
if dependency.get("version") not in (None, "", "*"):
|
|
55
|
+
filters["versionNumber"] = dependency["version"]
|
|
56
|
+
return filters
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _slots(dependency: Mapping[str, Any]) -> list[tuple[str, str, bool, dict[str, Any]]]:
|
|
60
|
+
"""Each slot with a demand: its key, what it is (``actionDemands``/``stateDemands``), whether it
|
|
61
|
+
is optional, and its demand. A slot without a demand asks nothing (and the server refuses one)."""
|
|
62
|
+
slots: list[tuple[str, str, bool, dict[str, Any]]] = []
|
|
63
|
+
for slot in dependency.get("action_dependencies") or ():
|
|
64
|
+
if slot.get("demand"):
|
|
65
|
+
demand = _demand(slot["demand"], ActionDemandInput)
|
|
66
|
+
slots.append((slot["key"], "actionDemands", bool(slot.get("optional")), demand))
|
|
67
|
+
for slot in dependency.get("state_dependencies") or ():
|
|
68
|
+
if slot.get("demand"):
|
|
69
|
+
demand = _demand(slot["demand"], StateDemandInput)
|
|
70
|
+
slots.append((slot["key"], "stateDemands", bool(slot.get("optional")), demand))
|
|
71
|
+
return slots
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def filters_for(dependency: Mapping[str, Any]) -> dict[str, Any]:
|
|
75
|
+
"""The ``AgentFilter`` of the agents that satisfy ``dependency``: every slot it requires."""
|
|
76
|
+
filters = _identity(dependency)
|
|
77
|
+
for _, field, optional, demand in _slots(dependency):
|
|
78
|
+
if not optional:
|
|
79
|
+
filters.setdefault(field, []).append(demand)
|
|
80
|
+
return filters
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def slot_filters(dependency: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
|
|
84
|
+
"""Per slot, the ``AgentFilter`` of the agents that meet that slot alone: what says which is unmet."""
|
|
85
|
+
return {
|
|
86
|
+
key: {**_identity(dependency), field: [demand]}
|
|
87
|
+
for key, field, _, demand in _slots(dependency)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# -- what it answered
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class AgentInfo:
|
|
96
|
+
id: str
|
|
97
|
+
name: str
|
|
98
|
+
connected: bool
|
|
99
|
+
app: str | None = None
|
|
100
|
+
version: str | None = None
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def of(cls, agent: Mapping[str, Any]) -> "AgentInfo":
|
|
104
|
+
return cls(
|
|
105
|
+
id=str(agent["id"]),
|
|
106
|
+
name=str(agent["name"]),
|
|
107
|
+
connected=bool(agent["connected"]),
|
|
108
|
+
app=(agent.get("app") or {}).get("identifier"),
|
|
109
|
+
version=(agent.get("release") or {}).get("version"),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class Availability:
|
|
115
|
+
"""Who would satisfy a dependency, as the server said it just now."""
|
|
116
|
+
|
|
117
|
+
agents: tuple[AgentInfo, ...]
|
|
118
|
+
slots: Mapping[str, int]
|
|
119
|
+
"""Per slot with a demand, how many agents meet it alone."""
|
|
120
|
+
optional_slots: frozenset[str] = frozenset()
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def connected(self) -> int:
|
|
124
|
+
return sum(1 for agent in self.agents if agent.connected)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _count(count: int) -> str:
|
|
128
|
+
return f"{count}+" if count >= AGENT_LIMIT else str(count)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def lens_title(availability: Availability) -> str:
|
|
132
|
+
"""One line above the protocol: ``3 agents · 2 connected``, and the slots nobody meets."""
|
|
133
|
+
total = len(availability.agents)
|
|
134
|
+
if total == 0:
|
|
135
|
+
title = "no agents"
|
|
136
|
+
else:
|
|
137
|
+
title = f"{_count(total)} agent{'' if total == 1 else 's'} · {_count(availability.connected)} connected"
|
|
138
|
+
unmet = [key for key, count in availability.slots.items() if count == 0 and key not in availability.optional_slots]
|
|
139
|
+
if unmet:
|
|
140
|
+
title += " · nobody offers " + ", ".join(unmet)
|
|
141
|
+
return title
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def problem(dependency: Mapping[str, Any], availability: Availability) -> tuple[str, str] | None:
|
|
145
|
+
"""The level and message when too few agents satisfy ``dependency``; never an error, because
|
|
146
|
+
what is missing may simply not be running yet."""
|
|
147
|
+
needed = dependency.get("min_viable_instances") or 1
|
|
148
|
+
total = len(availability.agents)
|
|
149
|
+
if total >= needed:
|
|
150
|
+
return None
|
|
151
|
+
unmet = [key for key, count in availability.slots.items() if count == 0 and key not in availability.optional_slots]
|
|
152
|
+
where = f" Nobody offers: {', '.join(unmet)}." if unmet else ""
|
|
153
|
+
level = "info" if dependency.get("optional") else "warning"
|
|
154
|
+
if total == 0:
|
|
155
|
+
return level, f"No agent satisfies this dependency.{where}"
|
|
156
|
+
return level, f"{total} agent{'' if total == 1 else 's'} satisfy this dependency, it asks for at least {needed}.{where}"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def hover_markdown(name: str, availability: Availability) -> str:
|
|
160
|
+
"""What hovering a dependency tells: the agents, then each slot."""
|
|
161
|
+
lines = [f"**{name}**: {lens_title(availability)}", ""]
|
|
162
|
+
for agent in availability.agents[:10]:
|
|
163
|
+
at = f" ({agent.app}{f'@{agent.version}' if agent.version else ''})" if agent.app else ""
|
|
164
|
+
lines.append(f"- {'●' if agent.connected else '○'} `{agent.name}`{at}")
|
|
165
|
+
if len(availability.agents) > 10:
|
|
166
|
+
lines.append(f"- … {_count(len(availability.agents))} in all")
|
|
167
|
+
if availability.slots:
|
|
168
|
+
lines.extend(("", "| slot | agents |", "|---|---|"))
|
|
169
|
+
lines.extend(
|
|
170
|
+
f"| `{key}`{' (optional)' if key in availability.optional_slots else ''} | {_count(count)} |"
|
|
171
|
+
for key, count in availability.slots.items()
|
|
172
|
+
)
|
|
173
|
+
return "\n".join(lines)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def class_span(source: str, name: str) -> tuple[int, int] | None:
|
|
177
|
+
"""Where ``class <name>`` names it in ``source`` as it reads now (the report's line may have moved)."""
|
|
178
|
+
match = re.search(rf"^[ \t]*class[ \t]+({re.escape(name.rsplit('.', 1)[-1])})\b", source, re.MULTILINE)
|
|
179
|
+
return match.span(1) if match else None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def dependency_key_at(text: str, index: int) -> str | None:
|
|
183
|
+
"""The ``<key>`` of the ``deps.<key>`` at ``index`` of a blok's BSX, if the cursor is on one."""
|
|
184
|
+
for match in re.finditer(r"\bdeps\.([A-Za-z_]\w*)", text):
|
|
185
|
+
if match.start() <= index <= match.end():
|
|
186
|
+
return match.group(1)
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# -- the connection
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
Status = Callable[..., None]
|
|
194
|
+
Login = Callable[["DeviceCodeChallenge"], Awaitable[None]]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class Connection:
|
|
198
|
+
"""One read-only login at ``url``, and what it has answered lately.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
url: The coordination server.
|
|
202
|
+
status: Told every change: ``status(state, message=...)``, a state of ``connecting``,
|
|
203
|
+
``login``, ``connected`` or ``offline``.
|
|
204
|
+
login: Shows the user a device code challenge.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def __init__(self, url: str, status: Status, login: Login, version: str = "0.0.0") -> None:
|
|
208
|
+
self.url = url
|
|
209
|
+
self.status = status
|
|
210
|
+
self.login = login
|
|
211
|
+
self.version = version
|
|
212
|
+
self.runtime: Any = None
|
|
213
|
+
self.client: RekuestRead | None = None
|
|
214
|
+
self.answers: dict[str, tuple[float, Availability]] = {}
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def connected(self) -> bool:
|
|
218
|
+
return self.client is not None
|
|
219
|
+
|
|
220
|
+
async def _show_login(self, challenge: "DeviceCodeChallenge") -> None:
|
|
221
|
+
self.status("login", message=f"Approve this editor, code {challenge.user_code}")
|
|
222
|
+
await self.login(challenge)
|
|
223
|
+
|
|
224
|
+
async def start(self) -> None:
|
|
225
|
+
"""Log in and connect; on any failure, stay local and say why."""
|
|
226
|
+
self.status("connecting")
|
|
227
|
+
try:
|
|
228
|
+
# Only here: a server that is not asked to connect never loads any of this.
|
|
229
|
+
from arkitekt import connect
|
|
230
|
+
|
|
231
|
+
from arkitekt_lsp.rekuest_read import RekuestRead, lsp_app
|
|
232
|
+
|
|
233
|
+
runtime = connect(
|
|
234
|
+
lsp_app(self.version), url=self.url, headless=True, device_code_hook=self._show_login, mesh=False
|
|
235
|
+
)
|
|
236
|
+
await asyncio.wait_for(runtime.__aenter__(), LOGIN_TIMEOUT)
|
|
237
|
+
except Exception as error: # noqa: BLE001 - whatever it was, the server stays local
|
|
238
|
+
logger.warning("Could not connect to %s: %s", self.url, error)
|
|
239
|
+
self.status("offline", message=f"{type(error).__name__}: {error}" if str(error) else type(error).__name__)
|
|
240
|
+
return
|
|
241
|
+
self.runtime = runtime
|
|
242
|
+
self.client = runtime.require(RekuestRead)
|
|
243
|
+
self.status("connected")
|
|
244
|
+
|
|
245
|
+
async def stop(self) -> None:
|
|
246
|
+
runtime, self.runtime, self.client = self.runtime, None, None
|
|
247
|
+
self.answers.clear()
|
|
248
|
+
if runtime is not None:
|
|
249
|
+
try:
|
|
250
|
+
await runtime.__aexit__(None, None, None)
|
|
251
|
+
except Exception: # noqa: BLE001 - leaving is best effort
|
|
252
|
+
logger.exception("Leaving the connection failed")
|
|
253
|
+
|
|
254
|
+
def forget(self) -> None:
|
|
255
|
+
"""Drop what it knows, so the next question reaches the server."""
|
|
256
|
+
self.answers.clear()
|
|
257
|
+
|
|
258
|
+
async def availability(self, dependency: Mapping[str, Any]) -> Availability | None:
|
|
259
|
+
"""Who would satisfy ``dependency`` now; None while there is no answer (offline, too slow)."""
|
|
260
|
+
client = self.client
|
|
261
|
+
if client is None:
|
|
262
|
+
return None
|
|
263
|
+
key = json.dumps(dependency, sort_keys=True, default=str)
|
|
264
|
+
known = self.answers.get(key)
|
|
265
|
+
if known is not None and time.monotonic() - known[0] < FRESH_FOR:
|
|
266
|
+
return known[1]
|
|
267
|
+
slots = slot_filters(dependency)
|
|
268
|
+
try:
|
|
269
|
+
agents, *counts = await asyncio.wait_for(
|
|
270
|
+
asyncio.gather(
|
|
271
|
+
client.aagents(filters_for(dependency), AGENT_LIMIT),
|
|
272
|
+
*(client.aagents(filters, AGENT_LIMIT) for filters in slots.values()),
|
|
273
|
+
),
|
|
274
|
+
QUERY_TIMEOUT,
|
|
275
|
+
)
|
|
276
|
+
except Exception as error: # noqa: BLE001 - no answer is an answer: nothing to say
|
|
277
|
+
logger.warning("Asking %s about a dependency failed: %s", self.url, error)
|
|
278
|
+
self.status("offline", message=f"{type(error).__name__}: {error}" if str(error) else type(error).__name__)
|
|
279
|
+
return None
|
|
280
|
+
self.status("connected")
|
|
281
|
+
answer = Availability(
|
|
282
|
+
agents=tuple(AgentInfo.of(agent) for agent in agents),
|
|
283
|
+
slots={slot: len(found) for slot, found in zip(slots, counts)},
|
|
284
|
+
optional_slots=frozenset(slot for slot, _, optional, _ in _slots(dependency) if optional),
|
|
285
|
+
)
|
|
286
|
+
self.answers[key] = (time.monotonic(), answer)
|
|
287
|
+
return answer
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def declared_protocols(report: Mapping[str, Any]) -> Sequence[Mapping[str, Any]]:
|
|
291
|
+
"""The report's dependency protocols; a version 1 report has none."""
|
|
292
|
+
return report.get("protocols") or ()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
__all__ = [
|
|
296
|
+
"DEFAULT_URL",
|
|
297
|
+
"AgentInfo",
|
|
298
|
+
"Availability",
|
|
299
|
+
"Connection",
|
|
300
|
+
"class_span",
|
|
301
|
+
"declared_protocols",
|
|
302
|
+
"dependency_key_at",
|
|
303
|
+
"filters_for",
|
|
304
|
+
"hover_markdown",
|
|
305
|
+
"lens_title",
|
|
306
|
+
"problem",
|
|
307
|
+
"slot_filters",
|
|
308
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""What a blok's BSX says about itself while it is typed, before any check has seen it.
|
|
2
|
+
|
|
3
|
+
``arkitekt check`` imports the app, so it runs on save. Between saves an edited blok is checked
|
|
4
|
+
here, in process, with the validator ``arkitekt check`` runs (:func:`check_blok_tree`): the
|
|
5
|
+
language and the catalogs, and what the last check said the blok declares (its scope, see
|
|
6
|
+
:mod:`.scopes`). A blok no check has seen has an empty scope, so beyond what the parser refuses
|
|
7
|
+
only the findings that do not depend on what the blok declares are told; a reference to a
|
|
8
|
+
dependency, a local or a query waits for the check.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from arkitekt_spec.blok.bsx import parse_bsx_with_spans
|
|
16
|
+
from arkitekt_spec.blok.catalogs import default_view
|
|
17
|
+
from arkitekt_spec.blok.vocabulary import BlokError
|
|
18
|
+
from arkitekt_spec.declare.blok.registry import catalogs_of
|
|
19
|
+
from arkitekt_spec.declare.blok.validate import BlokScope, check_blok_tree, place
|
|
20
|
+
|
|
21
|
+
from arkitekt_lsp.placement import Span, place_in_literal
|
|
22
|
+
from arkitekt_lsp.regions import BlokLiteral
|
|
23
|
+
from arkitekt_lsp.scopes import to_blok_scope
|
|
24
|
+
|
|
25
|
+
SCOPE_FREE = frozenset(
|
|
26
|
+
{"bsx", "expr", "callback", "palette", "props", "foreach", "context", "logo", "catalog", "toast"}
|
|
27
|
+
)
|
|
28
|
+
"""The diagnostic families (a code's first segment) an empty scope cannot get wrong."""
|
|
29
|
+
UNSCOPED = frozenset({"query"})
|
|
30
|
+
"""The families a check's scope still cannot judge: a query's value is typed by its service's
|
|
31
|
+
schema, which the report does not carry."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class LiveFinding:
|
|
36
|
+
"""A finding of one blok literal, placed in the Python source."""
|
|
37
|
+
|
|
38
|
+
span: Span
|
|
39
|
+
level: str
|
|
40
|
+
code: str
|
|
41
|
+
message: str
|
|
42
|
+
hint: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _scope_free(code: str) -> bool:
|
|
46
|
+
return code.split(".", 1)[0] in SCOPE_FREE
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _judged_by_scope(code: str) -> bool:
|
|
50
|
+
return code.split(".", 1)[0] not in UNSCOPED
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def live_findings(literal: BlokLiteral, scope: Mapping[str, Any] | None = None) -> list[LiveFinding]:
|
|
54
|
+
"""What the language and the default catalogs find in ``literal``, without the app.
|
|
55
|
+
|
|
56
|
+
With ``scope`` (what the last check said this blok declares) a reference is judged too.
|
|
57
|
+
"""
|
|
58
|
+
view = default_view()
|
|
59
|
+
try:
|
|
60
|
+
tree, spans = parse_bsx_with_spans(literal.value, view=view)
|
|
61
|
+
except BlokError as error:
|
|
62
|
+
# The parser knows no scope: whatever it refuses is the text's own.
|
|
63
|
+
where = {"line": error.line, "column": error.column, "node": error.node}
|
|
64
|
+
return [
|
|
65
|
+
LiveFinding(
|
|
66
|
+
span=place_in_literal(literal, {key: value for key, value in where.items() if value is not None}),
|
|
67
|
+
level="error",
|
|
68
|
+
code=error.code,
|
|
69
|
+
message=error.message,
|
|
70
|
+
hint=error.hint,
|
|
71
|
+
)
|
|
72
|
+
]
|
|
73
|
+
# Every finding, errors too: one the empty scope gets wrong must not hide the rest.
|
|
74
|
+
# The catalogs it draws from, as the app's own check takes them when none are declared.
|
|
75
|
+
if scope is not None:
|
|
76
|
+
checked = to_blok_scope(scope)
|
|
77
|
+
checked = replace(checked, catalogs=checked.catalogs or catalogs_of(tree, view))
|
|
78
|
+
told = _judged_by_scope
|
|
79
|
+
else:
|
|
80
|
+
checked = BlokScope(kind=literal.kind, context=literal.context, catalogs=catalogs_of(tree, view))
|
|
81
|
+
told = _scope_free
|
|
82
|
+
findings = [place(finding, spans) for finding in check_blok_tree(tree, checked, view)]
|
|
83
|
+
placed: list[LiveFinding] = []
|
|
84
|
+
for finding in findings:
|
|
85
|
+
if not told(finding.code):
|
|
86
|
+
continue
|
|
87
|
+
where = {"line": finding.line, "column": finding.column, "node": finding.path}
|
|
88
|
+
placed.append(
|
|
89
|
+
LiveFinding(
|
|
90
|
+
span=place_in_literal(literal, {key: value for key, value in where.items() if value is not None}),
|
|
91
|
+
level=finding.level,
|
|
92
|
+
code=finding.code,
|
|
93
|
+
message=finding.message,
|
|
94
|
+
hint=finding.hint,
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return placed
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = ["SCOPE_FREE", "UNSCOPED", "LiveFinding", "live_findings"]
|