pyweb-stack 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.
- pyweb/__init__.py +71 -0
- pyweb/app.py +37 -0
- pyweb/app_loader.py +139 -0
- pyweb/asgi.py +71 -0
- pyweb/auth.py +701 -0
- pyweb/bench.py +101 -0
- pyweb/browser.py +232 -0
- pyweb/build.py +295 -0
- pyweb/cache.py +201 -0
- pyweb/cli/__init__.py +465 -0
- pyweb/cli/__main__.py +3 -0
- pyweb/compiler/__init__.py +5 -0
- pyweb/compiler/ast.py +45 -0
- pyweb/compiler/codegen/__init__.py +9 -0
- pyweb/compiler/codegen/ir.py +50 -0
- pyweb/compiler/errors.py +29 -0
- pyweb/compiler/lower.py +733 -0
- pyweb/compiler/parser.py +532 -0
- pyweb/compiler/pipeline.py +158 -0
- pyweb/compiler/pyjs.py +1087 -0
- pyweb/compiler/rpc.py +35 -0
- pyweb/context.py +193 -0
- pyweb/css.py +115 -0
- pyweb/db/__init__.py +652 -0
- pyweb/db/migrate.py +143 -0
- pyweb/decorators.py +49 -0
- pyweb/deploy.py +102 -0
- pyweb/forms.py +108 -0
- pyweb/hosting.py +139 -0
- pyweb/jobs.py +248 -0
- pyweb/live.py +88 -0
- pyweb/lsp.py +214 -0
- pyweb/models.py +269 -0
- pyweb/npm.py +270 -0
- pyweb/observability.py +313 -0
- pyweb/platform.py +69 -0
- pyweb/plugins.py +112 -0
- pyweb/py.typed +0 -0
- pyweb/realtime.py +189 -0
- pyweb/rpc.py +137 -0
- pyweb/runtime/browser/runtime.js +1151 -0
- pyweb/runtime/server/__init__.py +445 -0
- pyweb/security.py +162 -0
- pyweb/serve.py +335 -0
- pyweb/ssr.py +260 -0
- pyweb/sync.py +118 -0
- pyweb/testing.py +154 -0
- pyweb/uploads.py +94 -0
- pyweb_stack-0.1.0.dist-info/METADATA +198 -0
- pyweb_stack-0.1.0.dist-info/RECORD +54 -0
- pyweb_stack-0.1.0.dist-info/WHEEL +5 -0
- pyweb_stack-0.1.0.dist-info/entry_points.txt +2 -0
- pyweb_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyweb_stack-0.1.0.dist-info/top_level.txt +1 -0
pyweb/jobs.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Background jobs: @task decorator, in-memory queue, retries, progress."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import traceback
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Job:
|
|
12
|
+
def __init__(self, job_id, fn_name):
|
|
13
|
+
self.id = job_id
|
|
14
|
+
self.fn_name = fn_name
|
|
15
|
+
self.pending = True
|
|
16
|
+
self.failed = False
|
|
17
|
+
self.error = None
|
|
18
|
+
self.result = None
|
|
19
|
+
self.progress = 0.0
|
|
20
|
+
self.traceback = None
|
|
21
|
+
|
|
22
|
+
def set_progress(self, value):
|
|
23
|
+
self.progress = max(0.0, min(1.0, float(value)))
|
|
24
|
+
|
|
25
|
+
def to_dict(self):
|
|
26
|
+
return {"id": self.id, "fn": self.fn_name, "pending": self.pending,
|
|
27
|
+
"failed": self.failed, "error": self.error,
|
|
28
|
+
"result": self.result, "progress": self.progress}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Queue:
|
|
32
|
+
"""In-memory queue (Redis/RabbitMQ/Kafka adapters plug in here)."""
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
self.jobs: dict[str, Job] = {}
|
|
36
|
+
self._lock = threading.Lock()
|
|
37
|
+
self._threads: list[threading.Thread] = []
|
|
38
|
+
|
|
39
|
+
def submit(self, fn, *args, retries=0, **kwargs):
|
|
40
|
+
job = Job(uuid.uuid4().hex[:12], getattr(fn, "__name__", "fn"))
|
|
41
|
+
with self._lock:
|
|
42
|
+
self.jobs[job.id] = job
|
|
43
|
+
self._threads = [t for t in self._threads if t.is_alive()]
|
|
44
|
+
t = threading.Thread(target=self._run, args=(job, fn, args, kwargs, retries), daemon=True)
|
|
45
|
+
self._threads.append(t)
|
|
46
|
+
t.start()
|
|
47
|
+
return job
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _wants_job(fn):
|
|
51
|
+
"""Does ``fn`` accept the ``_job`` keyword (for progress reporting)?"""
|
|
52
|
+
import inspect
|
|
53
|
+
try:
|
|
54
|
+
params = inspect.signature(fn).parameters
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
return False
|
|
57
|
+
return "_job" in params or any(p.kind is p.VAR_KEYWORD for p in params.values())
|
|
58
|
+
|
|
59
|
+
def _run(self, job, fn, args, kwargs, retries):
|
|
60
|
+
attempt = 0
|
|
61
|
+
call_kwargs = dict(kwargs)
|
|
62
|
+
if self._wants_job(fn):
|
|
63
|
+
call_kwargs["_job"] = job
|
|
64
|
+
while True:
|
|
65
|
+
try:
|
|
66
|
+
job.result = fn(*args, **call_kwargs)
|
|
67
|
+
job.pending = False
|
|
68
|
+
return
|
|
69
|
+
except Exception as exc: # noqa: BLE001
|
|
70
|
+
err = exc
|
|
71
|
+
tb = traceback.format_exc()
|
|
72
|
+
if attempt >= retries:
|
|
73
|
+
job.pending = False
|
|
74
|
+
job.failed = True
|
|
75
|
+
job.error = f"{type(err).__name__}: {err}"
|
|
76
|
+
job.traceback = tb
|
|
77
|
+
return
|
|
78
|
+
attempt += 1
|
|
79
|
+
time.sleep(0.01 * attempt)
|
|
80
|
+
|
|
81
|
+
def get(self, job_id):
|
|
82
|
+
return self.jobs.get(job_id)
|
|
83
|
+
|
|
84
|
+
def wait(self, job, timeout=10.0):
|
|
85
|
+
end = time.time() + timeout
|
|
86
|
+
while job.pending and time.time() < end:
|
|
87
|
+
time.sleep(0.005)
|
|
88
|
+
return job
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def save(self, path):
|
|
92
|
+
"""Persist job records to JSON (survives restarts; payload fns excluded)."""
|
|
93
|
+
import json as _json
|
|
94
|
+
with self._lock:
|
|
95
|
+
data = {jid: j.to_dict() for jid, j in self.jobs.items()}
|
|
96
|
+
with open(path, "w") as fh:
|
|
97
|
+
_json.dump(data, fh)
|
|
98
|
+
|
|
99
|
+
def load(self, path):
|
|
100
|
+
"""Restore job records saved with :meth:`save`."""
|
|
101
|
+
import json as _json
|
|
102
|
+
try:
|
|
103
|
+
with open(path) as fh:
|
|
104
|
+
data = _json.load(fh)
|
|
105
|
+
except FileNotFoundError:
|
|
106
|
+
return 0
|
|
107
|
+
with self._lock:
|
|
108
|
+
for jid, rec in data.items():
|
|
109
|
+
job = Job(jid, rec.get("fn", "?"))
|
|
110
|
+
job.pending = bool(rec.get("pending", False))
|
|
111
|
+
job.failed = bool(rec.get("failed", False))
|
|
112
|
+
job.error = rec.get("error")
|
|
113
|
+
job.result = rec.get("result")
|
|
114
|
+
job.progress = float(rec.get("progress", 0.0))
|
|
115
|
+
self.jobs[jid] = job
|
|
116
|
+
return len(data)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class RedisQueue(Queue):
|
|
120
|
+
"""Cross-process job queue over Redis lists + hashes.
|
|
121
|
+
|
|
122
|
+
``submit`` pushes the job id onto ``<prefix>pending`` and stores the
|
|
123
|
+
record in ``<prefix>job:<id>``; any process running :meth:`drain`
|
|
124
|
+
(or a worker loop) can pick it up. Payloads must be JSON-serializable
|
|
125
|
+
args; the function itself is resolved from a registry so workers do
|
|
126
|
+
not need the submitting process's memory. Falls back to in-memory
|
|
127
|
+
execution when Redis is unreachable.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, url="redis://localhost:6379/0", client=None,
|
|
131
|
+
prefix="pyweb:jobs:"):
|
|
132
|
+
super().__init__()
|
|
133
|
+
self._prefix = prefix
|
|
134
|
+
self._registry: dict[str, object] = {}
|
|
135
|
+
if client is not None:
|
|
136
|
+
self._r = client
|
|
137
|
+
else:
|
|
138
|
+
try:
|
|
139
|
+
import redis
|
|
140
|
+
except ImportError as e:
|
|
141
|
+
raise RuntimeError(
|
|
142
|
+
"RedisQueue requires the 'redis' package: "
|
|
143
|
+
"pip install redis") from e
|
|
144
|
+
self._r = redis.Redis.from_url(url)
|
|
145
|
+
|
|
146
|
+
def register(self, fn):
|
|
147
|
+
self._registry[getattr(fn, "__name__", "fn")] = fn
|
|
148
|
+
return fn
|
|
149
|
+
|
|
150
|
+
def submit(self, fn, *args, retries=0, **kwargs):
|
|
151
|
+
import json as _json
|
|
152
|
+
name = getattr(fn, "__name__", "fn")
|
|
153
|
+
self._registry.setdefault(name, fn)
|
|
154
|
+
try:
|
|
155
|
+
payload = _json.dumps({"args": args, "kwargs": kwargs})
|
|
156
|
+
except (TypeError, ValueError):
|
|
157
|
+
return super().submit(fn, *args, retries=retries, **kwargs)
|
|
158
|
+
job = Job(__import__("uuid").uuid4().hex[:12], name)
|
|
159
|
+
with self._lock:
|
|
160
|
+
self.jobs[job.id] = job
|
|
161
|
+
try:
|
|
162
|
+
self._r.hset(self._prefix + f"job:{job.id}", mapping={
|
|
163
|
+
"fn": name, "payload": payload, "retries": retries})
|
|
164
|
+
self._r.rpush(self._prefix + "pending", job.id)
|
|
165
|
+
except Exception: # noqa: BLE001 — Redis down: run in-memory
|
|
166
|
+
return super().submit(fn, *args, retries=retries, **kwargs)
|
|
167
|
+
return job
|
|
168
|
+
|
|
169
|
+
def drain(self, timeout=0):
|
|
170
|
+
"""Run one pending job from the Redis list. Returns job or None."""
|
|
171
|
+
import json as _json
|
|
172
|
+
try:
|
|
173
|
+
if timeout:
|
|
174
|
+
item = self._r.blpop(self._prefix + "pending", timeout=timeout)
|
|
175
|
+
job_id = item[1] if item else None
|
|
176
|
+
else:
|
|
177
|
+
# BLPOP with timeout=0 blocks forever on real Redis.
|
|
178
|
+
job_id = self._r.lpop(self._prefix + "pending")
|
|
179
|
+
except Exception:
|
|
180
|
+
return None
|
|
181
|
+
if not job_id:
|
|
182
|
+
return None
|
|
183
|
+
job_id = job_id.decode() if isinstance(job_id, bytes) else job_id
|
|
184
|
+
try:
|
|
185
|
+
rec = self._r.hgetall(self._prefix + f"job:{job_id}")
|
|
186
|
+
get = lambda k: (rec.get(k.encode(), b"") or b"").decode() \
|
|
187
|
+
if isinstance(rec.get(k.encode(), b""), bytes) \
|
|
188
|
+
else rec.get(k, "")
|
|
189
|
+
fn = self._registry.get(get("fn"))
|
|
190
|
+
payload = _json.loads(get("payload") or "{}")
|
|
191
|
+
retries = int(get("retries") or 0)
|
|
192
|
+
except Exception: # noqa: BLE001
|
|
193
|
+
return self.jobs.get(job_id)
|
|
194
|
+
job = self.jobs.get(job_id)
|
|
195
|
+
if job is None:
|
|
196
|
+
job = Job(job_id, get("fn"))
|
|
197
|
+
with self._lock:
|
|
198
|
+
self.jobs[job_id] = job
|
|
199
|
+
if fn is None:
|
|
200
|
+
job.pending = False
|
|
201
|
+
job.failed = True
|
|
202
|
+
job.error = "unknown function for worker"
|
|
203
|
+
return job
|
|
204
|
+
self._run(job, fn, payload.get("args", ()),
|
|
205
|
+
payload.get("kwargs", {}), retries)
|
|
206
|
+
try:
|
|
207
|
+
try:
|
|
208
|
+
result = _json.dumps(job.result)
|
|
209
|
+
except (TypeError, ValueError):
|
|
210
|
+
result = _json.dumps(repr(job.result))
|
|
211
|
+
self._r.hset(self._prefix + f"job:{job_id}", mapping={
|
|
212
|
+
"done": "1", "failed": "1" if job.failed else "",
|
|
213
|
+
"result": result, "error": str(job.error or "")})
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
return job
|
|
217
|
+
|
|
218
|
+
def status(self, job_id):
|
|
219
|
+
"""Job state as stored in Redis (readable from any process)."""
|
|
220
|
+
import json as _json
|
|
221
|
+
rec = self._r.hgetall(self._prefix + f"job:{job_id}")
|
|
222
|
+
if not rec:
|
|
223
|
+
return None
|
|
224
|
+
rec = {(k.decode() if isinstance(k, bytes) else k): (v.decode() if isinstance(v, bytes) else v)
|
|
225
|
+
for k, v in rec.items()}
|
|
226
|
+
return {"fn": rec.get("fn"), "done": rec.get("done") == "1",
|
|
227
|
+
"failed": rec.get("failed") == "1",
|
|
228
|
+
"result": _json.loads(rec["result"]) if rec.get("result") else None,
|
|
229
|
+
"error": rec.get("error") or None}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
_default_queue = Queue()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def task(_fn=None, *, retries=0, queue=None):
|
|
236
|
+
q = queue or _default_queue
|
|
237
|
+
|
|
238
|
+
def deco(fn):
|
|
239
|
+
if isinstance(q, RedisQueue):
|
|
240
|
+
q.register(fn)
|
|
241
|
+
|
|
242
|
+
def wrapper(*args, **kwargs):
|
|
243
|
+
return q.submit(fn, *args, retries=retries, **kwargs)
|
|
244
|
+
wrapper.__pyweb_location__ = "worker"
|
|
245
|
+
wrapper.sync = fn
|
|
246
|
+
wrapper.queue = q
|
|
247
|
+
return wrapper
|
|
248
|
+
return deco(_fn) if _fn else deco
|
pyweb/live.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Live queries: dependency-tracked subscriptions with minimal patches.
|
|
2
|
+
|
|
3
|
+
A ``LiveTable`` wraps rows and a ``Bus`` channel. ``live_query`` records
|
|
4
|
+
which rows a selector reads; ``commit`` diffs versions and only notifies
|
|
5
|
+
subscribers whose dependency set intersects the changed rows.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
|
|
12
|
+
from .realtime import Bus
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LiveTable:
|
|
16
|
+
def __init__(self, name, bus=None):
|
|
17
|
+
self.name = name
|
|
18
|
+
self.bus = bus or Bus()
|
|
19
|
+
self._rows: dict[str, dict] = {}
|
|
20
|
+
self._versions: dict[str, int] = {}
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
|
|
23
|
+
def set(self, row_id, data):
|
|
24
|
+
with self._lock:
|
|
25
|
+
self._rows[row_id] = dict(data)
|
|
26
|
+
self._versions[row_id] = self._versions.get(row_id, 0) + 1
|
|
27
|
+
version = self._versions[row_id]
|
|
28
|
+
self.bus.publish(f"live:{self.name}", {"row": row_id, "version": version})
|
|
29
|
+
return version
|
|
30
|
+
|
|
31
|
+
def delete(self, row_id):
|
|
32
|
+
with self._lock:
|
|
33
|
+
self._rows.pop(row_id, None)
|
|
34
|
+
self._versions[row_id] = self._versions.get(row_id, 0) + 1
|
|
35
|
+
self.bus.publish(f"live:{self.name}", {"row": row_id, "deleted": True})
|
|
36
|
+
|
|
37
|
+
def read(self, row_id, tracker=None):
|
|
38
|
+
with self._lock:
|
|
39
|
+
row = dict(self._rows.get(row_id, {}))
|
|
40
|
+
if tracker is not None:
|
|
41
|
+
tracker.add(row_id)
|
|
42
|
+
return row
|
|
43
|
+
|
|
44
|
+
def all(self, tracker=None):
|
|
45
|
+
with self._lock:
|
|
46
|
+
rows = {k: dict(v) for k, v in self._rows.items()}
|
|
47
|
+
if tracker is not None:
|
|
48
|
+
tracker.update(rows.keys())
|
|
49
|
+
return rows
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LiveQuery:
|
|
53
|
+
"""A selector + subscriber set; only fires when deps change."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, table, selector):
|
|
56
|
+
self.table = table
|
|
57
|
+
self.selector = selector
|
|
58
|
+
self.deps: set[str] = set()
|
|
59
|
+
self.value = None
|
|
60
|
+
self.fires = 0
|
|
61
|
+
self._subs: list = []
|
|
62
|
+
self.refresh()
|
|
63
|
+
table.bus.channel(f"live:{table.name}").subscribe(self._on_change)
|
|
64
|
+
|
|
65
|
+
def refresh(self):
|
|
66
|
+
tracker: set[str] = set()
|
|
67
|
+
import inspect
|
|
68
|
+
try:
|
|
69
|
+
if len(inspect.signature(self.selector).parameters) >= 2:
|
|
70
|
+
self.value = self.selector(self.table, tracker)
|
|
71
|
+
else:
|
|
72
|
+
self.value = self.selector(tracker)
|
|
73
|
+
except TypeError:
|
|
74
|
+
self.value = self.selector()
|
|
75
|
+
self.deps = set(tracker)
|
|
76
|
+
return self.value
|
|
77
|
+
|
|
78
|
+
def _on_change(self, msg):
|
|
79
|
+
if not self.deps or msg.get("row") in self.deps:
|
|
80
|
+
self.refresh()
|
|
81
|
+
self.fires += 1
|
|
82
|
+
for sub in list(self._subs):
|
|
83
|
+
sub(self.value)
|
|
84
|
+
|
|
85
|
+
def subscribe(self, fn):
|
|
86
|
+
self._subs.append(fn)
|
|
87
|
+
fn(self.value)
|
|
88
|
+
return lambda: self._subs.remove(fn)
|
pyweb/lsp.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Language-server helpers: symbols, completions, hover, boundary lens.
|
|
2
|
+
|
|
3
|
+
These are pure functions over source text + compiler output so any
|
|
4
|
+
LSP transport (e.g. pygls) can wrap them without importing server code.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from .compiler import parser as P
|
|
14
|
+
|
|
15
|
+
BROWSER_APIS = ["storage", "clipboard", "location", "camera", "notifications",
|
|
16
|
+
"fetch", "websocket", "indexeddb", "geolocation", "canvas"]
|
|
17
|
+
DECORATORS = ["@app.page", "@server", "@edge", "@worker",
|
|
18
|
+
"@component", "@task", "@cache", "@realtime",
|
|
19
|
+
"@auth.required", "@permission"]
|
|
20
|
+
HTML_TAGS = ["main", "div", "span", "p", "h1", "h2", "article", "section",
|
|
21
|
+
"button", "input", "form", "label", "ul", "li", "a", "img"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def symbols(source):
|
|
25
|
+
"""Outline: pages, components, server fns, models, signals (best-effort)."""
|
|
26
|
+
out = []
|
|
27
|
+
try:
|
|
28
|
+
tree = ast.parse(P.split_sources(source)[0])
|
|
29
|
+
except SyntaxError:
|
|
30
|
+
return out
|
|
31
|
+
for node in tree.body:
|
|
32
|
+
if isinstance(node, ast.ClassDef):
|
|
33
|
+
bases = [getattr(b, "id", "") for b in node.bases]
|
|
34
|
+
kind = "model" if "Model" in bases else "class"
|
|
35
|
+
out.append({"kind": kind, "name": node.name, "line": node.lineno})
|
|
36
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
37
|
+
decos = [ast.unparse(d) if hasattr(ast, "unparse") else "" for d in node.decorator_list]
|
|
38
|
+
joined = " ".join(decos)
|
|
39
|
+
if "page" in joined:
|
|
40
|
+
kind = "page"
|
|
41
|
+
elif any(k in joined for k in ("server", "worker", "edge", "browser", "task", "realtime")):
|
|
42
|
+
kind = "rpc"
|
|
43
|
+
elif "component" in joined:
|
|
44
|
+
kind = "component"
|
|
45
|
+
else:
|
|
46
|
+
kind = "function"
|
|
47
|
+
out.append({"kind": kind, "name": node.name, "line": node.lineno,
|
|
48
|
+
"decorators": decos})
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def complete(source, line, col):
|
|
53
|
+
"""Prefix completions for the token ending at (line, col), 1-based line."""
|
|
54
|
+
lines = source.splitlines()
|
|
55
|
+
if not (1 <= line <= len(lines)):
|
|
56
|
+
return []
|
|
57
|
+
frag = lines[line - 1][:col]
|
|
58
|
+
m = re.search(r"(@[\w.]*|[A-Za-z_][\w]*)$", frag)
|
|
59
|
+
token = m.group(1) if m else ""
|
|
60
|
+
pool = DECORATORS if token.startswith("@") else (
|
|
61
|
+
BROWSER_APIS if "browser." in frag or "pyweb.browser" in frag else
|
|
62
|
+
HTML_TAGS if "<" in frag else
|
|
63
|
+
["Signal", "Computed", "Resource", "Effect", "live", "App", "Model"])
|
|
64
|
+
if token.startswith("@"):
|
|
65
|
+
base = token[1:]
|
|
66
|
+
out = []
|
|
67
|
+
for c in pool:
|
|
68
|
+
name = c[1:] if c.startswith("@") else c
|
|
69
|
+
if name.startswith(base):
|
|
70
|
+
out.append({"label": c, "insert": name[len(base):]})
|
|
71
|
+
return out
|
|
72
|
+
return [{"label": c, "insert": c[len(token):]} for c in pool
|
|
73
|
+
if c.startswith(token)]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def hover(compiled, symbol):
|
|
77
|
+
"""Placement + reason lens for a signal/handler/rpc name."""
|
|
78
|
+
for name, page in compiled.get("pages", {}).items():
|
|
79
|
+
if symbol in page.get("placement", {}):
|
|
80
|
+
loc, reason = page["placement"][symbol]
|
|
81
|
+
return {"page": name, "symbol": symbol, "location": loc, "reason": reason}
|
|
82
|
+
for spec in compiled.get("rpc", []):
|
|
83
|
+
if spec["name"] == symbol:
|
|
84
|
+
return {"symbol": symbol, "location": spec["location"],
|
|
85
|
+
"reason": f"typed RPC ({spec.get('line', '?')})"}
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def boundary_lens(compiled):
|
|
90
|
+
"""Every symbol with its execution location for editor gutters."""
|
|
91
|
+
lens = []
|
|
92
|
+
for name, page in compiled.get("pages", {}).items():
|
|
93
|
+
for sym, (loc, reason) in page.get("placement", {}).items():
|
|
94
|
+
lens.append({"page": name, "symbol": sym, "location": loc, "reason": reason})
|
|
95
|
+
return sorted(lens, key=lambda e: (e["page"], e["symbol"]))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class CompletionItem:
|
|
100
|
+
label: str
|
|
101
|
+
kind: str = "Keyword"
|
|
102
|
+
detail: str = ""
|
|
103
|
+
insert_text: str = ""
|
|
104
|
+
|
|
105
|
+
def to_dict(self):
|
|
106
|
+
return {"label": self.label, "kind": self.kind,
|
|
107
|
+
"detail": self.detail,
|
|
108
|
+
"insertText": self.insert_text or self.label}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class DocumentState:
|
|
113
|
+
components: dict = None
|
|
114
|
+
routes: list = None
|
|
115
|
+
models: dict = None
|
|
116
|
+
keywords: tuple = ("signal", "route", "rpc", "component")
|
|
117
|
+
|
|
118
|
+
def __post_init__(self):
|
|
119
|
+
if self.components is None:
|
|
120
|
+
self.components = {}
|
|
121
|
+
if self.routes is None:
|
|
122
|
+
self.routes = []
|
|
123
|
+
if self.models is None:
|
|
124
|
+
self.models = {}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _prefix(items, prefix):
|
|
128
|
+
return sorted(i for i in items if i.startswith(prefix))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def complete_components(state, prefix=""):
|
|
132
|
+
return [CompletionItem(name, "Component",
|
|
133
|
+
detail=f"props: {', '.join(state.components[name]) or '—'}")
|
|
134
|
+
for name in _prefix(state.components, prefix)]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def complete_props(state, component, prefix=""):
|
|
138
|
+
return [CompletionItem(p, "Property", detail=f"{component}.{p}")
|
|
139
|
+
for p in _prefix(state.components.get(component, []), prefix)]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def complete_routes(state, prefix=""):
|
|
143
|
+
return [CompletionItem(r, "File", detail="route")
|
|
144
|
+
for r in _prefix(state.routes, prefix)]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def complete_model_fields(state, model, prefix=""):
|
|
148
|
+
return [CompletionItem(f, "Field", detail=f"{model}.{f}")
|
|
149
|
+
for f in _prefix(state.models.get(model, []), prefix)]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def complete_state(state, prefix=""):
|
|
153
|
+
items = complete_components(state, prefix)
|
|
154
|
+
items += complete_routes(state, prefix)
|
|
155
|
+
for model in _prefix(state.models, prefix):
|
|
156
|
+
items.append(CompletionItem(model, "Field",
|
|
157
|
+
detail=f"model: {', '.join(state.models[model])}"))
|
|
158
|
+
items += [CompletionItem(k, "Keyword") for k in _prefix(state.keywords, prefix)]
|
|
159
|
+
return items
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def state_from_graph(graph):
|
|
163
|
+
to_dict = getattr(graph, "to_dict", None)
|
|
164
|
+
data = to_dict() if callable(to_dict) else (graph if isinstance(graph, dict) else {})
|
|
165
|
+
components = {c["name"]: list(c.get("props", [])) for c in data.get("components", [])}
|
|
166
|
+
routes = [r["path"] for r in data.get("routes", [])]
|
|
167
|
+
models = {}
|
|
168
|
+
for m in data.get("models", []):
|
|
169
|
+
if isinstance(m, dict):
|
|
170
|
+
models[m.get("name", "?")] = list(m.get("fields", []))
|
|
171
|
+
return DocumentState(components=components, routes=routes, models=models)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _int_or(value, default):
|
|
175
|
+
try:
|
|
176
|
+
return int(value)
|
|
177
|
+
except (TypeError, ValueError):
|
|
178
|
+
return default
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def diagnostics_for_compile_error(error):
|
|
182
|
+
errors = list(error) if isinstance(error, (list, tuple)) else [error]
|
|
183
|
+
diags = []
|
|
184
|
+
for err in errors:
|
|
185
|
+
message = (getattr(err, "message", None) or getattr(err, "msg", None)
|
|
186
|
+
or (err.get("message") if isinstance(err, dict) else None)
|
|
187
|
+
or str(err))
|
|
188
|
+
span = getattr(err, "span", None) if not isinstance(err, dict) else None
|
|
189
|
+
if isinstance(err, dict):
|
|
190
|
+
inner = err.get("span")
|
|
191
|
+
span = {**err, **inner} if isinstance(inner, dict) else err
|
|
192
|
+
if span is not None and not isinstance(span, dict):
|
|
193
|
+
line = _int_or(getattr(span, "line", None), 1)
|
|
194
|
+
col = _int_or(getattr(span, "col", getattr(span, "column", None)), 0)
|
|
195
|
+
end_line = _int_or(getattr(span, "end_line", None), line)
|
|
196
|
+
end_col = _int_or(getattr(span, "end_col", getattr(span, "end_column", None)), col + 1)
|
|
197
|
+
else:
|
|
198
|
+
span = span or {}
|
|
199
|
+
line = _int_or(span.get("line"), 1)
|
|
200
|
+
col = _int_or(span.get("col", span.get("column")), 0)
|
|
201
|
+
end_line = _int_or(span.get("end_line"), line)
|
|
202
|
+
end_col = _int_or(span.get("end_col", span.get("end_column")), col + 1)
|
|
203
|
+
source = getattr(err, "file", getattr(err, "path", None))
|
|
204
|
+
if source is None and isinstance(span, dict):
|
|
205
|
+
source = span.get("file", span.get("path"))
|
|
206
|
+
diags.append({
|
|
207
|
+
"message": str(message),
|
|
208
|
+
"severity": 1,
|
|
209
|
+
"source": "pyweb",
|
|
210
|
+
"range": {"start": {"line": max(line - 1, 0), "character": max(col, 0)},
|
|
211
|
+
"end": {"line": max(end_line - 1, 0), "character": max(end_col, 0)}},
|
|
212
|
+
**({"uri": str(source)} if source else {}),
|
|
213
|
+
})
|
|
214
|
+
return diags
|