labull-framework 1.0.1__tar.gz → 1.0.3__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 (56) hide show
  1. {labull_framework-1.0.1/labull_framework.egg-info → labull_framework-1.0.3}/PKG-INFO +6 -1
  2. {labull_framework-1.0.1 → labull_framework-1.0.3}/README-pypi.md +5 -0
  3. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/canvas/data.py +3 -3
  4. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/canvas/gitinfo.py +10 -42
  5. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/canvas/shape_ast.py +96 -14
  6. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/env.py +2 -2
  7. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/magic_block/blockpackage.py +1 -16
  8. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/magic_block/devkit.py +238 -607
  9. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/magic_block/dispatch.py +2 -2
  10. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/magic_block/export.py +53 -25
  11. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/magic_block/sdk.py +4 -5
  12. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/magic_block/validate.py +18 -7
  13. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/magic_block/views.py +13 -4
  14. {labull_framework-1.0.1 → labull_framework-1.0.3/labull_framework.egg-info}/PKG-INFO +6 -1
  15. {labull_framework-1.0.1 → labull_framework-1.0.3}/pyproject.toml +1 -1
  16. {labull_framework-1.0.1 → labull_framework-1.0.3}/LICENSE +0 -0
  17. {labull_framework-1.0.1 → labull_framework-1.0.3}/MANIFEST.in +0 -0
  18. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/__init__.py +0 -0
  19. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/admin.py +0 -0
  20. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/apps.py +0 -0
  21. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/auth/__init__.py +0 -0
  22. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/auth/backend.py +0 -0
  23. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/auth/bootstrap.py +0 -0
  24. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/auth/sso.py +0 -0
  25. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/auth/views.py +0 -0
  26. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/canvas/__init__.py +0 -0
  27. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/canvas/static/canvas.css +0 -0
  28. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/canvas/static/canvas.js +0 -0
  29. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/canvas/static/page.html +0 -0
  30. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/canvas/views.py +0 -0
  31. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/jsonio.py +0 -0
  32. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/magic_block/__init__.py +0 -0
  33. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/magic_block/deepcheck.py +0 -0
  34. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/magic_block/health.py +0 -0
  35. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/magic_block/parallel.py +0 -0
  36. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/magic_block/registry.py +0 -0
  37. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/magic_block/service.py +0 -0
  38. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/magic_block/subproc.py +0 -0
  39. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/management/__init__.py +0 -0
  40. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/management/commands/__init__.py +0 -0
  41. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/management/commands/labull_contract.py +0 -0
  42. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/management/commands/labull_requires.py +0 -0
  43. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/migrations/0001_initial.py +0 -0
  44. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/migrations/0002_remove_magicblock_prev_files_and_more.py +0 -0
  45. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/migrations/0003_magicblock_tags.py +0 -0
  46. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/migrations/0004_magicblock_detail.py +0 -0
  47. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/migrations/__init__.py +0 -0
  48. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/models.py +0 -0
  49. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/settings.py +0 -0
  50. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/urls.py +0 -0
  51. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework/virtual.py +0 -0
  52. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework.egg-info/SOURCES.txt +0 -0
  53. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework.egg-info/dependency_links.txt +0 -0
  54. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework.egg-info/requires.txt +0 -0
  55. {labull_framework-1.0.1 → labull_framework-1.0.3}/labull_framework.egg-info/top_level.txt +0 -0
  56. {labull_framework-1.0.1 → labull_framework-1.0.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 1.0.1
3
+ Version: 1.0.3
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
@@ -139,6 +139,11 @@ class Order:
139
139
 
140
140
  配置:环境变量里**带 `BLOCK_` 前缀**的才会进 `ctx.config`(`BLOCK_OCR_KEY=a` ⇒ `ctx.config['OCR_KEY']`)。
141
141
 
142
+ 🔴 **弹窗走平台**:块里 `await magicBlock.dialog({ title, body, fields, buttons })` —— 由宿主页面渲染
143
+ (前端要挂一次 `<MagicBlockDialogs />`)。块自己画的浮层**摆不正**:块可以是好几屏高,块里
144
+ `position: fixed` / `100vh` 的基准是"整块高度"而不是用户那一屏。要自己写整篇就传 `{ html }`,
145
+ 宿主给一个确定大小的面板 + 另一格沙箱 iframe 装它,里面用 `magicBlock.done({ button, values })` 交回选择。
146
+
142
147
  ## 虚拟类(`@virtual_class`)
143
148
 
144
149
  几张表共有同一批列时,写一个**虚拟类**:一个**不建表的抽象模型**,自己那几列会被继承到子表上。
@@ -111,6 +111,11 @@ class Order:
111
111
 
112
112
  配置:环境变量里**带 `BLOCK_` 前缀**的才会进 `ctx.config`(`BLOCK_OCR_KEY=a` ⇒ `ctx.config['OCR_KEY']`)。
113
113
 
114
+ 🔴 **弹窗走平台**:块里 `await magicBlock.dialog({ title, body, fields, buttons })` —— 由宿主页面渲染
115
+ (前端要挂一次 `<MagicBlockDialogs />`)。块自己画的浮层**摆不正**:块可以是好几屏高,块里
116
+ `position: fixed` / `100vh` 的基准是"整块高度"而不是用户那一屏。要自己写整篇就传 `{ html }`,
117
+ 宿主给一个确定大小的面板 + 另一格沙箱 iframe 装它,里面用 `magicBlock.done({ button, values })` 交回选择。
118
+
114
119
  ## 虚拟类(`@virtual_class`)
115
120
 
116
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
  #: 🔴 **不剥行内注释**(值里真带 `#` 的密钥不该被截断)、**不做变量展开**(那会让"谁先谁后"变成隐式规则)。
@@ -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
  # 属于别的文件 —— 那样同一个类会被扫两遍、还可能把项目里的类当成路由类)。