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.
Files changed (54) hide show
  1. pyweb/__init__.py +71 -0
  2. pyweb/app.py +37 -0
  3. pyweb/app_loader.py +139 -0
  4. pyweb/asgi.py +71 -0
  5. pyweb/auth.py +701 -0
  6. pyweb/bench.py +101 -0
  7. pyweb/browser.py +232 -0
  8. pyweb/build.py +295 -0
  9. pyweb/cache.py +201 -0
  10. pyweb/cli/__init__.py +465 -0
  11. pyweb/cli/__main__.py +3 -0
  12. pyweb/compiler/__init__.py +5 -0
  13. pyweb/compiler/ast.py +45 -0
  14. pyweb/compiler/codegen/__init__.py +9 -0
  15. pyweb/compiler/codegen/ir.py +50 -0
  16. pyweb/compiler/errors.py +29 -0
  17. pyweb/compiler/lower.py +733 -0
  18. pyweb/compiler/parser.py +532 -0
  19. pyweb/compiler/pipeline.py +158 -0
  20. pyweb/compiler/pyjs.py +1087 -0
  21. pyweb/compiler/rpc.py +35 -0
  22. pyweb/context.py +193 -0
  23. pyweb/css.py +115 -0
  24. pyweb/db/__init__.py +652 -0
  25. pyweb/db/migrate.py +143 -0
  26. pyweb/decorators.py +49 -0
  27. pyweb/deploy.py +102 -0
  28. pyweb/forms.py +108 -0
  29. pyweb/hosting.py +139 -0
  30. pyweb/jobs.py +248 -0
  31. pyweb/live.py +88 -0
  32. pyweb/lsp.py +214 -0
  33. pyweb/models.py +269 -0
  34. pyweb/npm.py +270 -0
  35. pyweb/observability.py +313 -0
  36. pyweb/platform.py +69 -0
  37. pyweb/plugins.py +112 -0
  38. pyweb/py.typed +0 -0
  39. pyweb/realtime.py +189 -0
  40. pyweb/rpc.py +137 -0
  41. pyweb/runtime/browser/runtime.js +1151 -0
  42. pyweb/runtime/server/__init__.py +445 -0
  43. pyweb/security.py +162 -0
  44. pyweb/serve.py +335 -0
  45. pyweb/ssr.py +260 -0
  46. pyweb/sync.py +118 -0
  47. pyweb/testing.py +154 -0
  48. pyweb/uploads.py +94 -0
  49. pyweb_stack-0.1.0.dist-info/METADATA +198 -0
  50. pyweb_stack-0.1.0.dist-info/RECORD +54 -0
  51. pyweb_stack-0.1.0.dist-info/WHEEL +5 -0
  52. pyweb_stack-0.1.0.dist-info/entry_points.txt +2 -0
  53. pyweb_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. 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