labull-framework 0.1.35__tar.gz → 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.
Files changed (57) hide show
  1. {labull_framework-0.1.35 → labull_framework-1.0.0}/MANIFEST.in +3 -1
  2. {labull_framework-0.1.35/labull_framework.egg-info → labull_framework-1.0.0}/PKG-INFO +7 -3
  3. {labull_framework-0.1.35 → labull_framework-1.0.0}/README-pypi.md +5 -1
  4. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/env.py +18 -1
  5. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/magic_block/blockpackage.py +4 -1
  6. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/magic_block/deepcheck.py +231 -3
  7. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/magic_block/devkit.py +1734 -1202
  8. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/magic_block/dispatch.py +11 -31
  9. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/magic_block/export.py +80 -42
  10. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/magic_block/health.py +49 -20
  11. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/magic_block/parallel.py +2 -2
  12. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/magic_block/registry.py +22 -22
  13. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/magic_block/sdk.py +44 -1
  14. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/magic_block/service.py +108 -31
  15. labull_framework-1.0.0/labull_framework/magic_block/validate.py +2042 -0
  16. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/magic_block/views.py +19 -33
  17. labull_framework-1.0.0/labull_framework/management/commands/labull_requires.py +86 -0
  18. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/models.py +4 -3
  19. {labull_framework-0.1.35 → labull_framework-1.0.0/labull_framework.egg-info}/PKG-INFO +7 -3
  20. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework.egg-info/SOURCES.txt +1 -0
  21. {labull_framework-0.1.35 → labull_framework-1.0.0}/pyproject.toml +4 -2
  22. labull_framework-0.1.35/labull_framework/magic_block/validate.py +0 -998
  23. {labull_framework-0.1.35 → labull_framework-1.0.0}/LICENSE +0 -0
  24. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/__init__.py +0 -0
  25. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/admin.py +0 -0
  26. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/apps.py +0 -0
  27. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/auth/__init__.py +0 -0
  28. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/auth/backend.py +0 -0
  29. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/auth/bootstrap.py +0 -0
  30. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/auth/sso.py +0 -0
  31. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/auth/views.py +0 -0
  32. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/canvas/__init__.py +0 -0
  33. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/canvas/data.py +0 -0
  34. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/canvas/gitinfo.py +0 -0
  35. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/canvas/shape_ast.py +0 -0
  36. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/canvas/static/canvas.css +0 -0
  37. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/canvas/static/canvas.js +0 -0
  38. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/canvas/static/page.html +0 -0
  39. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/canvas/views.py +0 -0
  40. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/jsonio.py +0 -0
  41. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/magic_block/__init__.py +0 -0
  42. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/magic_block/subproc.py +0 -0
  43. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/management/__init__.py +0 -0
  44. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/management/commands/__init__.py +0 -0
  45. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/management/commands/labull_contract.py +0 -0
  46. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/migrations/0001_initial.py +0 -0
  47. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/migrations/0002_remove_magicblock_prev_files_and_more.py +0 -0
  48. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/migrations/0003_magicblock_tags.py +0 -0
  49. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/migrations/0004_magicblock_detail.py +0 -0
  50. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/migrations/__init__.py +0 -0
  51. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/settings.py +0 -0
  52. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/urls.py +0 -0
  53. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework/virtual.py +0 -0
  54. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework.egg-info/dependency_links.txt +0 -0
  55. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework.egg-info/requires.txt +0 -0
  56. {labull_framework-0.1.35 → labull_framework-1.0.0}/labull_framework.egg-info/top_level.txt +0 -0
  57. {labull_framework-0.1.35 → labull_framework-1.0.0}/setup.cfg +0 -0
@@ -18,5 +18,7 @@ include pyproject.toml
18
18
 
19
19
  # 包内数据(`canvas/views.py` 运行期真的会读这三个)。
20
20
  recursive-include labull_framework/canvas/static *.css *.js *.html
21
- recursive-include labull_framework *.pyi py.typed
22
21
  recursive-include labull_framework/migrations *.py
22
+ # ⚠️ 这里曾经有一行 `recursive-include labull_framework *.pyi py.typed` —— 已删:
23
+ # 库里一个 `*.pyi` / `py.typed` 都没有(`backend.lock.pyi` 是**导出时生成、只落进用户开发目录**的),
24
+ # `pyproject.toml` 那边也写着"刻意不加 py.typed"。一行匹配不到任何东西的规则只是噪声。
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 0.1.35
3
+ Version: 1.0.0
4
4
  Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
5
5
  Author: Labull Framework
6
6
  License-Expression: MIT
7
7
  Keywords: django,authing,oidc,sso,magic-block
8
- Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Development Status :: 5 - Production/Stable
9
9
  Classifier: Framework :: Django
10
10
  Classifier: Framework :: Django :: 4.2
11
11
  Classifier: Framework :: Django :: 5.0
@@ -95,6 +95,7 @@ CORS_ORIGIN=http://localhost:5173
95
95
  |---|---|
96
96
  | **谁是管理员** | 配 `BOOTSTRAP_ADMIN_WORK_IDS=工号,工号` —— 它是**声明式**的名单:启动时名单里的人补上 `is_staff`,**名单之外的一律清掉**(没配就是"一个管理员都没有")。它不碰 `is_superuser`,所以别拿超管当后门 |
97
97
  | **看数据模型那一页** | 设 `LABULL_SCHEMA_CANVAS=on`,浏览器打开 `/` |
98
+ | **让块的依赖声明拦住保存** | 设 `LABULL_REQUIRES=error`(默认 `warn`:判据照跑、**只提示不拦保存**;`off` 整段不跑) |
98
99
  | **导出一份模型形状** | `python manage.py labull_contract --out .labull/contract.json` |
99
100
 
100
101
  ## magic block
@@ -104,7 +105,10 @@ CORS_ORIGIN=http://localhost:5173
104
105
  ```
105
106
  我的块/
106
107
  ├─ index.open.html 入口(必备)
107
- ├─ magic-block.open.yaml 清单(必备):id / module / name / brief 四个键必填 + detail(详情介绍)可选
108
+ ├─ magic-block.open.yaml 清单(必备):`id` / `module` / `name` / `brief` 四个键必填
109
+ │ + `requires`(依赖声明:用了哪些模型 / pip 包 / settings 键 / `BLOCK_*`)可选
110
+ ├─ README.open.md **业务功能说明**(Markdown,可选):这一段的家就是这份文件,不在清单里
111
+ ├─ requires.open.yaml 依赖声明的**草稿**(导出铺下来的:正文进清单的 `requires`,注释是扫代码的建议)
108
112
  ├─ 任意 js / css
109
113
  ├─ excluded/_* 你自己的地盘(测试 / 草稿):**照样进库,执行时忽略**
110
114
  └─ backend_open.py 块自己的后端(可选)
@@ -67,6 +67,7 @@ CORS_ORIGIN=http://localhost:5173
67
67
  |---|---|
68
68
  | **谁是管理员** | 配 `BOOTSTRAP_ADMIN_WORK_IDS=工号,工号` —— 它是**声明式**的名单:启动时名单里的人补上 `is_staff`,**名单之外的一律清掉**(没配就是"一个管理员都没有")。它不碰 `is_superuser`,所以别拿超管当后门 |
69
69
  | **看数据模型那一页** | 设 `LABULL_SCHEMA_CANVAS=on`,浏览器打开 `/` |
70
+ | **让块的依赖声明拦住保存** | 设 `LABULL_REQUIRES=error`(默认 `warn`:判据照跑、**只提示不拦保存**;`off` 整段不跑) |
70
71
  | **导出一份模型形状** | `python manage.py labull_contract --out .labull/contract.json` |
71
72
 
72
73
  ## magic block
@@ -76,7 +77,10 @@ CORS_ORIGIN=http://localhost:5173
76
77
  ```
77
78
  我的块/
78
79
  ├─ index.open.html 入口(必备)
79
- ├─ magic-block.open.yaml 清单(必备):id / module / name / brief 四个键必填 + detail(详情介绍)可选
80
+ ├─ magic-block.open.yaml 清单(必备):`id` / `module` / `name` / `brief` 四个键必填
81
+ │ + `requires`(依赖声明:用了哪些模型 / pip 包 / settings 键 / `BLOCK_*`)可选
82
+ ├─ README.open.md **业务功能说明**(Markdown,可选):这一段的家就是这份文件,不在清单里
83
+ ├─ requires.open.yaml 依赖声明的**草稿**(导出铺下来的:正文进清单的 `requires`,注释是扫代码的建议)
80
84
  ├─ 任意 js / css
81
85
  ├─ excluded/_* 你自己的地盘(测试 / 草稿):**照样进库,执行时忽略**
82
86
  └─ backend_open.py 块自己的后端(可选)
@@ -14,7 +14,7 @@ from dataclasses import dataclass
14
14
  from pathlib import Path
15
15
  from typing import Mapping
16
16
 
17
- from .magic_block.sdk import BLOCK_DEEPCHECK, BLOCK_PROBE
17
+ from .magic_block.sdk import BLOCK_DEEPCHECK, BLOCK_PROBE, BLOCK_REQUIRES
18
18
 
19
19
  #: 给块的配置前缀:`BLOCK_OCR_KEY=abc` ⇒ 块后端里 `ctx.config['OCR_KEY'] == 'abc'`。
20
20
  #: 🔴 这是**白名单**(不是黑名单):想让块读一项,就给那个变量加这个前缀,不用改库的代码。
@@ -200,6 +200,23 @@ ENV_ITEMS: tuple[EnvItem, ...] = (
200
200
  hint=f'{BLOCK_PROBE}=off',
201
201
  group='平台开关',
202
202
  ),
203
+ # 🔴 依赖声明(`requires`)那一层的开关(名字的真源在 `magic_block/sdk.py`):
204
+ # 默认 `warn` —— 存量块的清单里一个声明都没有,一上线就硬报错等于让每个块集体停用。
205
+ EnvItem(
206
+ name=BLOCK_REQUIRES,
207
+ brief='块「依赖声明」(requires)那一层的开关:off 整段不跑 / warn(默认)只提示不拦 / error 变成硬判据',
208
+ hint=f'{BLOCK_REQUIRES}=error',
209
+ group='平台开关',
210
+ ),
211
+ # 🔴 体检那条链一次开几个口子(名字的真源在 `magic_block/parallel.py` 的 `ENV_WORKERS`)。
212
+ # ⚠️ 它是**性能旋钮、不是判据**:写错一个数(0 / 64 / 一句乱码)会被夹到 1..8,不该让体检报错。
213
+ # 它在这里的意义是"使用者的清单里能看见这个开关"(`backend.lock.pyi` 就是按 ENV_ITEMS 铺的)。
214
+ EnvItem(
215
+ name='LABULL_BLOCK_WORKERS',
216
+ brief='启动体检 / 全量校验一次开几个子进程(默认 4,夹在 1..8):机器小就往下调,写 1 退回串行',
217
+ hint='LABULL_BLOCK_WORKERS=2',
218
+ group='平台开关',
219
+ ),
203
220
  # ── 块的配置面 ──
204
221
  EnvItem(
205
222
  name=f'{BLOCK_CONFIG_PREFIX}*',
@@ -13,7 +13,10 @@
13
13
 
14
14
  🔴 为什么要"不许互相引用"这条规矩:块后端的正文**在库里**,不在磁盘上、也不在 `sys.path` 上。
15
15
  要想让 `from helper import x` 成立,就得先把包内文件注册成真模块(注册壳 → 依赖排序 →
16
- 别名共享 → 用完摘干净)——那套能跑通,但每一步都有坑(见 `probe-module-loader.py` 的血泪)。
16
+ 别名共享 → 用完摘干净)——那套**试过两次都放弃**了:一次是"假文件系统 + `sys.path_hooks`",
17
+ 它在现场会**带坏真模块的首次导入**(`labull_framework.magic_block.sdk` 报 ModuleNotFoundError);
18
+ 另一次是"自己造模块对象塞进 `sys.modules`",它跑得通,但"用完就摘"那一步既脆弱又没必要
19
+ (块里那几个文件本来就不该互相认识)。
17
20
  而"各写各的"把这一整类复杂度消掉了:想分文件就分,但每个文件要能独立站住
18
21
  (公共逻辑各写一份,或者放进**项目**里的公共模块 —— 那是普通 Python,随便 import)。
19
22
 
@@ -31,10 +31,14 @@
31
31
  ② 同一个名字在多处是**不同模型的实例**时,整个名字跳过(`{名字: {模型, …}}`),
32
32
  不许"后者覆盖前者"(那会拿 B 的属性去套 A 的实例)。
33
33
 
34
- 🔴 **判据只有一份、整层跑在子进程里**(与 `validate` 那次试加载同一个进程、同一次超时):
34
+ 🔴 **判据只有一份、整层跑在子进程里**(主路上它**和试加载同一趟**:`trial=True`):
35
35
  深检要真的把项目模型注册进来(`django.setup()` + `apps.get_models()`),而宿主进程的
36
36
  app registry 是迁移与请求**正在用**的那一份 —— 在进程内试跑块代码等于拿生产赌。
37
37
  ⚠️ 子进程里一律用**只读 / 内存**的那条数据库连接(别名 `probe`),真库一根头发都不动。
38
+ 🔴 试加载("这包加载得起来吗")搭这趟一起做的理由:两件都要 `django.setup()`,
39
+ 分两个进程就是把那份冷启动付两遍(一次块判定 1.4s ⇒ 0.7s)。
40
+ ⚠️ 从前那趟**没有 `django.setup()`**,于是模块级 `from orders.models import Order`
41
+ (Django 最正常的写法)会被判成 `AppRegistryNotReady` —— 一条假错,已修。
38
42
 
39
43
  🔴 **整包与判据正文一律走 stdin**(`subproc.run_child()` 的 `payload`),**不走环境变量**:
40
44
  这一包有多大是作者说了算的(实测有两个块 349 KB / 336 KB),而 Linux 对**单个**环境字符串
@@ -415,6 +419,27 @@ def main():
415
419
  {str(name): str(body) for name, body in raw_package.items()} if isinstance(raw_package, dict) else {}
416
420
  )
417
421
  package.setdefault(_main_file, source)
422
+ # 🔴 **试加载那一半**(`_GIVEN['trial']`):在同一趟子进程里先把包加载一次。
423
+ #
424
+ # 🔴 为什么并进来(而不是像从前那样另起一个子进程):两件都要 `django.setup()`,
425
+ # 分两个进程就是把那份冷启动付两遍 —— 实测一次块判定 1.4s ⇒ 0.7s,
426
+ # 而保存与体检都走这一条路(`validate.check_backend`)。
427
+ # 🔴 顺带修掉一条**假错**:从前这一半跑在一个**没有 `django.setup()`** 的子进程里,
428
+ # 于是"模块级写 `from orders.models import Order`"(Django 最正常的写法)会被判成
429
+ # `AppRegistryNotReady: Apps aren't loaded yet.` —— 而块运行期本来就在 app registry
430
+ # 已经就绪的进程里(`registry` 是在宿主进程里加载它的)。这里 `django.setup()` 已经跑过。
431
+ # ⚠️ 加载不过 ⇒ **只报那一条**(与从前一致):加载都不过的包,深检那些结论没有意义。
432
+ if _GIVEN.get('trial'):
433
+ try:
434
+ _loaded = _blockpackage.load_block(package, tag=str(_GIVEN.get('tag') or 'trial'))
435
+ except Exception as exc:
436
+ _STATIC.append(_issue('backend', '%s 加载失败(顶层与装饰器不许出错):%s: %s'
437
+ % (_main_file, type(exc).__name__, exc)))
438
+ return _result()
439
+ if _loaded.errors:
440
+ _STATIC.append(_issue('backend', '%s 加载失败(顶层与装饰器不许出错):%s'
441
+ % (_main_file, ';'.join(_loaded.errors))))
442
+ return _result()
418
443
  # 🔴 包内文件**互相引用**要先报出来(规矩,不是风格):块后端的正文在库里、不在磁盘上,
419
444
  # `from helper import x` 那种写法在块里**不成立**(平台按"每个 .py 各自独立加载"来跑)。
420
445
  # 不报的话症状是运行期 `ModuleNotFoundError`,而那时人已经不知道该看哪儿了。
@@ -451,6 +476,9 @@ def main():
451
476
  # 🔴 试跑那一层会用到的几个函数(撒样例行 / 取主键 / 错该算谁头上)是**从外面注入**的
452
477
  # (见 `deep_check()` 里那个 wrapper):它们只有一份实现(模块级那几个),
453
478
  # 这里**不许再抄一份** —— 抄一份的下场是"保存时说合法、体检时说非法"。
479
+ # 🔴 依赖声明的"声明的东西存不存在"也在这里跑(同一个进程、同一次 `django.setup()`):
480
+ # 它要问的就是 app registry / 包元数据 / settings,而这三样只有这个进程里是活的。
481
+ _requires_check()
454
482
  probing = bool(_GIVEN.get('probe'))
455
483
  if probing:
456
484
  probe(source, module_name, apps, package)
@@ -472,6 +500,187 @@ def _any_probe_note():
472
500
  return False
473
501
 
474
502
 
503
+ # ──────────────────────────── 依赖声明:声明的东西**真的存在吗** ────────────────────────────
504
+ #
505
+ # 🔴 判据只问**活的现实**:app registry / `importlib.metadata` / `django.conf.settings`。
506
+ # **绝不读 `backend.lock.pyi` 那类生成物**:生成物是快照、可能过期,registry 是当下的事实。
507
+ # (那条纪律见 docs/magic-block-requires.md 第 10.2 节:一份真源、两个消费者。)
508
+ # 🔴 声明由**父进程**从清单里读出来递进来(`_GIVEN['requires']`):这一半跟深检**同一次子进程**
509
+ # 走,所以不额外起进程;`LABULL_REQUIRES=off` 时父进程根本不递(整段不跑)。
510
+ # 🔴 **"代码用了却没声明"不在这里**(那一半是纯 `ast`,落在 `validate.check_requires`):
511
+ # 两半分开放,是因为这一半要运行环境、那一半不要 —— 混在一起会让"没 Django 时连静态判据都没跑"。
512
+
513
+ _REQUIRES = _GIVEN.get('requires') if isinstance(_GIVEN.get('requires'), dict) else None
514
+
515
+
516
+ def _declared(key):
517
+ """`requires.<key>` 那一串字符串(父进程已经规范化过;形状不对就当空)。"""
518
+ if not _REQUIRES:
519
+ return []
520
+ declared = _REQUIRES.get('declared')
521
+ items = declared.get(key) if isinstance(declared, dict) else None
522
+ if not isinstance(items, list):
523
+ return []
524
+ return [str(item) for item in items if str(item or '').strip()]
525
+
526
+
527
+ def _prefix():
528
+ """报文前缀(`模块/可读名:`)—— 与别处的报文一样,三项俱全(在哪 / 声明在哪 / 怎么改)。"""
529
+ label = str((_REQUIRES or {}).get('label') or '')
530
+ return ('%s:' % label) if label else ''
531
+
532
+
533
+ def _public_names(cls, limit=12):
534
+ """这个模型上**项目自己写的**方法 / 属性名(报文里那串"有:…")。
535
+
536
+ 🔴 只列项目自己定义的:`django.db.models.Model` 上那一堆(`save` / `objects` / `pk` …)
537
+ 列出来只会把真正该看的名字淹掉(人要看的是"这个方法不叫 log_event 了吗")。
538
+ """
539
+ from django.db import models as _models
540
+
541
+ inherited = set(dir(_models.Model))
542
+ out = []
543
+ for klass in getattr(cls, '__mro__', ()):
544
+ if str(getattr(klass, '__module__', '')).startswith('django.'):
545
+ continue
546
+ for key in vars(klass):
547
+ if key.startswith('_') or key in inherited or key in out:
548
+ continue
549
+ out.append(key)
550
+ return out[:limit]
551
+
552
+
553
+ def _model_of(path):
554
+ """按**完整路径**取那个类,回 `(类, 取不到的原因)`。
555
+
556
+ 🔴 先 `import` 那个模块再 `getattr`(不是按类名猜):声明给的是完整路径,
557
+ 所以判据可以**不猜** —— 这正是它比 `deepcheck` 那套"按类名匹配"准的地方。
558
+ ⚠️ 兜底再问一次 app registry(`core.models.WorkflowModel` ⇒ `apps.get_model('core', 'WorkflowModel')`):
559
+ 有的项目把模型放在包的 `__init__` 里,import 那一步会拿到别的东西。
560
+ """
561
+ import importlib
562
+
563
+ text = str(path)
564
+ module_path, _, name = text.rpartition('.')
565
+ why = '要写成完整的模块路径,例如 core.models.WorkflowModel'
566
+ if module_path:
567
+ try:
568
+ module = importlib.import_module(module_path)
569
+ except Exception as exc:
570
+ why = '%s: %s' % (type(exc).__name__, exc)
571
+ else:
572
+ found = getattr(module, name, None)
573
+ if isinstance(found, type):
574
+ return found, ''
575
+ why = '那个模块里没有 %s' % name
576
+ parts = [item for item in text.split('.') if item]
577
+ if len(parts) >= 2:
578
+ try:
579
+ from django.apps import apps as _apps
580
+
581
+ found = _apps.get_model(parts[0], parts[-1])
582
+ except Exception:
583
+ found = None
584
+ if found is not None:
585
+ return found, ''
586
+ return None, why
587
+
588
+
589
+ def _requires_hard():
590
+ """"声明了但它不存在"的逐条报文(**确定**的那一档)。
591
+
592
+ ⚠️ 这里**不分档**(`warn` / `error` 是父进程的事):这一层只报"事实",
593
+ 分档只有一处判据(`validate.requires_mode()`),两处各判一次迟早漂。
594
+ """
595
+ import importlib
596
+ from importlib import metadata
597
+
598
+ from django.conf import settings
599
+
600
+ out = []
601
+ if not _REQUIRES:
602
+ return out
603
+ head = _prefix()
604
+
605
+ for module in _declared('models'):
606
+ try:
607
+ importlib.import_module(module)
608
+ except Exception as exc:
609
+ out.append(
610
+ '%srequires.models 里的 %s 导不进来:%s: %s —— '
611
+ '改法:确认这个模块在目标项目里存在;换了项目就要跟着改声明。'
612
+ % (head, module, type(exc).__name__, exc)
613
+ )
614
+
615
+ surface = (_REQUIRES.get('declared') or {}).get('surface')
616
+ for path, attrs in (surface.items() if isinstance(surface, dict) else ()):
617
+ cls, why = _model_of(path)
618
+ if cls is None:
619
+ out.append(
620
+ '%srequires.surface 里的 %s 取不到:%s —— '
621
+ '改法:确认这个模型在目标项目里存在(要写完整的模块路径)。' % (head, path, why)
622
+ )
623
+ continue
624
+ for attr in (attrs if isinstance(attrs, list) else ()):
625
+ name = str(attr)
626
+ if hasattr(cls, name):
627
+ continue
628
+ near = _public_names(cls)
629
+ listed = ('(这个模型上有:%s)' % ' / '.join(near)) if near else ''
630
+ out.append(
631
+ '%srequires.surface 声明了 %s.%s,但这个模型上没有它%s —— '
632
+ '改法:把声明改成真实存在的方法名,或改代码。' % (head, path, name, listed)
633
+ )
634
+
635
+ for dist in _declared('packages'):
636
+ try:
637
+ metadata.version(dist)
638
+ except metadata.PackageNotFoundError:
639
+ out.append(
640
+ '%srequires.packages 里的 %s 没装 —— '
641
+ '改法:pip install %s,或把它从声明里删掉(删掉之后代码里再用会被报错)。'
642
+ % (head, dist, dist)
643
+ )
644
+ except Exception as exc:
645
+ # ⚠️ 取不到元数据**不是"没装"**(那可能是这个环境自己的毛病):只记说明,不当结论。
646
+ _NOTES.append(
647
+ '%srequires.packages 里的 %s 这一次没验(读包元数据失败:%s: %s)'
648
+ % (head, dist, type(exc).__name__, exc)
649
+ )
650
+
651
+ for key in _declared('settings'):
652
+ if not hasattr(settings, key):
653
+ out.append(
654
+ '%srequires.settings 里的 %s 在 settings 里不存在 —— '
655
+ '改法:确认这个设置键在目标项目里真的配了'
656
+ '(这一项声明的是"我依赖这个键存在",不判它的值)。' % (head, key)
657
+ )
658
+ return out
659
+
660
+
661
+ def _requires_check():
662
+ """跑一遍依赖声明的"存不存在",并按开关落位(`warn` ⇒ 只进备注)。
663
+
664
+ ⚠️ 判据自己崩了**不算这个块的错**(软失败口径见文件头):只记一句说明。
665
+ """
666
+ if not _REQUIRES:
667
+ return
668
+ try:
669
+ hard = _requires_hard()
670
+ except Exception as exc:
671
+ _NOTES.append('依赖声明的"存不存在"这一半没跑(不是这个块的问题):%s: %s'
672
+ % (type(exc).__name__, exc))
673
+ return
674
+ if not hard:
675
+ return
676
+ if str(_REQUIRES.get('mode') or 'warn') == 'error':
677
+ for message in hard:
678
+ _STATIC.append(_issue('requires', message))
679
+ return
680
+ for message in hard:
681
+ _NOTES.append('(提示,不影响保存)%s' % message)
682
+
683
+
475
684
  # ──────────────────────────── 静态判据:点了什么名 ────────────────────────────
476
685
 
477
686
 
@@ -1176,6 +1385,9 @@ def deep_check(
1176
1385
  files: dict[str, str] | None = None,
1177
1386
  probe: bool = True,
1178
1387
  deleted: dict[str, Any] | None = None,
1388
+ requires: dict[str, Any] | None = None,
1389
+ trial: bool = False,
1390
+ tag: str = '',
1179
1391
  ) -> tuple[list[tuple[str, str]], list[str]]:
1180
1392
  """深检一份块后端正文:`([(stage, issue)], [没验到的说明])`。
1181
1393
 
@@ -1187,6 +1399,14 @@ def deep_check(
1187
1399
  不给 / 给空 ⇒ 这一条判据不报(`None` 与"空"同义)。
1188
1400
  ⚠️ 调用方(`validate.check_block`)会先算一次再递进来:算它要读 git,
1189
1401
  而体检是对每个块各问一次的(见 `canvas.data` 里那段缓存的说明)。
1402
+ :param requires: 依赖声明那一层(形状见 `validate.requires_payload()`:
1403
+ `{'declared': …, 'label': …, 'mode': …}`)。不给 ⇒ 那一条判据不跑。
1404
+ 🔴 它搭**这一次**子进程走:判"声明的东西存不存在"要 app registry / 包元数据 / settings,
1405
+ 而这三样只在子进程里是活的 —— 另开一个进程等于给每个块的保存与体检白加 0.4 秒。
1406
+ :param trial: **顺带把包加载一次**("试加载"那一半,见 `_CHECK` 里那一段)。
1407
+ 🔴 默认 `False`:只有 `validate.check_backend` 会让它一起做(一次子进程做完两件事,
1408
+ 省掉一次 `django.setup()`);直接调 `deep_check` 的调用点照旧只做深检。
1409
+ :param tag: 试加载时给包内模块起前缀用的(传块 uuid 更稳)。只在 `trial=True` 时用得上。
1190
1410
 
1191
1411
  🔴 只回**拿得准的问题**:环境交不出来时回 `([], ['没验…'])` —— 说明进日志,不落库、不停用。
1192
1412
  🔴 **正文与整包走 stdin**(不是环境变量、也不是 argv):这一包有多大是作者说了算的,
@@ -1251,6 +1471,14 @@ def deep_check(
1251
1471
  # ⚠️ 参数是**小数据**(作者写的一段 JSON),但仍走同一个通道:这一层"数据只有一条路"
1252
1472
  # (少一条通道就少一处"某个调用点还塞环境变量"的复发空间)。
1253
1473
  'params': params if params.strip() else '',
1474
+ # 🔴 依赖声明那一层:**声明本身**(父进程从清单里读出来、规范化过的)+ 报文前缀 + 开关。
1475
+ # 它是**数据**、不是代码:子进程里那份判据是平台自己的(`_requires_check()`)。
1476
+ 'requires': requires if isinstance(requires, dict) else None,
1477
+ # 🔴 试加载那一半要不要在**这一趟**里做(见 `_CHECK` 里那一段):
1478
+ # `validate.check_backend` 默认让子进程一起做(省一次 `django.setup()`);
1479
+ # `LABULL_DEEPCHECK=off` 时父进程自己另起一个只做加载的子进程。
1480
+ 'trial': bool(trial),
1481
+ 'tag': str(tag or ''),
1254
1482
  },
1255
1483
  ensure_ascii=False,
1256
1484
  )
@@ -1276,8 +1504,8 @@ def deep_check(
1276
1504
  [
1277
1505
  (
1278
1506
  'backend',
1279
- f'{BLOCK_BACKEND_FILE} 引用深检超过 {DEEP_TIMEOUT} 秒没有结束,已中止'
1280
- '(handler 里不许有死循环 / 长轮询;耗时的事请改成先落状态再异步处理)',
1507
+ f'{BLOCK_BACKEND_FILE} 试加载 / 引用深检超过 {DEEP_TIMEOUT} 秒没有结束,已中止'
1508
+ '(顶层与 handler 里都不许有死循环 / 长轮询;耗时的事请改成先落状态再异步处理)',
1281
1509
  )
1282
1510
  ],
1283
1511
  [],