labull-framework 1.0.0__tar.gz → 1.0.2__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 (58) hide show
  1. {labull_framework-1.0.0/labull_framework.egg-info → labull_framework-1.0.2}/PKG-INFO +9 -3
  2. {labull_framework-1.0.0 → labull_framework-1.0.2}/README-pypi.md +8 -2
  3. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/canvas/data.py +3 -3
  4. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/canvas/gitinfo.py +10 -42
  5. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/canvas/shape_ast.py +96 -14
  6. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/env.py +8 -5
  7. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/magic_block/blockpackage.py +1 -16
  8. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/magic_block/deepcheck.py +14 -4
  9. labull_framework-1.0.2/labull_framework/magic_block/devkit.py +1492 -0
  10. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/magic_block/dispatch.py +2 -2
  11. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/magic_block/export.py +1268 -1214
  12. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/magic_block/sdk.py +11 -8
  13. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/magic_block/validate.py +2303 -2042
  14. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/magic_block/views.py +13 -4
  15. labull_framework-1.0.2/labull_framework/management/commands/labull_requires.py +193 -0
  16. {labull_framework-1.0.0 → labull_framework-1.0.2/labull_framework.egg-info}/PKG-INFO +9 -3
  17. {labull_framework-1.0.0 → labull_framework-1.0.2}/pyproject.toml +1 -1
  18. labull_framework-1.0.0/labull_framework/magic_block/devkit.py +0 -1734
  19. labull_framework-1.0.0/labull_framework/management/commands/labull_requires.py +0 -86
  20. {labull_framework-1.0.0 → labull_framework-1.0.2}/LICENSE +0 -0
  21. {labull_framework-1.0.0 → labull_framework-1.0.2}/MANIFEST.in +0 -0
  22. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/__init__.py +0 -0
  23. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/admin.py +0 -0
  24. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/apps.py +0 -0
  25. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/auth/__init__.py +0 -0
  26. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/auth/backend.py +0 -0
  27. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/auth/bootstrap.py +0 -0
  28. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/auth/sso.py +0 -0
  29. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/auth/views.py +0 -0
  30. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/canvas/__init__.py +0 -0
  31. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/canvas/static/canvas.css +0 -0
  32. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/canvas/static/canvas.js +0 -0
  33. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/canvas/static/page.html +0 -0
  34. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/canvas/views.py +0 -0
  35. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/jsonio.py +0 -0
  36. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/magic_block/__init__.py +0 -0
  37. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/magic_block/health.py +0 -0
  38. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/magic_block/parallel.py +0 -0
  39. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/magic_block/registry.py +0 -0
  40. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/magic_block/service.py +0 -0
  41. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/magic_block/subproc.py +0 -0
  42. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/management/__init__.py +0 -0
  43. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/management/commands/__init__.py +0 -0
  44. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/management/commands/labull_contract.py +0 -0
  45. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/migrations/0001_initial.py +0 -0
  46. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/migrations/0002_remove_magicblock_prev_files_and_more.py +0 -0
  47. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/migrations/0003_magicblock_tags.py +0 -0
  48. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/migrations/0004_magicblock_detail.py +0 -0
  49. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/migrations/__init__.py +0 -0
  50. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/models.py +0 -0
  51. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/settings.py +0 -0
  52. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/urls.py +0 -0
  53. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework/virtual.py +0 -0
  54. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework.egg-info/SOURCES.txt +0 -0
  55. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework.egg-info/dependency_links.txt +0 -0
  56. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework.egg-info/requires.txt +0 -0
  57. {labull_framework-1.0.0 → labull_framework-1.0.2}/labull_framework.egg-info/top_level.txt +0 -0
  58. {labull_framework-1.0.0 → labull_framework-1.0.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 1.0.0
3
+ Version: 1.0.2
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
@@ -95,7 +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
+ | **块的依赖声明不许"用了却没声明"** | 它是**默认**行为(`LABULL_REQUIRES=error`,1.0.1 起):代码里用了没声明的东西 ⇒ 拦住保存。⚠️ 升级前先在项目里跑一遍 `python manage.py labull_requires --write` 把存量块扫得到的声明补进库;`warn`(只提示不拦)与 `off`(整段不跑)都要**显式**写 |
99
99
  | **导出一份模型形状** | `python manage.py labull_contract --out .labull/contract.json` |
100
100
 
101
101
  ## magic block
@@ -107,7 +107,8 @@ CORS_ORIGIN=http://localhost:5173
107
107
  ├─ index.open.html 入口(必备)
108
108
  ├─ magic-block.open.yaml 清单(必备):`id` / `module` / `name` / `brief` 四个键必填
109
109
  │ + `requires`(依赖声明:用了哪些模型 / pip 包 / settings 键 / `BLOCK_*`)可选
110
- ├─ README.open.md **业务功能说明**(Markdown,可选):这一段的家就是这份文件,不在清单里
110
+ ├─ README.open.md **业务功能说明**(Markdown):这一段的家就是这份文件,不在清单里
111
+ │ (**每个块都会有一份** —— 导出时无条件铺,没写就是空文件)
111
112
  ├─ requires.open.yaml 依赖声明的**草稿**(导出铺下来的:正文进清单的 `requires`,注释是扫代码的建议)
112
113
  ├─ 任意 js / css
113
114
  ├─ excluded/_* 你自己的地盘(测试 / 草稿):**照样进库,执行时忽略**
@@ -138,6 +139,11 @@ class Order:
138
139
 
139
140
  配置:环境变量里**带 `BLOCK_` 前缀**的才会进 `ctx.config`(`BLOCK_OCR_KEY=a` ⇒ `ctx.config['OCR_KEY']`)。
140
141
 
142
+ 🔴 **弹窗走平台**:块里 `await magicBlock.dialog({ title, body, fields, buttons })` —— 由宿主页面渲染
143
+ (前端要挂一次 `<MagicBlockDialogs />`)。块自己画的浮层**摆不正**:块可以是好几屏高,块里
144
+ `position: fixed` / `100vh` 的基准是"整块高度"而不是用户那一屏。要自己写整篇就传 `{ html }`,
145
+ 宿主给一个确定大小的面板 + 另一格沙箱 iframe 装它,里面用 `magicBlock.done({ button, values })` 交回选择。
146
+
141
147
  ## 虚拟类(`@virtual_class`)
142
148
 
143
149
  几张表共有同一批列时,写一个**虚拟类**:一个**不建表的抽象模型**,自己那几列会被继承到子表上。
@@ -67,7 +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
+ | **块的依赖声明不许"用了却没声明"** | 它是**默认**行为(`LABULL_REQUIRES=error`,1.0.1 起):代码里用了没声明的东西 ⇒ 拦住保存。⚠️ 升级前先在项目里跑一遍 `python manage.py labull_requires --write` 把存量块扫得到的声明补进库;`warn`(只提示不拦)与 `off`(整段不跑)都要**显式**写 |
71
71
  | **导出一份模型形状** | `python manage.py labull_contract --out .labull/contract.json` |
72
72
 
73
73
  ## magic block
@@ -79,7 +79,8 @@ CORS_ORIGIN=http://localhost:5173
79
79
  ├─ index.open.html 入口(必备)
80
80
  ├─ magic-block.open.yaml 清单(必备):`id` / `module` / `name` / `brief` 四个键必填
81
81
  │ + `requires`(依赖声明:用了哪些模型 / pip 包 / settings 键 / `BLOCK_*`)可选
82
- ├─ README.open.md **业务功能说明**(Markdown,可选):这一段的家就是这份文件,不在清单里
82
+ ├─ README.open.md **业务功能说明**(Markdown):这一段的家就是这份文件,不在清单里
83
+ │ (**每个块都会有一份** —— 导出时无条件铺,没写就是空文件)
83
84
  ├─ requires.open.yaml 依赖声明的**草稿**(导出铺下来的:正文进清单的 `requires`,注释是扫代码的建议)
84
85
  ├─ 任意 js / css
85
86
  ├─ excluded/_* 你自己的地盘(测试 / 草稿):**照样进库,执行时忽略**
@@ -110,6 +111,11 @@ class Order:
110
111
 
111
112
  配置:环境变量里**带 `BLOCK_` 前缀**的才会进 `ctx.config`(`BLOCK_OCR_KEY=a` ⇒ `ctx.config['OCR_KEY']`)。
112
113
 
114
+ 🔴 **弹窗走平台**:块里 `await magicBlock.dialog({ title, body, fields, buttons })` —— 由宿主页面渲染
115
+ (前端要挂一次 `<MagicBlockDialogs />`)。块自己画的浮层**摆不正**:块可以是好几屏高,块里
116
+ `position: fixed` / `100vh` 的基准是"整块高度"而不是用户那一屏。要自己写整篇就传 `{ html }`,
117
+ 宿主给一个确定大小的面板 + 另一格沙箱 iframe 装它,里面用 `magicBlock.done({ button, values })` 交回选择。
118
+
113
119
  ## 虚拟类(`@virtual_class`)
114
120
 
115
121
  几张表共有同一批列时,写一个**虚拟类**:一个**不建表的抽象模型**,自己那几列会被继承到子表上。
@@ -481,9 +481,9 @@ def _fields_of(model: Any) -> tuple[list[dict[str, Any]], list[dict[str, Any]],
481
481
  # 它的正向声明住在 **Django 自己的源码**里,而"上一版"那一份只扫项目与装着的
482
482
  # 第三方 app(`shape_ast._reverses()` 里 `if target is None or not self._installed(...)`
483
483
  # 那一句)⇒ 基线里**永远没有**这一条,于是画布常年报一处假的"新增的关联"。
484
- # 实测就是它:装了 `django.contrib.admin` 时用户表上那条 `labull_user.logentry`
485
- # (`gitinfo._known_limitation()` 原来把它记成"这条路固有的边界",其实两边
486
- # **用同一条判据**就能对齐:不画的表,两边都不挂它的反向关联)。
484
+ # 实测就是它:装了 `django.contrib.admin` 时用户表上那条 `labull_user.logentry`。
485
+ # ⚠️ 这条边界**只能靠"不画的表两边都不挂它的反向关联"来对齐**(见下面那句
486
+ # `SKIP_TABLES` 判定)—— 想靠"在基线里把它剔掉"是越剔越糟,别再去试。
487
487
  if _table_of_model(target) in SKIP_TABLES:
488
488
  continue
489
489
  # 🔴 一对一反过来的**基数也是 `1`**:这一头只对应一行(`OneToOneRel.one_to_one`)。
@@ -242,29 +242,18 @@ def baseline_tables() -> list[dict[str, Any]] | None:
242
242
  而且不需要任何人记得维护什么文件;
243
243
  ② 退到提交里那份 `contract.json`(老做法):只在①读不出来时用
244
244
  (源码语法坏掉 / 模型写在 `models/` 包里这种今天不支持的形状)。
245
- ⚠️ 有一类**已知的**假差异(装了 `django.contrib.admin` 时用户表上那条 `logentry`),
246
- 见 `_known_limitation()` —— 那是"从源码算"这条路的固有边界,不是可以顺手滤掉的噪声
247
- (试过滤,越滤越糟)。
245
+ ⚠️ 有一类**已知的**假差异:装了 `django.contrib.admin` 的项目里,`LogEntry` 有一条外键指向
246
+ **你的**用户表 ⇒ `_meta.get_fields()` 会在你的用户表上给出 `logentry` 这条反向关联,
247
+ 而它的正向声明在 Django 自己的源码里(这个项目的 git 里一行都没有)—— 所以"从源码算基线"
248
+ 这条路**永远算不出它**(画布上会常年挂着一条"新增的关联")。
249
+ 🔴 这是这条路的**固有边界,不是可以顺手滤掉的噪声**:试过两条路(靠"差分反推来源"、
250
+ 靠 `origin` 标记)都**越滤越糟** —— 剔掉之后基线里没有、现在这一份里有,差异恰好从
251
+ "没多出来"变成"新增"。要根治只能让基线也算上 Django 那边的正向声明,那是另一件事。
248
252
  """
249
253
  derived = _revision_tables()
250
254
  return derived if derived is not None else contract_tables()
251
255
 
252
256
 
253
- def _known_limitation() -> str:
254
- """这一层**已知**的一类假差异(写给下一个人看,别再去踩)。
255
-
256
- 装过 `django.contrib.admin` 的项目里,`django.contrib.admin.LogEntry` 有一条外键指向**你的**
257
- 用户表 ⇒ `_meta.get_fields()` 会在你的用户表上给出 `logentry` 这条反向关联,
258
- 而它的正向声明在 Django 自己的源码里(这个项目的 git 里一行都没有)——
259
- 所以"从源码算基线"这条路**永远算不出它**,画布上会常年挂着一条"新增的关联"。
260
- ⚠️ 试过在基线里把它剔掉(两条路都试过:靠"差分反推来源"、靠 `origin` 标记):
261
- **越剔越糟** —— 剔掉之后基线里没有、现在这一份里有,差异恰好从"没多出来"变成"新增"。
262
- 要根治只能"让基线多知道一条 Django 自己那边的关联",那是另一件事(读 Django 的源码 +
263
- 把它的正向声明也算进来)。今天只留这一条说明。
264
- """
265
- return 'labull_user.logentry(装了 admin 时)'
266
-
267
-
268
257
  def contract_tables() -> list[dict[str, Any]] | None:
269
258
  """提交里那份 `contract.json` 的形状(**兜底**来源;没有就是 `None`)。
270
259
 
@@ -425,27 +414,6 @@ def _sources_state() -> float:
425
414
  return newest
426
415
 
427
416
 
428
- def _newest_mtime(path: Path) -> float:
429
- """这个路径(文件就自己,目录就**递归**)里最新的 mtime(拿不到回 `0.0`)。
430
-
431
- ⚠️ 只对**小的那几棵**目录树用它(`refs` / `logs`,一个仓库几十个文件)。
432
- **不要拿它去递归 `.git` 自己**:那底下有 `objects/`,一个仓库几千上万个对象 ——
433
- 真踩过:这一层是每次访问缓存都要走的,递归一遍 `.git` 让"热"的那次从 1ms 涨到 340ms,
434
- `tools/baseline-cases.py` 里那条"第二次走缓存"的判据当场就红了。
435
- """
436
- try:
437
- newest = path.stat().st_mtime
438
- except OSError:
439
- return 0.0
440
- if path.is_dir():
441
- for child in path.rglob('*'):
442
- try:
443
- newest = max(newest, child.stat().st_mtime)
444
- except OSError:
445
- continue
446
- return newest
447
-
448
-
449
417
  def _git_state(roots: list[Path | None]) -> float:
450
418
  """这几个路径**各自最近那个** `.git` 里,"一变就该重算"的东西的最新 mtime(没有 git 回 `0.0`)。
451
419
 
@@ -458,9 +426,9 @@ def _git_state(roots: list[Path | None]) -> float:
458
426
  · `index` —— `git add`;
459
427
  · **当前那个分支的 ref 文件 + `HEAD` 里的提交号**(见 `_head_marker()`)—— **提交**;
460
428
  · `packed-refs` —— 打包过的仓库(`git gc` / 服务器上克隆下来的)。
461
- 🔴 **递归 `refs/**` 那一版删掉了**(真踩过):`_newest_mtime()` 会把 `refs` 与 `logs` 整棵小树
462
- 走一遍,而"平台那些 app 各自一个仓库"让这件事**按 app 数乘上去** —— 实测一次画布要
463
- 多花 ~200ms(profiler 里 `_newest_mtime` 是最大的一块)。改成"读 `.git/HEAD` 找到当前
429
+ 🔴 **从前那一版是"递归 `refs/**` + `logs/**` 数最新的 mtime"**(真踩过):它会把那两棵小树
430
+ 整个走一遍,而"平台那些 app 各自一个仓库"让这件事**按 app 数乘上去** —— 实测一次画布要
431
+ 多花 ~200ms(profiler 里递归那一块是最大的)。改成"读 `.git/HEAD` 找到当前
464
432
  分支、只 stat 那**一个** ref 文件"之后:提交照样能看见(ref 文件被重写),
465
433
  而代价从"几十上百次 stat"降到"两三次"。⚠️ 判据是 `HEAD` 里那个提交号、
466
434
  **不是 `pytest.ini` 那种 `paths` 里的 `logs/HEAD`** —— 后者只在 reflog 开着时才变。
@@ -243,6 +243,9 @@ class _Project:
243
243
  #: 字段工厂函数(`def current_assignees_field(...)`)的记忆:**函数名 → 那个 `def`**。
244
244
  #: 🔴 连 `None` 一起记:一个工厂会被好几张表用到,不记"没有"就等于**每张表都起一次 git**。
245
245
  self._helpers: dict[str, ast.FunctionDef | None] = {}
246
+ #: "**一个类自己**继承完之后的完整字段表"的记忆(见 `_own_fields()`):键是
247
+ #: `'app.类名'` 或外部基类的模块路径。同一个基类会被好几张表用到,不记就重复解析。
248
+ self._own_cache: dict[str, dict[str, tuple[ast.Call, dict[str, ast.expr]]]] = {}
246
249
  #: app 标签(`'masterdata'` / `'apps.masterdata'`):`lookup()` 要拿它去认源码里的懒引用
247
250
  #: (见那个函数)。懒算 —— 没碰上带点懒引用的项目一次都不建(见 `_labels()`)。
248
251
  self._label_cache: list[str] | None = None
@@ -447,6 +450,32 @@ class _Project:
447
450
  return self.table_name(model)
448
451
  return KNOWN_TARGET_TABLES.get(self._external_name(call), '')
449
452
 
453
+ def external_target_name(self, call: ast.Call) -> str:
454
+ """这条关联指向的**外部**模型名(`groups` ⇒ `'Group'`);不是外部 / 认不出回空串。
455
+
456
+ 🔴 为什么不直接照搬 `_meta` 那条路:`resolve_relation()` **只认本项目里声明过的模型**
457
+ (那条 docstring 里写着"认不出就回 `None`,一个都不猜"),所以 `auth.Group` 那类
458
+ 对端在那一层是 `None` ⇒ 关联的 `target` 是空串、`targetTable` 靠
459
+ `KNOWN_TARGET_TABLES` 兜住。两处**必须一致**:`target` 与 `targetTable` 都取自
460
+ 同一份"已知外部表"清单,猜不出来就两个都给空。
461
+ """
462
+ return self._external_name(call)
463
+
464
+ def external_m2m_table(self, model: _Model, name: str, call: ast.Call) -> str:
465
+ """默认多对多中间表名(Django `ManyToManyField._get_m2m_db_table()` 的那一条算式)。
466
+
467
+ Django 的正文就是一句:`opts.db_table` 拼上字段名 —— `labull_user` + `groups` ⇒
468
+ `labull_user_groups`。⚠️ 前缀是**声明那一头**的表名,与对端(`auth.Group`)无关
469
+ ("两个模型名按字母序拼"是中间表**模型**的默认 `db_table`,不是这一条)。
470
+
471
+ ⚠️ 只对**认出对端**的那几条成立:对端在本项目里没有同名模型是"这是外部关联"的判据,
472
+ 认不出来的对端连模型名都没有,硬拼一个表名就是编 —— 那种情况老实回空串
473
+ (`_meta` 那边此刻也不会给 `via`)。
474
+ """
475
+ if not self.external_target_name(call):
476
+ return ''
477
+ return f'{self.table_name(model)}_{name}'
478
+
450
479
  def known_primary_type(self, model: _Model | None, call: ast.Call) -> str:
451
480
  """一条外键列的**库类型**(对端主键那一列的类型)。
452
481
 
@@ -605,18 +634,20 @@ class _Project:
605
634
  def _merge(self, model: _Model) -> list[tuple[str, ast.Call, dict[str, ast.expr]]]:
606
635
  """真干活的那一半:MRO + 外部基类 + 剔掉非字段 + 补齐主键(见 `fields_of()` 的说明)。"""
607
636
  package = self._pkg(model.app)
637
+ # 🔴 **按 MRO 从子类往基类走**,而每一个类都拿"**它自己**继承完之后的完整字段表"
638
+ # (`_own_fields()` 递归同一个规则)—— 这正是 Django `ModelBase.__new__` 干的事:
639
+ # 它 `for base in new_class.mro()`(**自然序**)逐个抽象基类把 `base._meta.fields`
640
+ # 抄进子类,`field_names` 用来去重 ⇒ 同名的那一列**挪到子类的位置上**。
641
+ # ⚠️ 从前这里是 `for base in reversed(mro)` + 一个全局字典,等于假设"Django 是
642
+ # 从最远的基类往子类铺"。那个假设**在多继承上就是错的**:`A(AbstractBaseUser,
643
+ # PermissionsMixin)` 的自然 MRO 里 `AbstractBaseUser` 在 `PermissionsMixin`
644
+ # **前面**,于是 `is_superuser` 被摆到 `password` 前面,而 Django 给的是
645
+ # `password / last_login / is_superuser / …` —— 列一模一样、只是次序错,
646
+ # 而 `_compare_key()` 比的是**有序**的列列表 ⇒ 没动过的 `labull_user`
647
+ # 常年挂一处假的"已改动"(`sdk-check-py` 判据①②报的就是它)。
608
648
  merged: dict[str, tuple[ast.Call, dict[str, ast.expr]]] = {}
609
- # 🔴 **按 MRO 从子类往基类走**(`reversed(mro)`):次序与 Django 的 `_get_fields()`
610
- # 一致 —— 它**先递归父类**、最后才 `+= self.local_fields`。所以最靠前的类是
611
- # 子类(它的字段最后落位、覆盖基类同名那一列时也是挪到子类的位置上),
612
- # 而基类的字段先落位。🔴 判据不只是好看:`_compare_key()` 比的是**有序**的列列表,
613
- # 次序错一位就是一处"改过"。
614
- # ⚠️ 这里是 `reversed`,**不是**自然序 —— 文档串头一句 `[模型, 基类, …]` 说的是
615
- # `mro()` 的返回值,别照着它把这一句改掉(真验过:改了之后抽象基类那几列
616
- # `created_at` 会从 `id` 后面掉到整张表的最后,⑦ 那条夹具当场变红)。
617
- for base in reversed(self.mro(model, package)):
618
- for name, call, kwargs in self._declared(base):
619
- merged[name] = (call, kwargs)
649
+ for base in self.mro(model, package):
650
+ merged.update(self._own_fields(base))
620
651
  # 🔴 **先剔掉"不是字段"的那些,再判主键**:`objects = Manager()` 这种在解析阶段是
621
652
  # "暂收"的(见 `_is_field_call()`),真正的判据在这里 —— 不剔的话它会在表上
622
653
  # 凭空多出一列,而且 `Manager(primary_key=True)` 那种写法还会把合成主键挤掉。
@@ -659,6 +690,31 @@ class _Project:
659
690
  kept[name] = (call, kwargs)
660
691
  return kept
661
692
 
693
+ def _own_fields(self, node: Any) -> dict[str, tuple[ast.Call, dict[str, ast.expr]]]:
694
+ """**一个类自己**继承完之后的完整字段表(不含它子类加的东西)。
695
+
696
+ :param node: MRO 上的一个条目 —— 本项目里的模型(`_Model`)或一个外部基类的字符串键。
697
+
698
+ 🔴 为什么必须**每个类各算一份**,不能像从前那样把全部基类的声明铺进一个字典:
699
+ Django 抄的是 `base._meta.fields`(**那个基类自己的完整字段表**,已含它继承来的),
700
+ 而"同名覆盖"是在**子类那一步**发生的。两者在"子类和某个抽象基类声明了同一列"
701
+ 时给出**不同的次序**(Django 把那一列挪到子类的位置上)—— 而次序参与判据。
702
+ 🔴 递归走**同一个规则**(自然 MRO + 逐类 update)⇒ 与 Django 的 `mro()` 逐层一致。
703
+ ⚠️ 这条路**不会成环**:MRO 本身就是线性的,每个类的 `lin()` 只包含它自己那条链。
704
+ """
705
+ key = node if isinstance(node, str) else f'{node.app}.{node.name}'
706
+ if key in self._own_cache:
707
+ return self._own_cache[key]
708
+ out: dict[str, tuple[ast.Call, dict[str, ast.expr]]] = {}
709
+ for base in self.mro(node, self._pkg(node.app) if not isinstance(node, str) else ''):
710
+ if (base if isinstance(base, str) else f'{base.app}.{base.name}') == key:
711
+ continue # 自己这一层的声明由下面那一句收(顺序:先基类、后自己)
712
+ out.update(self._own_fields(base))
713
+ for name, call, kwargs in self._declared(node):
714
+ out[name] = (call, kwargs)
715
+ self._own_cache[key] = out
716
+ return out
717
+
662
718
  def mro(self, model: _Model, package: str = '') -> list[Any]:
663
719
  """一个模型的 MRO(含**外部**基类):`[模型, 基类, …, Model]`(C3 线性化)。
664
720
 
@@ -730,11 +786,29 @@ class _Project:
730
786
  out.append(resolved)
731
787
  return out
732
788
 
789
+ #: 正在算的那条链(**防环专用**)。🔴 它必须是**另一个**集合,不能拿 `memo` 兼职:
790
+ # `memo[key] = [key]` 那种"先占位"的写法把**没算完**的结果写进了备忘 ——
791
+ # 而链上任何一个类**都可能在算完之前被问到第二次**(多继承就是这条路:
792
+ # 算 `AbstractUser` 时先算 `AbstractBaseUser`,而它的线性化里已经包含
793
+ # `AbstractUser` 的兄弟 `PermissionsMixin`)。那一次拿到的是"只有我自己"的
794
+ # 半成品,它随后被**当成正确结果用**,而且**谁先被问到就决定了对错** ——
795
+ # 实测:`AbstractUser(AbstractBaseUser, PermissionsMixin)` 的 MRO 变成
796
+ # `[…, PermissionsMixin, AbstractBaseUser, AbstractUser]`,于是一张表上
797
+ # `is_superuser` 排到了 `password` 前面 ⇒ `_compare_key()` 逐位比列 ⇒
798
+ # 没动过的 `labull_user` 常年挂一处假的"已改动"(列一模一样、只是次序错)。
799
+ # ⚠️ 那条判据(`sdk-check-py` 判据①②)在**真环**不在时永远看不见这件事:
800
+ # 它只在"同一个类被间接问到两次"的多继承项目上才露出来。
801
+ active: set[str] = set()
802
+
733
803
  def lin(key: str) -> list[str]:
734
804
  """C3:`lin(C) = [C] + merge(lin(B1), …, [B1, …])`(`merge` 取每个序列的下一个可用头)。"""
735
805
  if key in memo:
736
806
  return memo[key]
737
- memo[key] = [key] # 防环:先占位(真环只出现在手滑的代码里,不该把整页带炸)
807
+ if key in active:
808
+ # 真环(手滑写出来的 `class A(B)` / `class B(A)`):只回它自己,
809
+ # **不写 memo** —— 免得把一个半成品记成结论。
810
+ return [key]
811
+ active.add(key)
738
812
  # ⚠️ `bases_of(key)` 只算**一次**:它是"读一个文件 + 解析"的量级,而下面两处都要用。
739
813
  bases = bases_of(key)
740
814
  seqs: list[list[str]] = [list(lin(base)) for base in bases]
@@ -760,6 +834,7 @@ class _Project:
760
834
  if seq and seq[0] == pick:
761
835
  seq.pop(0)
762
836
  memo[key] = out
837
+ active.discard(key) # 算完了才算离开这条链:`break` 那条路也走得到这里
763
838
  return out
764
839
 
765
840
  result: list[Any] = []
@@ -995,8 +1070,9 @@ class _Project:
995
1070
  if kind == 'manyToMany':
996
1071
  relations.append(_relation(
997
1072
  name, kind, cardinality, target,
1073
+ target_name=self.external_target_name(call),
998
1074
  target_table=target_table,
999
- via=self._through(call, model),
1075
+ via=self._through(call, model) or self.external_m2m_table(model, name, call),
1000
1076
  external=external,
1001
1077
  ))
1002
1078
  continue
@@ -1615,6 +1691,7 @@ def _relation(
1615
1691
  cardinality: str,
1616
1692
  target: _Model | None,
1617
1693
  *,
1694
+ target_name: str = '',
1618
1695
  target_table: str = '',
1619
1696
  column: str | None = None,
1620
1697
  via: str | None = None,
@@ -1625,6 +1702,11 @@ def _relation(
1625
1702
  ) -> dict[str, Any]:
1626
1703
  """一条关联(与 `data._relation_of()` 同形 —— 键少一个就可能被当成"改过")。
1627
1704
 
1705
+ :param target_name: 对端**模型名**。⚠️ 外部对端(`auth.Group` 那两张)在本项目里
1706
+ `resolve_relation()` 认不出来(它只认声明过的模型),所以要由调用方从
1707
+ `KNOWN_TARGET_TABLES` 那一份清单里把名字递进来 —— 不递的话 `target` 是空串,
1708
+ 而 `_meta` 给的是 `'Group'` ⇒ 判据"两份形状必须同形"当场红
1709
+ (列与指向都对,只有名字那一格空着)。
1628
1710
  :param external: 这一条是**靠 `KNOWN_TARGET_TABLES` 兜出来的**(对端是 Django 自带的表)。
1629
1711
  多带一个 `origin` 键:`gitinfo` 会把这几条从基线里剔掉 —— 它们的**正向声明**
1630
1712
  在 Django 的源码里(本项目一行都没有),留着会在画布上变成一条常年不消的"新增"。
@@ -1635,7 +1717,7 @@ def _relation(
1635
1717
  'field': name,
1636
1718
  'kind': kind,
1637
1719
  'cardinality': cardinality,
1638
- 'target': target.name if target is not None else '',
1720
+ 'target': target.name if target is not None else target_name,
1639
1721
  'targetTable': target_table or (target.name if target is not None else ''),
1640
1722
  'column': column,
1641
1723
  'via': via,
@@ -14,11 +14,11 @@ 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, BLOCK_REQUIRES
17
+ from .magic_block.sdk import BLOCK_CONFIG_PREFIX, BLOCK_DEEPCHECK, BLOCK_PROBE, BLOCK_REQUIRES
18
18
 
19
19
  #: 给块的配置前缀:`BLOCK_OCR_KEY=abc` ⇒ 块后端里 `ctx.config['OCR_KEY'] == 'abc'`。
20
20
  #: 🔴 这是**白名单**(不是黑名单):想让块读一项,就给那个变量加这个前缀,不用改库的代码。
21
- BLOCK_CONFIG_PREFIX = 'BLOCK_'
21
+ #: ⚠️ 字面量的真源在 `sdk`(上面那行 import)—— 这里**不许**再写一遍 `'BLOCK_'`。
22
22
 
23
23
  #: `.env` 里一行的形状。允许 `export` 前缀、行首缩进、`=` 两边空白;
24
24
  #: 🔴 **不剥行内注释**(值里真带 `#` 的密钥不该被截断)、**不做变量展开**(那会让"谁先谁后"变成隐式规则)。
@@ -201,11 +201,14 @@ ENV_ITEMS: tuple[EnvItem, ...] = (
201
201
  group='平台开关',
202
202
  ),
203
203
  # 🔴 依赖声明(`requires`)那一层的开关(名字的真源在 `magic_block/sdk.py`):
204
- # 默认 `warn` —— 存量块的清单里一个声明都没有,一上线就硬报错等于让每个块集体停用。
204
+ # 默认 `error`(1.0.1 起)—— "先警告、后强制"里的**后强制**已经落地了。
205
+ # ⚠️ 代价如实说:库里那些**还没声明**的存量块会集体判不合格(全部停用),
206
+ # 所以升级前先用 `manage.py labull_requires --write` 把扫得到的声明批量补进库
207
+ # (那条命令就是为这个默认值而存在的:它是这条路上唯一的恢复口)。
205
208
  EnvItem(
206
209
  name=BLOCK_REQUIRES,
207
- brief='块「依赖声明」(requires)那一层的开关:off 整段不跑 / warn(默认)只提示不拦 / error 变成硬判据',
208
- hint=f'{BLOCK_REQUIRES}=error',
210
+ brief='块「依赖声明」(requires)那一层的开关:off 整段不跑 / warn 只提示不拦(要显式写)/ error(默认)变成硬判据',
211
+ hint=f'{BLOCK_REQUIRES}=warn',
209
212
  group='平台开关',
210
213
  ),
211
214
  # 🔴 体检那条链一次开几个口子(名字的真源在 `magic_block/parallel.py` 的 `ENV_WORKERS`)。
@@ -30,10 +30,7 @@ import ast
30
30
  from dataclasses import dataclass, field
31
31
  from typing import Any
32
32
 
33
- from .sdk import BLOCK_BACKEND_FILE, is_excluded
34
-
35
- #: 块后端的入口文件(**契约**:这个名字不变;它同时是"主文件")。
36
- ENTRY_FILE = BLOCK_BACKEND_FILE
33
+ from .sdk import is_excluded
37
34
 
38
35
 
39
36
  def python_files(files: dict[str, str] | None) -> list[tuple[str, str]]:
@@ -56,24 +53,13 @@ def python_files(files: dict[str, str] | None) -> list[tuple[str, str]]:
56
53
  class LoadedBlock:
57
54
  """一次加载的结果。
58
55
 
59
- · `namespaces`:每个 `.py` 各一份命名空间(键是包内相对路径);
60
56
  · `classes`:**包里所有文件**里定义的类(`类名 → 类`)—— 路由判据要它;
61
57
  · `errors`:逐条"哪个文件怎么了"(作者一眼看得出是哪一份的问题)。
62
58
  """
63
59
 
64
- namespaces: dict[str, dict[str, Any]] = field(default_factory=dict)
65
60
  classes: dict[str, type] = field(default_factory=dict)
66
61
  errors: list[str] = field(default_factory=list)
67
62
 
68
- @property
69
- def ok(self) -> bool:
70
- return not self.errors
71
-
72
- @property
73
- def namespace(self) -> dict[str, Any]:
74
- """主文件那份命名空间(老调用点用得多;主文件不在包里就是空的)。"""
75
- return self.namespaces.get(ENTRY_FILE, {})
76
-
77
63
 
78
64
  def load_block(files: dict[str, str], tag: str = '') -> LoadedBlock:
79
65
  """把包内每个 `.py` 各执行一次(互不影响),收集它们的类。
@@ -90,7 +76,6 @@ def load_block(files: dict[str, str], tag: str = '') -> LoadedBlock:
90
76
  except BaseException as exc: # noqa: BLE001 —— 逐文件报,不让它带崩别的文件
91
77
  result.errors.append(f'{relative} 加载失败:{type(exc).__name__}: {exc}')
92
78
  continue
93
- result.namespaces[relative] = namespace
94
79
  for name, value in namespace.items():
95
80
  # 只看**这个文件自己定义**的公开类(`import` 进来的名字也在命名空间里,但它们
96
81
  # 属于别的文件 —— 那样同一个类会被扫两遍、还可能把项目里的类当成路由类)。
@@ -509,6 +509,9 @@ def _any_probe_note():
509
509
  # 走,所以不额外起进程;`LABULL_REQUIRES=off` 时父进程根本不递(整段不跑)。
510
510
  # 🔴 **"代码用了却没声明"不在这里**(那一半是纯 `ast`,落在 `validate.check_requires`):
511
511
  # 两半分开放,是因为这一半要运行环境、那一半不要 —— 混在一起会让"没 Django 时连静态判据都没跑"。
512
+ # 🔴 **分档不在这里**(`warn` / `error` 是父进程的事,而 `LABULL_REQUIRES` 的默认档
513
+ # 1.0.1 起是 `error`;`warn` 要显式写):这一层只报"事实",落位由 `_requires_check()` 按
514
+ # 父进程递进来的 `mode` 做 —— 分档只有一处判据(`validate.requires_mode()`),两处各判一次迟早漂。
512
515
 
513
516
  _REQUIRES = _GIVEN.get('requires') if isinstance(_GIVEN.get('requires'), dict) else None
514
517
 
@@ -589,8 +592,8 @@ def _model_of(path):
589
592
  def _requires_hard():
590
593
  """"声明了但它不存在"的逐条报文(**确定**的那一档)。
591
594
 
592
- ⚠️ 这里**不分档**(`warn` / `error` 是父进程的事):这一层只报"事实",
593
- 分档只有一处判据(`validate.requires_mode()`),两处各判一次迟早漂。
595
+ ⚠️ 这里**不分档**(`warn` / `error` 是父进程的事,默认档见 `validate.requires_mode()`):
596
+ 这一层只报"事实",而分档只有一处判据,两处各判一次迟早漂。
594
597
  """
595
598
  import importlib
596
599
  from importlib import metadata
@@ -659,9 +662,11 @@ def _requires_hard():
659
662
 
660
663
 
661
664
  def _requires_check():
662
- """跑一遍依赖声明的"存不存在",并按开关落位(`warn` ⇒ 只进备注)。
665
+ """跑一遍依赖声明的"存不存在",并按开关落位(`error` ⇒ 硬判据;`warn` ⇒ 只进备注)。
663
666
 
664
667
  ⚠️ 判据自己崩了**不算这个块的错**(软失败口径见文件头):只记一句说明。
668
+ ⚠️ 兜底值与 `validate.requires_mode()` 的默认档**必须同一个**(1.0.1 起是 `error`):
669
+ 写 `warn` 的话,一份没有 `mode` 键的 payload 会悄悄退回 1.0.0 的旧行为。
665
670
  """
666
671
  if not _REQUIRES:
667
672
  return
@@ -673,7 +678,7 @@ def _requires_check():
673
678
  return
674
679
  if not hard:
675
680
  return
676
- if str(_REQUIRES.get('mode') or 'warn') == 'error':
681
+ if str(_REQUIRES.get('mode') or 'error') == 'error':
677
682
  for message in hard:
678
683
  _STATIC.append(_issue('requires', message))
679
684
  return
@@ -809,6 +814,11 @@ def _value_model(node, imports):
809
814
  model = _manager_of(func.value)
810
815
  if model is None and isinstance(func.value, ast.Call):
811
816
  model = _value_model(func.value, imports) # 链子的里层必须自己也是"查这批 / 查一行"
817
+ # 🔴 这一行与 `validate._manager_model()` 是**同一个口径**(那一份也是"一个循环同时剥
818
+ # `Call` 与 `Attribute`、中间夹着 Call 也照样往左走")。两处**必须一起改** ——
819
+ # 一份真源做不到:那边是 `%` 拼出来的子进程模板字符串,这边是普通函数。
820
+ # 只改一边的症状:`surface` 草稿与深检对同一个模型各说一套(用户截图报的漏建议
821
+ # `StockTransaction.log_event` 就是 `_manager_model` 那一侧停了、这一侧没停)。
812
822
  if model is None:
813
823
  return None
814
824
  # 🔴 "查一批"也算(`for row in OrderRow.objects.all()` 里的 iter 就是这一句):