labull-framework 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 (40) hide show
  1. labull_framework/__init__.py +8 -0
  2. labull_framework/admin.py +63 -0
  3. labull_framework/apps.py +33 -0
  4. labull_framework/auth/__init__.py +4 -0
  5. labull_framework/auth/backend.py +144 -0
  6. labull_framework/auth/bootstrap.py +47 -0
  7. labull_framework/auth/sso.py +259 -0
  8. labull_framework/auth/views.py +184 -0
  9. labull_framework/canvas/__init__.py +19 -0
  10. labull_framework/canvas/data.py +523 -0
  11. labull_framework/canvas/gitinfo.py +155 -0
  12. labull_framework/canvas/static/canvas.css +487 -0
  13. labull_framework/canvas/static/canvas.js +1036 -0
  14. labull_framework/canvas/static/page.html +52 -0
  15. labull_framework/canvas/views.py +121 -0
  16. labull_framework/env.py +187 -0
  17. labull_framework/jsonio.py +57 -0
  18. labull_framework/magic_block/__init__.py +2 -0
  19. labull_framework/magic_block/devkit.py +428 -0
  20. labull_framework/magic_block/dispatch.py +314 -0
  21. labull_framework/magic_block/export.py +773 -0
  22. labull_framework/magic_block/health.py +220 -0
  23. labull_framework/magic_block/registry.py +157 -0
  24. labull_framework/magic_block/sdk.py +245 -0
  25. labull_framework/magic_block/service.py +405 -0
  26. labull_framework/magic_block/validate.py +558 -0
  27. labull_framework/magic_block/views.py +407 -0
  28. labull_framework/management/__init__.py +0 -0
  29. labull_framework/management/commands/__init__.py +0 -0
  30. labull_framework/management/commands/labull_contract.py +52 -0
  31. labull_framework/migrations/0001_initial.py +71 -0
  32. labull_framework/migrations/__init__.py +0 -0
  33. labull_framework/models.py +76 -0
  34. labull_framework/settings.py +223 -0
  35. labull_framework/urls.py +93 -0
  36. labull_framework-0.1.0.dist-info/METADATA +140 -0
  37. labull_framework-0.1.0.dist-info/RECORD +40 -0
  38. labull_framework-0.1.0.dist-info/WHEEL +5 -0
  39. labull_framework-0.1.0.dist-info/licenses/LICENSE +28 -0
  40. labull_framework-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,558 @@
1
+ """
2
+ "什么代码能入库"的**唯一判据**:包结构 / 清单 / 文件名 / 前端脚本,以及块后端的 `ast` 静态判据
3
+ 与**子进程试加载**。保存(`service.save`)与体检(`health.evaluate`)都只调这里 —— 判据写两处必然漂,
4
+ 而"保存说合法、体检说非法"是最难查的一类事故。
5
+
6
+ 🔴 试加载在**子进程**里:那份代码是使用者写的,顶层死循环 / 主动 `os._exit` 会带走整个服务进程。
7
+ 超时(10 秒)**不算通过** —— "不知道"不等于"没问题"。
8
+ 🔴 这里判的**只是库里的数据**(这一包要不要存进去),不是使用者的项目代码。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import json
15
+ import os
16
+ import re
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from .sdk import BLOCK_BACKEND_FILE, BLOCK_ENTRY, BLOCK_MANIFEST, LIB_SDK
22
+
23
+ # ──────────────────────────── 包结构与清单的判据 ────────────────────────────
24
+
25
+ #: 清单只认这四个键,全部必填(顺序就是文档里的顺序)。
26
+ MANIFEST_KEYS: tuple[str, ...] = ('id', 'module', 'name', 'brief')
27
+
28
+ #: 包里文件名的合法形状:只许字母数字与 `. _ - /`;不许绝对路径、不许 `..` 段。
29
+ FILE_NAME_PATTERN = re.compile(r'^(?!/)(?![A-Za-z]:)[A-Za-z0-9._\-/]+$')
30
+
31
+ #: 可读名(清单里的 `name`):允许中文,但不许空、不超 40 字符、不含控制字符。
32
+ NAME_MAX = 40
33
+ NAME_PATTERN = re.compile(r'^(?=[\s\S]*\S)[^\u0000-\u001f\u007f]{1,40}$')
34
+
35
+ #: 清单里 `id` 的形状:uuid(8-4-4-4-12)。
36
+ BLOCK_ID_PATTERN = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.I)
37
+
38
+ #: 清单里 `module` 的形状。🔴 它同时是 Django 的 app label(`INSTALLED_APPS` 里那一段),
39
+ #: 所以只许小写字母数字、必须以字母开头 —— 横线与下划线在那里当不了标识符。
40
+ MODULE_PATTERN = re.compile(r'^[a-z][a-z0-9]*$')
41
+
42
+ #: 清单里的键名形状。
43
+ MANIFEST_KEY_PATTERN = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
44
+
45
+ # ──────────────────────────── 块后端的 ast 判据 ────────────────────────────
46
+ #
47
+ # 🔴 **块里的 import 不做任何检查**:这个 Django 项目里能 import 的,块里就能 import。
48
+ # 没有白名单、没有禁止清单 —— 那种清单要么漏、要么挡住正常写法(`from django.utils import timezone`
49
+ # 这种写法凭什么不许?),而且它维护的是一份永远不完整的模块名单。
50
+ # 🔴 **只有 `import` 才是真的关联**:块里写了什么,就在这个块被加载时才真的加载它。
51
+ # ⚠️ **唯一还隔着的一层是配置**:环境变量里只有带 `BLOCK_` 前缀的能进 `ctx.config`(见 `dispatch`)。
52
+ # 🔴 **这不是安全沙箱**:`backend_open.py` 是管理员写进库里的数据。下面这些 `ast` 判据要的是
53
+ # "这包能不能被分发器正确执行"(有没有那条 SDK import、方法与装饰器形状对不对),
54
+ # 以及"模块级别在加载时就动数据";试加载(`trial_load`)负责把拼错的模块名 / 语法错当场报出来。
55
+
56
+ #: 那条 SDK import 里能取的名字(就这两个)。
57
+ SDK_NAMES: tuple[str, ...] = ('MagicBackendAPI', 'errors')
58
+
59
+ #: 🔴 唯一还拦的两个内置:它们在块里**造出新的代码**(于是"这份正文长什么样"就不再是全部事实)。
60
+ #: 别的调用一律不拦 —— 块后端与项目里别的 Python 代码没有区别。
61
+ _NO_DYNAMIC_CODE: frozenset[str] = frozenset({'eval', 'exec'})
62
+
63
+ #: 试加载的子进程超时(秒)。判据就是这个数。
64
+ TRIAL_LOAD_TIMEOUT = 10
65
+
66
+ #: 试加载脚本:真的 import 那份 SDK 模块、把块正文当模块执行一次。
67
+ #: 🔴 块正文走**环境变量里的 JSON**,不往脚本里拼字符串(正文里一个引号就能把脚本本身改坏)。
68
+ #: ⚠️ 这里**不做任何替身**:块 import 什么就真的 import 什么 —— 块能用的东西与项目里别的代码一模一样。
69
+ _TRIAL_SCRIPT = (
70
+ 'import json, os, sys\n'
71
+ 'SOURCE = json.loads(os.environ["LABULL_TRIAL_SOURCE"])\n'
72
+ 'try:\n'
73
+ ' exec(compile(SOURCE, "backend_open.py", "exec"), {"__name__": "backend_open"})\n'
74
+ 'except Exception as exc:\n'
75
+ ' sys.stderr.write(f"{type(exc).__name__}: {exc}")\n'
76
+ ' sys.exit(1)\n'
77
+ )
78
+
79
+
80
+ # ──────────────────────────── 清单(零依赖手写解析) ────────────────────────────
81
+
82
+
83
+ def _strip_comment(line: str) -> str:
84
+ """去掉一行里的注释:`#` 之后是注释,但**引号内**的 `#` 是正文(`name: 'a # b'`)。"""
85
+ quote = ''
86
+ for index, char in enumerate(line):
87
+ if quote:
88
+ if char == quote:
89
+ quote = ''
90
+ elif char in ('"', "'"):
91
+ quote = char
92
+ elif char == '#':
93
+ return line[:index]
94
+ return line
95
+
96
+
97
+ def _separator_at(line: str) -> int:
98
+ """第一个**不在引号里**的 `:`(「键: 值」的分隔符);没有就回 -1。"""
99
+ quote = ''
100
+ for index, char in enumerate(line):
101
+ if quote:
102
+ if char == quote:
103
+ quote = ''
104
+ elif char in ('"', "'"):
105
+ quote = char
106
+ elif char == ':':
107
+ return index
108
+ return -1
109
+
110
+
111
+ def _unquote(value: str) -> str:
112
+ """去掉值两侧**成对**的引号;不成对的原样留着,让后面的判据说它不合法。"""
113
+ if not value or value[0] not in ('"', "'"):
114
+ return value
115
+ if len(value) >= 2 and value[-1] == value[0]:
116
+ return value[1:-1].strip()
117
+ return value
118
+
119
+
120
+ def _now_in(files: dict[str, str]) -> str:
121
+ """包里现在有哪些文件(写进报文:缺了什么、手上有什么,一眼看得出)。"""
122
+ names = list(files)
123
+ return f'现在包里有:{" / ".join(names)}' if names else '现在这个包是空的'
124
+
125
+
126
+ def parse_manifest(text: str) -> tuple[dict[str, str], list[str]]:
127
+ """解析清单正文:`(认得的字段, 逐条问题)`。
128
+
129
+ 🔴 不引 YAML 库:校验器跑在保存路径上,多一个依赖就多一处"装不上就存不了"。
130
+ 🔴 不认识的写法(列表 / 嵌套 / 没有冒号 / 不认识的键 / 重复键)**各报一条**,绝不静默忽略 ——
131
+ 静默的后果是"块没有身份"这种错要到运行期才现形。
132
+ """
133
+ issues: list[str] = []
134
+ found: dict[str, str] = {}
135
+ seen_at: dict[str, int] = {}
136
+
137
+ for index, raw in enumerate(text.splitlines()):
138
+ line_no = index + 1
139
+ line = _strip_comment(raw).strip()
140
+ if not line:
141
+ continue
142
+ if line.startswith('-'):
143
+ issues.append(
144
+ f'第 {line_no} 行是列表写法({line!r}):清单只认「键: 值」,四个键各占一行 —— '
145
+ '例如 name: 客户档案'
146
+ )
147
+ continue
148
+ at = _separator_at(line)
149
+ if at < 0:
150
+ issues.append(
151
+ f'第 {line_no} 行不是「键: 值」({line!r}):清单只认「键: 值」与 # 注释 —— '
152
+ '要么删掉这一行,要么照 name: 客户档案 写'
153
+ )
154
+ continue
155
+ key = line[:at].strip()
156
+ if not MANIFEST_KEY_PATTERN.match(key):
157
+ issues.append(
158
+ f'第 {line_no} 行的键名不合法({key!r}):键名只许字母或下划线开头,'
159
+ f'认得的键只有 {" / ".join(MANIFEST_KEYS)}'
160
+ )
161
+ continue
162
+ if key not in MANIFEST_KEYS:
163
+ issues.append(
164
+ f'第 {line_no} 行的键不认识({key}):清单只有 {" / ".join(MANIFEST_KEYS)} 这四个键'
165
+ )
166
+ continue
167
+ if key in seen_at:
168
+ issues.append(f'第 {line_no} 行重复写了 {key}(第 {seen_at[key]} 行已经写过):一个键只许出现一次')
169
+ continue
170
+ seen_at[key] = line_no
171
+ found[key] = _unquote(line[at + 1:].strip())
172
+
173
+ hints = {
174
+ 'id': '块的 uuid,必须与页面里挂载它时写死的那个 uuid 逐字一致',
175
+ 'module': '模块名(小写字母与数字,必须以字母开头),例如 home',
176
+ 'name': f'可读名:可以用中文,不超过 {NAME_MAX} 个字符',
177
+ 'brief': '一句话说明这个块解决什么问题',
178
+ }
179
+ for key in MANIFEST_KEYS:
180
+ value = found.get(key)
181
+ if value is None or value == '':
182
+ issues.append(f'清单里缺少 {key}(或者是空的):{hints[key]}')
183
+ continue
184
+ if key == 'id' and not BLOCK_ID_PATTERN.match(value):
185
+ issues.append(f'清单里的 id 不是 uuid:{value!r} —— 要 8-4-4-4-12 那种十六进制')
186
+ elif key == 'module' and not MODULE_PATTERN.match(value):
187
+ issues.append(
188
+ f'清单里的 module 不合法:{value!r} —— 它同时是 Django 的 app label,'
189
+ '所以只许小写字母与数字、必须以字母开头(不能有横线与下划线),例如 home / orders'
190
+ )
191
+ elif key == 'name' and not NAME_PATTERN.match(value):
192
+ issues.append(
193
+ f'清单里的 name 不合法:{value!r} —— 不能超过 {NAME_MAX} 个字符、不能含控制字符(中文可以)'
194
+ )
195
+ return found, issues
196
+
197
+
198
+ def read_manifest(files: dict[str, str]) -> tuple[dict[str, str] | None, list[str]]:
199
+ """从包读清单:**齐备且合法**才给 manifest,否则 `None` + 逐条问题。
200
+
201
+ 🔴 清单缺失 / 为空也在这里报(它是必备件,调用方不必自己再判一次)。
202
+ """
203
+ if BLOCK_MANIFEST not in files:
204
+ return None, [f'包里缺清单:必须有 {BLOCK_MANIFEST}({_now_in(files)})']
205
+ text = files.get(BLOCK_MANIFEST) or ''
206
+ if not text.strip():
207
+ return None, [f'{BLOCK_MANIFEST} 不能是空文件']
208
+ manifest, issues = parse_manifest(text)
209
+ if issues:
210
+ return None, issues
211
+ return manifest, []
212
+
213
+
214
+ def _valid_file_name(name: str) -> bool:
215
+ """包内文件名合法吗(形状 + 不许 `..` 段)。"""
216
+ return bool(FILE_NAME_PATTERN.match(name)) and '..' not in str(name).split('/')
217
+
218
+
219
+ def check_files(files: dict[str, str]) -> list[tuple[str, str]]:
220
+ """包结构那一半:入口在不在、清单读不读得出、文件名许不许、有没有动态执行。
221
+
222
+ 返回 `[(stage, issue)]`:stage 走 `health` 落库时要用(见 `health.CHECKS`)。
223
+ """
224
+ out: list[tuple[str, str]] = []
225
+ if BLOCK_ENTRY not in files:
226
+ out.append(('files', f'包里缺入口:必须有 {BLOCK_ENTRY}({_now_in(files)})'))
227
+ elif not (files.get(BLOCK_ENTRY) or '').strip():
228
+ out.append(('files', f'{BLOCK_ENTRY} 不能是空文件'))
229
+ for message in read_manifest(files)[1]:
230
+ out.append(('files', message))
231
+ for name in files:
232
+ if not _valid_file_name(name):
233
+ out.append(('files', f'文件名不合法:{name!r}(只许字母数字与 . _ - /,且不许 .. 与绝对路径)'))
234
+ # 🔴 到此为止:块前端那些文件(`.html` / `.js` / `.css`)的正文一个字都不看 ——
235
+ # 前端代码合不合格是作者与前端那一侧的事,服务端保存时只判"这包能不能跑"。
236
+ return out
237
+
238
+
239
+ # ──────────────────────────── 块后端的静态判据 ────────────────────────────
240
+
241
+
242
+ def _line_of(node: ast.AST) -> int:
243
+ """这个节点在第几行(报文要能对上编辑器里的行号)。"""
244
+ return getattr(node, 'lineno', 0) or 0
245
+
246
+
247
+ def _banned_call(node: ast.AST) -> str:
248
+ """这一处调用命中了哪条禁令(没命中回空串)。
249
+
250
+ 🔴 **只拦"在块里制造代码"的那两个内置**(`eval` / `exec`)。别的调用一概不拦 ——
251
+ 块后端与项目里别的 Python 代码没有区别:`from django.utils import timezone`、
252
+ `Order.objects.filter(...)`、`requests.get(...)` 全是正常写法。
253
+ "哪些模块不许 import"那种名单已经不做了(见文件头)。
254
+ ⚠️ 只认**调用**:读一个属性没有副作用,报了只会是假错。
255
+ """
256
+ if isinstance(node, ast.Name) and node.id in _NO_DYNAMIC_CODE:
257
+ return node.id
258
+ return ''
259
+
260
+
261
+ def _has_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
262
+ """这个方法上挂了 `@MagicBackendAPI`(带不带参数都算)吗。"""
263
+ for decorator in node.decorator_list:
264
+ target = decorator.func if isinstance(decorator, ast.Call) else decorator
265
+ if isinstance(target, ast.Name) and target.id == 'MagicBackendAPI':
266
+ return True
267
+ return False
268
+
269
+
270
+ def _method_issues(file: str, cls: ast.ClassDef, node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
271
+ """一个方法的判据:带装饰器、是同步的、签名是 `(self, ctx, params)`;`__init__` 里不许有调用。"""
272
+ issues: list[str] = []
273
+ where = f'{file} 第 {_line_of(node)} 行({cls.name}.{node.name})'
274
+
275
+ if isinstance(node, ast.AsyncFunctionDef):
276
+ issues.append(f'{where}:不要写成 async def —— 分发器按同步调用它,异步只会回一个协程对象')
277
+ if not _has_decorator(node):
278
+ issues.append(f'{where}:没有 @MagicBackendAPI,所以它**不是路由**(路径从装饰器来,不是从"是 public 方法"来)')
279
+
280
+ if node.name == '__init__':
281
+ for child in ast.walk(node):
282
+ if isinstance(child, ast.Call):
283
+ issues.append(f'{where}:__init__ 里不许有任何调用(它只用来赋常量,副作用会在加载时发生)')
284
+ break
285
+ return issues
286
+
287
+ args = node.args
288
+ positional = [*args.posonlyargs, *args.args]
289
+ if args.vararg or args.kwarg:
290
+ issues.append(f'{where}:签名不要写 *args / **kwargs(参数就是 ctx 与 params 两个)')
291
+ if len(positional) != 3:
292
+ issues.append(
293
+ f'{where}:签名要写成 def {node.name}(self, ctx, params) —— 就两个参数'
294
+ f'(ctx 与 params),现在读到 {max(len(positional) - 1, 0)} 个'
295
+ )
296
+ elif args.defaults or args.kwonlyargs:
297
+ issues.append(f'{where}:参数不许带默认值、不许有关键字参数(就 self, ctx, params)')
298
+ return issues
299
+
300
+
301
+ def check_backend(src: str) -> list[tuple[str, str]]:
302
+ """块后端正文的 `ast` 判据 + 子进程试加载。返回 `[(stage, issue)]`。
303
+
304
+ 判据顺序(也就是报文顺序):模块级语句形状 → import 形状与那条 SDK import → 类体与方法 →
305
+ 危险调用(整棵树扫) → 试加载(前面都过了才跑)。
306
+
307
+ 🔴 **不看 import 的是哪个模块**(见本文件头部):项目里能 import 的,块里就能 import。
308
+ """
309
+ file = BLOCK_BACKEND_FILE
310
+ if not str(src or '').strip():
311
+ return [('backend', f'{file} 不能是空文件(要么不写这个文件,要么写一个类)')]
312
+
313
+ try:
314
+ tree = ast.parse(src, filename=file)
315
+ except SyntaxError as exc:
316
+ return [('syntax', f'{file} 第 {exc.lineno or 0} 行第 {exc.offset or 0} 列有语法错:{exc.msg}')]
317
+
318
+ issues: list[tuple[str, str]] = []
319
+
320
+ # ① 模块级只许 import / class / __all__ / docstring。
321
+ # 🔴 `ast.Expr` 必须**显式分叉成"只许字符串常量"**:整体放行 `Expr` 就是漏洞 ——
322
+ # 任何调用都是 `Expr`,而那段代码会在**别人的进程**里执行。
323
+ for node in tree.body:
324
+ if isinstance(node, ast.Expr):
325
+ if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
326
+ continue
327
+ issues.append(
328
+ (
329
+ 'backend',
330
+ f'{file} 第 {_line_of(node)} 行:模块级只许 import / class / docstring / __all__ —— '
331
+ '这一行是一个表达式,它会在加载这个块时真的执行;要写逻辑请写进类的方法里',
332
+ )
333
+ )
334
+ continue
335
+ if isinstance(node, ast.Assign):
336
+ if [t.id for t in node.targets if isinstance(t, ast.Name)] == ['__all__']:
337
+ continue
338
+ issues.append(
339
+ (
340
+ 'backend',
341
+ f'{file} 第 {_line_of(node)} 行:模块级不许赋值 / 不许调用(只有 __all__ 例外)—— '
342
+ '模块级副作用会在保存试加载时就动到数据',
343
+ )
344
+ )
345
+ continue
346
+ if not isinstance(node, (ast.Import, ast.ImportFrom, ast.ClassDef)):
347
+ issues.append(
348
+ (
349
+ 'backend',
350
+ f'{file} 第 {_line_of(node)} 行:模块级只许 import / class / docstring / __all__ —— '
351
+ '要写逻辑请写进类的方法里',
352
+ )
353
+ )
354
+
355
+ # ② import 的**形状**(不看模块名):那条 SDK import 必须在、只许一条、不许 `as` 改名。
356
+ imports = [node for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom))]
357
+ sdk_lines = [node for node in imports if isinstance(node, ast.ImportFrom) and (node.module or '') == LIB_SDK]
358
+ if not sdk_lines:
359
+ issues.append(
360
+ ('backend', f'{file} 里必须有一条 from {LIB_SDK} import MagicBackendAPI —— 没有它就没有任何接口')
361
+ )
362
+ if len(sdk_lines) > 1:
363
+ lines = ' / '.join(str(_line_of(node)) for node in sdk_lines)
364
+ issues.append(
365
+ (
366
+ 'backend',
367
+ f'{file} 第 {_line_of(sdk_lines[1])} 行:那条 SDK import 只许写**一条**(现在有 '
368
+ f'{len(sdk_lines)} 条,第 {lines} 行)—— 要别的名字就并进同一条',
369
+ )
370
+ )
371
+ for node in imports:
372
+ for alias in node.names:
373
+ if alias.asname:
374
+ issues.append(
375
+ (
376
+ 'backend',
377
+ f'{file} 第 {_line_of(node)} 行:import 不许 as 改名(收到的是 '
378
+ f'{alias.name} as {alias.asname})—— 名字与契约逐字对应,改名等于把代码改坏',
379
+ )
380
+ )
381
+ for node in sdk_lines:
382
+ for alias in node.names:
383
+ if alias.name not in SDK_NAMES:
384
+ issues.append(
385
+ (
386
+ 'backend',
387
+ f'{file} 第 {_line_of(node)} 行:SDK 里没有 {alias.name} —— '
388
+ f'能 import 的只有 {" / ".join(SDK_NAMES)}',
389
+ )
390
+ )
391
+
392
+ # ③ 类体只许方法;方法逐条判。
393
+ classes = [node for node in tree.body if isinstance(node, ast.ClassDef)]
394
+ if not classes:
395
+ issues.append(
396
+ (
397
+ 'backend',
398
+ f'{file} 里必须有一个类(类名 + 方法名 = 路由名,例如 Customer.create)—— '
399
+ '模块级函数认不出来',
400
+ )
401
+ )
402
+ for cls in classes:
403
+ for node in cls.body:
404
+ if isinstance(node, ast.Expr):
405
+ if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
406
+ continue
407
+ issues.append(
408
+ (
409
+ 'backend',
410
+ f'{file} 第 {_line_of(node)} 行(类 {cls.name} 里):类体里只许写方法 —— '
411
+ '这一行是表达式,它会在类定义时执行',
412
+ )
413
+ )
414
+ continue
415
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
416
+ for message in _method_issues(file, cls, node):
417
+ issues.append(('backend', message))
418
+ continue
419
+ issues.append(
420
+ (
421
+ 'backend',
422
+ f'{file} 第 {_line_of(node)} 行(类 {cls.name} 里):类体里只许写方法'
423
+ '(不许类变量、不许嵌套类)—— 常量写在方法里',
424
+ )
425
+ )
426
+
427
+ # ④ 在块里造代码的那两个内置(`eval` / `exec`):整棵树扫一遍(不只是方法体)。
428
+ for node in ast.walk(tree):
429
+ if isinstance(node, ast.Call):
430
+ hit = _banned_call(node.func)
431
+ if hit:
432
+ issues.append(
433
+ (
434
+ 'backend',
435
+ f'{file} 第 {_line_of(node)} 行:不许调用 {hit} —— '
436
+ '块代码是运行时加载的数据,不要在块里再拼一段代码出来',
437
+ )
438
+ )
439
+
440
+ # ⑤ 前面都过了才真的加载一次(装饰器求值会不会炸,只有跑一遍才知道)。
441
+ if not issues:
442
+ for message in trial_load(src):
443
+ issues.append(('backend', message))
444
+ return issues
445
+
446
+
447
+ def trial_load(src: str) -> list[str]:
448
+ """在子进程里把这份块后端加载一次(顶层 + 装饰器求值,handler 一次都不调)。
449
+
450
+ 🔴 超时(10 秒)**不算通过**:那是"不知道",不是"没问题"。
451
+ 🔴 这条判据的价值在**及时报错**:块里 `import` 了一个项目里没装的包、拼错了模块名、
452
+ 装饰器参数写错 —— 都在保存那一刻报出来,而不是等到某次请求炸。
453
+ """
454
+ env = dict(os.environ)
455
+ # 🔴 把父进程的 sys.path 交过去:子进程默认的 path 里没有项目根,
456
+ # 不交的话每个块都会报"找不到 labull_framework / modules"——那是假错。
457
+ env['PYTHONPATH'] = os.pathsep.join(item for item in sys.path if item)
458
+ env['LABULL_TRIAL_SOURCE'] = json.dumps(src, ensure_ascii=False)
459
+ try:
460
+ done = subprocess.run(
461
+ [sys.executable, '-c', _TRIAL_SCRIPT],
462
+ env=env,
463
+ capture_output=True,
464
+ text=True,
465
+ timeout=TRIAL_LOAD_TIMEOUT,
466
+ )
467
+ except subprocess.TimeoutExpired:
468
+ return [f'{BLOCK_BACKEND_FILE} 试加载超过 {TRIAL_LOAD_TIMEOUT} 秒没有结束,已中止(顶层不许做耗时的事)']
469
+ except OSError as exc:
470
+ return [f'{BLOCK_BACKEND_FILE} 试加载跑不起来({exc}):找不到可用的 Python 解释器']
471
+
472
+ if done.returncode == 0:
473
+ return []
474
+ lines = (done.stderr or '').strip().splitlines()
475
+ return [f'{BLOCK_BACKEND_FILE} 加载失败(顶层与装饰器不许出错):{lines[-1] if lines else done.returncode}']
476
+
477
+
478
+ # ──────────────────────────── 路由名(唯一一处扫它) ────────────────────────────
479
+
480
+
481
+ def _tree_of(src: str) -> ast.Module | None:
482
+ """把块后端正文解析成语法树(空 / 语法错 ⇒ `None`,由 `check_backend` 去报那一条)。"""
483
+ if not str(src or '').strip():
484
+ return None
485
+ try:
486
+ return ast.parse(str(src), filename=BLOCK_BACKEND_FILE)
487
+ except SyntaxError:
488
+ return None
489
+
490
+
491
+ def routes_of(src: str) -> list[str]:
492
+ """从 `ast` 读出的 `类名.方法名` 列表(**不执行任何代码**)。
493
+
494
+ 🔴 这是**唯一一处**扫路由名的地方:保存校验的引用判据、`api.open.d.ts` 的正文、
495
+ 以及运行期那张真实路由表(`registry.py`)都以此为准。
496
+ """
497
+ tree = _tree_of(src)
498
+ if tree is None:
499
+ return []
500
+ out: list[str] = []
501
+ for cls in [node for node in tree.body if isinstance(node, ast.ClassDef)]:
502
+ for node in cls.body:
503
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
504
+ continue
505
+ if _has_decorator(node):
506
+ out.append(f'{cls.name}.{node.name}')
507
+ return out
508
+
509
+
510
+ def routes_meta_of(src: str) -> dict[str, str]:
511
+ """`类名.方法名` → 声明的权限(读 `@MagicBackendAPI(permission=…)`)。
512
+
513
+ ⚠️ 只认字符串字面量;省略 / 算出来的值一律记成空串(= 只要登录)。
514
+ """
515
+ tree = _tree_of(src)
516
+ if tree is None:
517
+ return {}
518
+ out: dict[str, str] = {}
519
+ for cls in [node for node in tree.body if isinstance(node, ast.ClassDef)]:
520
+ for node in cls.body:
521
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
522
+ continue
523
+ if not _has_decorator(node):
524
+ continue
525
+ permission = ''
526
+ for decorator in node.decorator_list:
527
+ if not isinstance(decorator, ast.Call):
528
+ continue
529
+ target = decorator.func
530
+ if not (isinstance(target, ast.Name) and target.id == 'MagicBackendAPI'):
531
+ continue
532
+ for keyword in decorator.keywords:
533
+ if keyword.arg == 'permission' and isinstance(keyword.value, ast.Constant):
534
+ value = keyword.value.value
535
+ permission = value if isinstance(value, str) else ''
536
+ out[f'{cls.name}.{node.name}'] = permission
537
+ return out
538
+
539
+
540
+ # ──────────────────────────── 调用方要的那几件 ────────────────────────────
541
+
542
+
543
+ def check_block(files: dict[str, str]) -> list[tuple[str, str]]:
544
+ """一整个块包的判据:包结构 + **块后端**。返回 `[(stage, issue)]`。
545
+
546
+ 🔴 **只校验服务端那一半**:块的前端代码是作者自己的东西,保存时一个字都不看 ——
547
+ 前端该不该合格由前端自己负责(它的编辑器 / 控制台那一侧)。
548
+ """
549
+ files = files or {}
550
+ src = files.get(BLOCK_BACKEND_FILE)
551
+ out = check_files(files)
552
+ if src is not None:
553
+ if not isinstance(src, str):
554
+ out.append(('backend', f'{BLOCK_BACKEND_FILE} 的正文必须是文本'))
555
+ else:
556
+ out.extend(check_backend(src))
557
+ return out
558
+