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/models.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Model layer: annotation-style prototype models plus typed Field models
|
|
2
|
+
bound to a database driver (SQLite/Postgres)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sqlite3
|
|
7
|
+
import threading
|
|
8
|
+
from typing import ClassVar
|
|
9
|
+
|
|
10
|
+
_lock = threading.RLock()
|
|
11
|
+
_db_path = ":memory:"
|
|
12
|
+
_conns: dict[str, sqlite3.Connection] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Email(str):
|
|
16
|
+
@classmethod
|
|
17
|
+
def validate(cls, value):
|
|
18
|
+
if "@" not in str(value):
|
|
19
|
+
raise ValueError(f"invalid email: {value!r}")
|
|
20
|
+
return cls(value)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Field:
|
|
24
|
+
column_type = "TEXT"
|
|
25
|
+
|
|
26
|
+
def __init__(self, *, primary_key=False, nullable=True, default=None):
|
|
27
|
+
self.primary_key = primary_key
|
|
28
|
+
self.nullable = nullable
|
|
29
|
+
self.default = default
|
|
30
|
+
self.name = ""
|
|
31
|
+
|
|
32
|
+
def sql(self):
|
|
33
|
+
parts = [self.name, self.column_type]
|
|
34
|
+
if self.primary_key:
|
|
35
|
+
parts.append("PRIMARY KEY")
|
|
36
|
+
if self.column_type == "INTEGER":
|
|
37
|
+
parts.append("AUTOINCREMENT")
|
|
38
|
+
elif not self.nullable:
|
|
39
|
+
parts.append("NOT NULL")
|
|
40
|
+
if self.default is not None and not self.primary_key:
|
|
41
|
+
parts.append(f"DEFAULT {self._default_sql()}")
|
|
42
|
+
return " ".join(parts)
|
|
43
|
+
|
|
44
|
+
def _default_sql(self):
|
|
45
|
+
if isinstance(self.default, str):
|
|
46
|
+
return "'" + self.default.replace("'", "''") + "'"
|
|
47
|
+
if isinstance(self.default, bool):
|
|
48
|
+
return "1" if self.default else "0"
|
|
49
|
+
return str(self.default)
|
|
50
|
+
|
|
51
|
+
def to_db(self, value):
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
def from_db(self, value):
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class IntegerField(Field):
|
|
59
|
+
column_type = "INTEGER"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TextField(Field):
|
|
63
|
+
column_type = "TEXT"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RealField(Field):
|
|
67
|
+
column_type = "REAL"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BlobField(Field):
|
|
71
|
+
column_type = "BLOB"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ModelMeta(type):
|
|
75
|
+
_registry: list[type] = []
|
|
76
|
+
|
|
77
|
+
def __new__(mcls, name, bases, ns):
|
|
78
|
+
fields: dict[str, Field] = {}
|
|
79
|
+
for base in bases:
|
|
80
|
+
if hasattr(base, "__fields__") and isinstance(getattr(base, "__fields__"), dict):
|
|
81
|
+
for k, f in getattr(base, "__fields__").items():
|
|
82
|
+
if isinstance(f, Field):
|
|
83
|
+
fields[k] = f
|
|
84
|
+
annotations = ns.get("__annotations__", {})
|
|
85
|
+
cls = super().__new__(mcls, name, bases, ns)
|
|
86
|
+
if name != "Model":
|
|
87
|
+
cls._fields = dict(annotations)
|
|
88
|
+
for key, val in list(ns.items()):
|
|
89
|
+
if isinstance(val, Field):
|
|
90
|
+
val.name = key
|
|
91
|
+
fields[key] = val
|
|
92
|
+
if fields:
|
|
93
|
+
cls.__fields__ = fields
|
|
94
|
+
if "__table__" not in ns:
|
|
95
|
+
cls.__table__ = name.lower()
|
|
96
|
+
pks = [k for k, f in fields.items() if f.primary_key]
|
|
97
|
+
cls.__pk__ = pks[0] if pks else None
|
|
98
|
+
elif "__fields__" not in ns:
|
|
99
|
+
cls.__fields__ = {}
|
|
100
|
+
ModelMeta._registry.append(cls)
|
|
101
|
+
return cls
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Model(metaclass=ModelMeta):
|
|
105
|
+
_fields: dict = {}
|
|
106
|
+
_table: str = ""
|
|
107
|
+
__fields__: ClassVar[dict] = {}
|
|
108
|
+
__table__: ClassVar[str] = ""
|
|
109
|
+
__pk__: ClassVar[str | None] = None
|
|
110
|
+
_db: ClassVar = None
|
|
111
|
+
|
|
112
|
+
def __init__(self, **kwargs):
|
|
113
|
+
if getattr(type(self), "__fields__", {}):
|
|
114
|
+
for key, field in type(self).__fields__.items():
|
|
115
|
+
setattr(self, key, kwargs.pop(key, field.default))
|
|
116
|
+
if kwargs:
|
|
117
|
+
raise TypeError(f"unknown fields for {type(self).__name__}: {sorted(kwargs)}")
|
|
118
|
+
else:
|
|
119
|
+
for k, v in kwargs.items():
|
|
120
|
+
setattr(self, k, v)
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def bind(cls, db):
|
|
124
|
+
cls._db = db
|
|
125
|
+
return db
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def _db_or_raise(cls):
|
|
129
|
+
if cls._db is None:
|
|
130
|
+
raise RuntimeError(f"{cls.__name__} is not bound to a database; "
|
|
131
|
+
f"call {cls.__name__}.bind(db) first")
|
|
132
|
+
return cls._db
|
|
133
|
+
|
|
134
|
+
def to_dict(self):
|
|
135
|
+
if getattr(type(self), "__fields__", {}):
|
|
136
|
+
return {k: getattr(self, k) for k in type(self).__fields__}
|
|
137
|
+
return dict(self.__dict__)
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def schema_sql(cls):
|
|
141
|
+
cols = ", ".join(f.sql() for f in cls.__fields__.values())
|
|
142
|
+
return f"CREATE TABLE IF NOT EXISTS {cls.__table__} ({cols})"
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def _from_row(cls, row, columns):
|
|
146
|
+
obj = cls.__new__(cls)
|
|
147
|
+
for col, val in zip(columns, row):
|
|
148
|
+
field = cls.__fields__.get(col)
|
|
149
|
+
setattr(obj, col, field.from_db(val) if field else val)
|
|
150
|
+
return obj
|
|
151
|
+
|
|
152
|
+
def save(self):
|
|
153
|
+
from pyweb.db import Query
|
|
154
|
+
db = self._db_or_raise()
|
|
155
|
+
data = {k: f.to_db(getattr(self, k)) for k, f in type(self).__fields__.items()
|
|
156
|
+
if not (f.primary_key and getattr(self, k) is None)}
|
|
157
|
+
pk = type(self).__pk__
|
|
158
|
+
if pk is not None and getattr(self, pk) is not None:
|
|
159
|
+
assignments = {k: v for k, v in data.items() if k != pk}
|
|
160
|
+
if assignments:
|
|
161
|
+
q = Query(type(self).__table__).where(**{pk: getattr(self, pk)})
|
|
162
|
+
sql, params = q.build_update(assignments)
|
|
163
|
+
db.execute(sql, params)
|
|
164
|
+
else:
|
|
165
|
+
sql, params = Query(type(self).__table__).build_insert(data)
|
|
166
|
+
result = db.execute(sql, params)
|
|
167
|
+
if pk is not None and getattr(self, pk) is None:
|
|
168
|
+
setattr(self, pk, result.lastrowid)
|
|
169
|
+
return self
|
|
170
|
+
|
|
171
|
+
def delete(self):
|
|
172
|
+
from pyweb.db import Query
|
|
173
|
+
db = self._db_or_raise()
|
|
174
|
+
pk = type(self).__pk__
|
|
175
|
+
if pk is None or getattr(self, pk) is None:
|
|
176
|
+
raise ValueError("cannot delete a model without a primary key value")
|
|
177
|
+
q = Query(type(self).__table__).where(**{pk: getattr(self, pk)})
|
|
178
|
+
sql, params = q.build_delete()
|
|
179
|
+
db.execute(sql, params)
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def get(cls, pk_value):
|
|
183
|
+
from pyweb.db import Query
|
|
184
|
+
db = cls._db_or_raise()
|
|
185
|
+
q = Query(cls.__table__).where(**{cls.__pk__: pk_value})
|
|
186
|
+
sql, params = q.build_select()
|
|
187
|
+
res = db.execute(sql, params)
|
|
188
|
+
rows = res.fetchall()
|
|
189
|
+
if not rows:
|
|
190
|
+
return None
|
|
191
|
+
return cls._from_row(rows[0], res.columns)
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def filter(cls, **kwargs):
|
|
195
|
+
from pyweb.db import Query
|
|
196
|
+
db = cls._db_or_raise()
|
|
197
|
+
q = Query(cls.__table__).where(**kwargs)
|
|
198
|
+
sql, params = q.build_select()
|
|
199
|
+
res = db.execute(sql, params)
|
|
200
|
+
return [cls._from_row(r, res.columns) for r in res.fetchall()]
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def _conn(cls):
|
|
204
|
+
global _db_path
|
|
205
|
+
key = _db_path
|
|
206
|
+
with _lock:
|
|
207
|
+
if key not in _conns:
|
|
208
|
+
_conns[key] = sqlite3.connect(key, check_same_thread=False)
|
|
209
|
+
_conns[key].row_factory = sqlite3.Row
|
|
210
|
+
return _conns[key]
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def configure(cls, path=":memory:"):
|
|
214
|
+
global _db_path
|
|
215
|
+
_db_path = path
|
|
216
|
+
|
|
217
|
+
@classmethod
|
|
218
|
+
def table(cls):
|
|
219
|
+
return cls._table or cls.__name__.lower() + "s"
|
|
220
|
+
|
|
221
|
+
@classmethod
|
|
222
|
+
def migrate(cls):
|
|
223
|
+
cols = []
|
|
224
|
+
for name, ann in cls._fields.items():
|
|
225
|
+
tname = getattr(ann, "__name__", "TEXT").upper()
|
|
226
|
+
sql_t = {"STR": "TEXT", "INT": "INTEGER", "FLOAT": "REAL", "BOOL": "INTEGER"}.get(tname, "TEXT")
|
|
227
|
+
cols.append(f'"{name}" {sql_t}')
|
|
228
|
+
conn = cls._conn()
|
|
229
|
+
with _lock:
|
|
230
|
+
conn.execute(f'CREATE TABLE IF NOT EXISTS "{cls.table()}" (id INTEGER PRIMARY KEY AUTOINCREMENT, {", ".join(cols)})')
|
|
231
|
+
conn.commit()
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
def create(cls, **kwargs):
|
|
235
|
+
if getattr(cls, "__fields__", {}):
|
|
236
|
+
return cls(**kwargs).save()
|
|
237
|
+
cls.migrate()
|
|
238
|
+
conn = cls._conn()
|
|
239
|
+
keys = list(kwargs)
|
|
240
|
+
with _lock:
|
|
241
|
+
cur = conn.execute(
|
|
242
|
+
f'INSERT INTO "{cls.table()}" ({", ".join(keys)}) VALUES ({", ".join("?" for _ in keys)})',
|
|
243
|
+
[kwargs[k] for k in keys],
|
|
244
|
+
)
|
|
245
|
+
conn.commit()
|
|
246
|
+
return {"id": cur.lastrowid, **kwargs}
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
def all(cls):
|
|
250
|
+
if getattr(cls, "__fields__", {}):
|
|
251
|
+
from pyweb.db import Query
|
|
252
|
+
db = cls._db_or_raise()
|
|
253
|
+
sql, params = Query(cls.__table__).build_select()
|
|
254
|
+
res = db.execute(sql, params)
|
|
255
|
+
return [cls._from_row(r, res.columns) for r in res.fetchall()]
|
|
256
|
+
cls.migrate()
|
|
257
|
+
conn = cls._conn()
|
|
258
|
+
with _lock:
|
|
259
|
+
return [dict(r) for r in conn.execute(f'SELECT * FROM "{cls.table()}"')]
|
|
260
|
+
|
|
261
|
+
@classmethod
|
|
262
|
+
def where(cls, **kwargs):
|
|
263
|
+
cls.migrate()
|
|
264
|
+
conn = cls._conn()
|
|
265
|
+
with _lock:
|
|
266
|
+
if not kwargs:
|
|
267
|
+
return cls.all()
|
|
268
|
+
cond = " AND ".join(f'"{k}" = ?' for k in kwargs)
|
|
269
|
+
return [dict(r) for r in conn.execute(f'SELECT * FROM "{cls.table()}" WHERE {cond}', list(kwargs.values()))]
|
pyweb/npm.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""npm interop stub: typed package references + .d.ts binding sketch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def package(name: str, version: str = "latest"):
|
|
10
|
+
return {"npm": name, "version": version, "kind": "esm"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_TS_SCALARS = {"number": "float", "string": "str", "boolean": "bool",
|
|
14
|
+
"any": "object", "unknown": "object", "void": "None",
|
|
15
|
+
"null": "None", "undefined": "None", "Date": "str"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _ts_to_py(ts_type):
|
|
19
|
+
import re
|
|
20
|
+
t = ts_type.strip().rstrip(";").strip()
|
|
21
|
+
if t.endswith("[]"):
|
|
22
|
+
return f"list[{_ts_to_py(t[:-2])}]"
|
|
23
|
+
m = re.match(r"Array<(.+)>", t)
|
|
24
|
+
if m:
|
|
25
|
+
return f"list[{_ts_to_py(m.group(1))}]"
|
|
26
|
+
if "|" in t:
|
|
27
|
+
parts = [_ts_to_py(p) for p in t.split("|")]
|
|
28
|
+
parts = [p for p in parts if p != "None"]
|
|
29
|
+
if len(parts) != len(t.split("|")):
|
|
30
|
+
return f"Optional[{' | '.join(parts) if parts else 'object'}]"
|
|
31
|
+
return " | ".join(parts) if parts else "object"
|
|
32
|
+
m = re.match(r"Record<(.+),\s*(.+)>", t)
|
|
33
|
+
if m:
|
|
34
|
+
return f"dict[{_ts_to_py(m.group(1))}, {_ts_to_py(m.group(2))}]"
|
|
35
|
+
return _TS_SCALARS.get(t, t)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def from_dts(dts: str):
|
|
39
|
+
"""Parse `interface`/`type` declarations into typed Python dataclasses."""
|
|
40
|
+
import re
|
|
41
|
+
out = ["from __future__ import annotations", "from dataclasses import dataclass",
|
|
42
|
+
"from typing import Optional", ""]
|
|
43
|
+
found = False
|
|
44
|
+
for m in re.finditer(r"(?:interface|type)\s+(\w+)(?:\s*=\s*)?\s*\{([^}]*)\}", dts):
|
|
45
|
+
found = True
|
|
46
|
+
name, body = m.group(1), m.group(2)
|
|
47
|
+
out.append("@dataclass")
|
|
48
|
+
out.append(f"class {name}:")
|
|
49
|
+
n_fields = 0
|
|
50
|
+
for line in body.splitlines():
|
|
51
|
+
line = line.split("//")[0].strip().rstrip(",;")
|
|
52
|
+
fm = re.match(r"(?:readonly\s+)?(\w+)(\?)?:\s*(.+)", line)
|
|
53
|
+
if fm:
|
|
54
|
+
fname, opt, ftype = fm.group(1), fm.group(2), fm.group(3).strip()
|
|
55
|
+
py_t = _ts_to_py(ftype)
|
|
56
|
+
out.append(f" {fname}: {py_t} = None" if opt else f" {fname}: {py_t}")
|
|
57
|
+
n_fields += 1
|
|
58
|
+
if not n_fields:
|
|
59
|
+
out.append(" pass")
|
|
60
|
+
out.append("")
|
|
61
|
+
for m in re.finditer(r"type\s+(\w+)\s*=\s*([^;]+);", dts):
|
|
62
|
+
if m.group(1) not in dts:
|
|
63
|
+
out.append(f"{m.group(1)} = {_ts_to_py(m.group(2))}")
|
|
64
|
+
return "\n".join(out) if found else "\n".join(out)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class DtsField:
|
|
69
|
+
name: str
|
|
70
|
+
ts_type: str
|
|
71
|
+
py_type: str
|
|
72
|
+
optional: bool
|
|
73
|
+
nested: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class DtsInterface:
|
|
78
|
+
name: str
|
|
79
|
+
base: str | None
|
|
80
|
+
fields: list = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_INTERFACE_RE = None
|
|
84
|
+
_FIELD_RE = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_interface_re():
|
|
88
|
+
import re as _re
|
|
89
|
+
global _INTERFACE_RE
|
|
90
|
+
if _INTERFACE_RE is None:
|
|
91
|
+
_INTERFACE_RE = _re.compile(
|
|
92
|
+
r"interface\s+(?P<name>[A-Za-z_][\w]*)\s*"
|
|
93
|
+
r"(?:extends\s+(?P<base>[A-Za-z_][\w]*))?\s*\{(?P<body>[^}]*)\}",
|
|
94
|
+
_re.DOTALL)
|
|
95
|
+
return _INTERFACE_RE
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _get_field_re():
|
|
99
|
+
import re as _re
|
|
100
|
+
global _FIELD_RE
|
|
101
|
+
if _FIELD_RE is None:
|
|
102
|
+
_FIELD_RE = _re.compile(
|
|
103
|
+
r"(?P<name>[A-Za-z_][\w]*)(?P<opt>\?)?\s*:\s*(?P<type>[^;,\n]+)\s*;?,?\s*$")
|
|
104
|
+
return _FIELD_RE
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _ts_to_py_typed(ts_type, known):
|
|
108
|
+
import re as _re
|
|
109
|
+
_KNOWN_SCALARS = {"string": "str", "number": "float", "boolean": "bool",
|
|
110
|
+
"any": "Any", "unknown": "Any", "void": "None",
|
|
111
|
+
"null": "None", "undefined": "None", "object": "dict"}
|
|
112
|
+
t = ts_type.strip()
|
|
113
|
+
nested = None
|
|
114
|
+
|
|
115
|
+
def scalar(tok):
|
|
116
|
+
tok = tok.strip()
|
|
117
|
+
if (tok.startswith('"') and tok.endswith('"')) or (
|
|
118
|
+
tok.startswith("'") and tok.endswith("'")):
|
|
119
|
+
return "str"
|
|
120
|
+
if tok in _KNOWN_SCALARS:
|
|
121
|
+
return _KNOWN_SCALARS[tok]
|
|
122
|
+
if _re.fullmatch(r"-?\d+", tok):
|
|
123
|
+
return "int"
|
|
124
|
+
if _re.fullmatch(r"-?\d*\.\d+", tok):
|
|
125
|
+
return "float"
|
|
126
|
+
if tok in known:
|
|
127
|
+
return tok
|
|
128
|
+
return "Any"
|
|
129
|
+
|
|
130
|
+
m = _re.fullmatch(r"Array\s*<\s*(.+)\s*>", t)
|
|
131
|
+
if m:
|
|
132
|
+
inner, nested = _ts_to_py_typed(m.group(1), known)
|
|
133
|
+
return f"list[{inner}]", nested
|
|
134
|
+
while t.endswith("[]"):
|
|
135
|
+
t = t[:-2].strip()
|
|
136
|
+
inner, nested = _ts_to_py_typed(t, known)
|
|
137
|
+
return f"list[{inner}]", nested
|
|
138
|
+
if "|" in t:
|
|
139
|
+
parts = [p.strip() for p in t.split("|")]
|
|
140
|
+
parts = [p for p in parts if p not in ("undefined", "null")]
|
|
141
|
+
if parts and all((p.startswith('"') or p.startswith("'")) for p in parts):
|
|
142
|
+
return "str", None
|
|
143
|
+
if len(parts) == 1:
|
|
144
|
+
return _ts_to_py_typed(parts[0], known)
|
|
145
|
+
non_any = [scalar(p) for p in parts if scalar(p) != "Any"]
|
|
146
|
+
if len(non_any) == 1:
|
|
147
|
+
return non_any[0], (non_any[0] if non_any[0] in known else None)
|
|
148
|
+
return "Any", None
|
|
149
|
+
base = scalar(t)
|
|
150
|
+
if base in known:
|
|
151
|
+
nested = base
|
|
152
|
+
return base, nested
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parse_dts(source):
|
|
156
|
+
found = list(_get_interface_re().finditer(source))
|
|
157
|
+
known = {m.group("name") for m in found}
|
|
158
|
+
out = []
|
|
159
|
+
for m in found:
|
|
160
|
+
iface = DtsInterface(name=m.group("name"), base=m.group("base"))
|
|
161
|
+
for raw_line in m.group("body").splitlines():
|
|
162
|
+
line = raw_line.strip()
|
|
163
|
+
if not line or line.startswith("//"):
|
|
164
|
+
continue
|
|
165
|
+
fm = _get_field_re().match(line)
|
|
166
|
+
if not fm:
|
|
167
|
+
continue
|
|
168
|
+
py_type, nested = _ts_to_py_typed(fm.group("type"), known)
|
|
169
|
+
iface.fields.append(DtsField(
|
|
170
|
+
name=fm.group("name"), ts_type=fm.group("type").strip(),
|
|
171
|
+
py_type=py_type, optional=bool(fm.group("opt")), nested=nested))
|
|
172
|
+
out.append(iface)
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _coerce_typed(nested, py_type, value_expr):
|
|
177
|
+
if nested is None:
|
|
178
|
+
return value_expr
|
|
179
|
+
if py_type.startswith("list["):
|
|
180
|
+
return (f"[{nested}.from_dict(v) if isinstance(v, dict) else v "
|
|
181
|
+
f"for v in {value_expr}]")
|
|
182
|
+
return (f"{nested}.from_dict({value_expr}) "
|
|
183
|
+
f"if isinstance({value_expr}, dict) else {value_expr}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def generate_stub(interfaces):
|
|
187
|
+
lines = [
|
|
188
|
+
'"""Generated stubs - do not edit. Source: .d.ts via pyweb npm.',
|
|
189
|
+
"",
|
|
190
|
+
"Regenerate with: pyweb npm <file.d.ts> [-o out.py]",
|
|
191
|
+
'"""',
|
|
192
|
+
"from __future__ import annotations",
|
|
193
|
+
"",
|
|
194
|
+
"from dataclasses import dataclass",
|
|
195
|
+
"from typing import Any, Optional",
|
|
196
|
+
"",
|
|
197
|
+
"",
|
|
198
|
+
]
|
|
199
|
+
known = {i.name for i in interfaces}
|
|
200
|
+
for base in dict.fromkeys(i.base for i in interfaces if i.base and i.base not in known):
|
|
201
|
+
lines += ["@dataclass", f"class {base}:", " pass", "", ""]
|
|
202
|
+
for iface in interfaces:
|
|
203
|
+
base = f"({iface.base})" if iface.base else ""
|
|
204
|
+
lines.append("@dataclass")
|
|
205
|
+
lines.append(f"class {iface.name}{base}:")
|
|
206
|
+
fields = sorted(iface.fields, key=lambda f: f.optional)
|
|
207
|
+
if not fields and not iface.base:
|
|
208
|
+
lines.append(" pass")
|
|
209
|
+
else:
|
|
210
|
+
for f in fields:
|
|
211
|
+
ann = f"Optional[{f.py_type}]" if f.optional else f.py_type
|
|
212
|
+
default = " = None" if f.optional else ""
|
|
213
|
+
lines.append(f" {f.name}: {ann}{default}")
|
|
214
|
+
lines.append("")
|
|
215
|
+
lines.append(" @classmethod")
|
|
216
|
+
lines.append(f" def from_dict(cls, data: dict) -> {iface.name}:")
|
|
217
|
+
lines.append(" if not isinstance(data, dict):")
|
|
218
|
+
lines.append(f' raise TypeError(f"{iface.name} must be a dict")')
|
|
219
|
+
lines.append(" kwargs: dict[str, Any] = {}")
|
|
220
|
+
for f in fields:
|
|
221
|
+
if f.optional:
|
|
222
|
+
val = _coerce_typed(f.nested, f.py_type, f"data[{f.name!r}]")
|
|
223
|
+
lines.append(f" if data.get({f.name!r}) is not None:")
|
|
224
|
+
lines.append(f" kwargs[{f.name!r}] = {val}")
|
|
225
|
+
else:
|
|
226
|
+
val = _coerce_typed(f.nested, f.py_type, "_v")
|
|
227
|
+
lines.append(f" if {f.name!r} not in data:")
|
|
228
|
+
lines.append(f' raise TypeError(f"{iface.name} missing required field: {f.name}")')
|
|
229
|
+
lines.append(f" _v = data[{f.name!r}]")
|
|
230
|
+
lines.append(f" kwargs[{f.name!r}] = {val}")
|
|
231
|
+
lines.append(" try:")
|
|
232
|
+
lines.append(" return cls(**kwargs)")
|
|
233
|
+
lines.append(" except TypeError as exc:")
|
|
234
|
+
lines.append(f' raise TypeError(f"invalid {iface.name}: {{exc}}") from exc')
|
|
235
|
+
lines.append("")
|
|
236
|
+
lines.append("")
|
|
237
|
+
lines.append(f"def validate_{iface.name}(data: dict) -> {iface.name}:")
|
|
238
|
+
lines.append(f' """Validate a dict against {iface.name}; raise TypeError if invalid."""')
|
|
239
|
+
lines.append(f" return {iface.name}.from_dict(data)")
|
|
240
|
+
lines.append("")
|
|
241
|
+
lines.append("")
|
|
242
|
+
return "\n".join(lines).rstrip("\n") + "\n"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def generate_stubs_from_file(dts_path, out_path=None):
|
|
246
|
+
source = Path(dts_path).read_text(encoding="utf-8")
|
|
247
|
+
code = generate_stub(parse_dts(source))
|
|
248
|
+
if out_path is not None:
|
|
249
|
+
Path(out_path).write_text(code, encoding="utf-8")
|
|
250
|
+
return code
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def npm_main(argv=None):
|
|
254
|
+
import argparse
|
|
255
|
+
ap = argparse.ArgumentParser(prog="pyweb npm",
|
|
256
|
+
description="Generate typed Python stubs from .d.ts")
|
|
257
|
+
ap.add_argument("dts", help="Input .d.ts file")
|
|
258
|
+
ap.add_argument("-o", "--out", default=None,
|
|
259
|
+
help="Write stubs to file (default: stdout)")
|
|
260
|
+
args = ap.parse_args(argv)
|
|
261
|
+
try:
|
|
262
|
+
code = generate_stubs_from_file(args.dts, args.out)
|
|
263
|
+
except FileNotFoundError:
|
|
264
|
+
print(f"npm: {args.dts}: file not found")
|
|
265
|
+
return 1
|
|
266
|
+
if args.out:
|
|
267
|
+
print(f"npm: wrote {args.out}")
|
|
268
|
+
else:
|
|
269
|
+
print(code, end="")
|
|
270
|
+
return 0
|