labull-framework 0.1.26__tar.gz → 0.1.28__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 (54) hide show
  1. {labull_framework-0.1.26/labull_framework.egg-info → labull_framework-0.1.28}/PKG-INFO +1 -1
  2. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/canvas/data.py +16 -8
  3. labull_framework-0.1.28/labull_framework/canvas/gitinfo.py +521 -0
  4. labull_framework-0.1.28/labull_framework/canvas/shape_ast.py +1304 -0
  5. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/canvas/static/canvas.css +19 -3
  6. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/canvas/static/canvas.js +257 -53
  7. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/canvas/static/page.html +3 -3
  8. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/env.py +7 -1
  9. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/magic_block/devkit.py +1123 -1074
  10. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/magic_block/export.py +6 -3
  11. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/magic_block/service.py +19 -5
  12. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/magic_block/validate.py +95 -17
  13. {labull_framework-0.1.26 → labull_framework-0.1.28/labull_framework.egg-info}/PKG-INFO +1 -1
  14. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework.egg-info/SOURCES.txt +1 -0
  15. {labull_framework-0.1.26 → labull_framework-0.1.28}/pyproject.toml +1 -1
  16. labull_framework-0.1.26/labull_framework/canvas/gitinfo.py +0 -155
  17. {labull_framework-0.1.26 → labull_framework-0.1.28}/LICENSE +0 -0
  18. {labull_framework-0.1.26 → labull_framework-0.1.28}/MANIFEST.in +0 -0
  19. {labull_framework-0.1.26 → labull_framework-0.1.28}/README-pypi.md +0 -0
  20. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/__init__.py +0 -0
  21. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/admin.py +0 -0
  22. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/apps.py +0 -0
  23. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/auth/__init__.py +0 -0
  24. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/auth/backend.py +0 -0
  25. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/auth/bootstrap.py +0 -0
  26. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/auth/sso.py +0 -0
  27. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/auth/views.py +0 -0
  28. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/canvas/__init__.py +0 -0
  29. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/canvas/views.py +0 -0
  30. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/jsonio.py +0 -0
  31. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/magic_block/__init__.py +0 -0
  32. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/magic_block/blockpackage.py +0 -0
  33. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/magic_block/deepcheck.py +0 -0
  34. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/magic_block/dispatch.py +0 -0
  35. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/magic_block/health.py +0 -0
  36. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/magic_block/parallel.py +0 -0
  37. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/magic_block/registry.py +0 -0
  38. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/magic_block/sdk.py +0 -0
  39. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/magic_block/views.py +0 -0
  40. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/management/__init__.py +0 -0
  41. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/management/commands/__init__.py +0 -0
  42. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/management/commands/labull_contract.py +0 -0
  43. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/migrations/0001_initial.py +0 -0
  44. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/migrations/0002_remove_magicblock_prev_files_and_more.py +0 -0
  45. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/migrations/0003_magicblock_tags.py +0 -0
  46. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/migrations/0004_magicblock_detail.py +0 -0
  47. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/migrations/__init__.py +0 -0
  48. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/models.py +0 -0
  49. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/settings.py +0 -0
  50. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework/urls.py +0 -0
  51. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework.egg-info/dependency_links.txt +0 -0
  52. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework.egg-info/requires.txt +0 -0
  53. {labull_framework-0.1.26 → labull_framework-0.1.28}/labull_framework.egg-info/top_level.txt +0 -0
  54. {labull_framework-0.1.26 → labull_framework-0.1.28}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 0.1.26
3
+ Version: 0.1.28
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
@@ -456,9 +456,12 @@ def _enums_of(model: Any) -> dict[str, dict[str, Any]]:
456
456
  def payload() -> dict[str, Any]:
457
457
  """画布整包:当前形状 + 与 git 那份比出来的三态 + 头部那行 git 信息 + 布局。
458
458
 
459
+ 🔴 **基线是"算出来的"**(开发者明确要的):拿基线那一版提交里的 `models.py` 用 `ast`
460
+ 解析出形状,与进程内这一份做差 —— **不需要任何人导出 / 提交一份平行产物**。
461
+ 口径与兜底见 `gitinfo.baseline_tables()`;"哪一种来源"单独给一列 `baselineSource`。
459
462
  🔴 **被删掉的表也要带上**(`deletedTables`):不然页面上只有"删除 N"这个数字,
460
463
  看不到删掉的是哪张、原来有哪些列 —— 那正是重构时最需要看的东西。
461
- 它们从基线那份契约里取,列结构与当前形状同形(老契约里没有的新字段按缺省补)。
464
+ 它们从基线那份形状里取,列结构与当前形状同形(老契约里没有的新字段按缺省补)。
462
465
  """
463
466
  from . import gitinfo
464
467
 
@@ -487,6 +490,10 @@ def payload() -> dict[str, Any]:
487
490
  'deletedRelations': _deleted_relations(baseline, relation_diff),
488
491
  'git': gitinfo.head(),
489
492
  'baseline': 'none' if baseline is None else 'present',
493
+ # 🔴 **这份基线是怎么来的**(`code` = 从那个提交的源码算的 / `contract` = 提交里那份
494
+ # JSON / `none` = 没有可比基线):必须写到页面上 —— 人得能分清
495
+ # "这里全清"是"真没改"还是"基线没取到"(见 `gitinfo.baseline_source()`)。
496
+ 'baselineSource': gitinfo.baseline_source(),
490
497
  'layout': read_layout(),
491
498
  }
492
499
 
@@ -670,11 +677,12 @@ def _field_diff(
670
677
  # 于是"模型里删了一个字段"这件事保存时不报、体检全绿,一路到某个请求才 500。
671
678
  # 而"哪几个字段是这次被删掉的"画布那侧**早就算出来了**(`_field_diff`)—— 把它交给深检,
672
679
  # 一次纯文本比对就能把那条引用揪出来(不需要任何类型推断、误报率极低)。
673
- # 🔴 **快照按"仓库 + 提交"缓存**:`git` 一次要起一个进程(`gitinfo._run` 自己也带 5 秒超时),
674
- # 而全量体检会对每个块各问一次。缓存键是 `(仓库根, HEAD, 契约文件的状态)`,
675
- # 所以"换个项目 / 又提交了一版 / 契约文件被重新导出"都会当场失效(不会拿旧集合去比)。
676
- # 🔴 **一律不抛**:没有 git / 不是仓库 / 契约坏了 ⇒ 空快照(`deepcheck` 那一侧因此**一条都不报**)。
677
- # 基线是可选的加速度,不是判据的前置条件。
680
+ # 🔴 **快照按"仓库 + 提交"缓存**:算一份基线要起两个 git 进程(`gitinfo._run` 自己也带 5 秒超时),
681
+ # 而全量体检会对每个块各问一次。缓存键是 `(仓库根, HEAD, 契约文件的状态)` ——
682
+ # 换个项目 / 又提交了一版都会当场失效;`gitinfo` 那一层自己还有一份更细的缓存
683
+ # (键里带"基线那一版 + 源码状态"),所以这里不必再管"基线换没换"。
684
+ # 🔴 **一律不抛**:没有 git / 不是仓库 / 那个提交里读不出源码 ⇒ 空快照
685
+ # (`deepcheck` 那一侧因此**一条都不报**)。基线是可选的加速度,不是判据的前置条件。
678
686
 
679
687
  #: 快照缓存:`(仓库根, HEAD, 契约状态) → ({模型名: {deleted, renamed}}, 契约文件状态)`。
680
688
  _BASELINE_CACHE: dict[tuple[str, str, float], tuple[dict[str, dict[str, Any]], float]] = {}
@@ -733,7 +741,7 @@ def _dead_models(diff: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
733
741
  def _baseline_snapshot() -> dict[str, dict[str, Any]]:
734
742
  """这一次的"死字段"快照:`{模型名: {'deleted', 'renamed'}}`(拿不到就回空)。
735
743
 
736
- :return: 空字典 = 没有可比基线(不是仓库 / 提交里没有契约 / 契约坏了 / 没有 Django)。
744
+ :return: 空字典 = 没有可比基线(不是仓库 / 那个提交里读不出模型源码 / 没有 Django)。
737
745
  """
738
746
  from . import gitinfo
739
747
 
@@ -753,7 +761,7 @@ def _baseline_snapshot() -> dict[str, dict[str, Any]]:
753
761
  if hit is not None and hit[1] == state:
754
762
  return hit[0]
755
763
  # 🔴 **两个入参的方向**(这里写错过一次,而它不报错、只是"一条都不报"):
756
- # 第一份是**契约那一版**(= 基线),第二份是**现在这份形状**(`shape()`)。
764
+ # 第一份是**基线那一版**(从那个提交的源码算出来的),第二份是**现在这份形状**(`shape()`)。
757
765
  # 写成 `_field_diff(baseline, _columns_for_diff(baseline))` 就是"拿基线跟自己比" ⇒
758
766
  # 恒为空 ⇒ 整条判据静默失效(症状与"没有基线"一模一样,所以最难发现)。
759
767
  baseline = gitinfo.baseline_tables()
@@ -0,0 +1,521 @@
1
+ """画布要的 git 那一半:当前提交、那一行备注、以及**上一版**的模型形状。
2
+
3
+ 🔴 **上一版是"算出来的",不是"存出来的"**(开发者明确要的:"基线由代码本身推出来,
4
+ 而不是维护一个平行产物"):
5
+ 老做法要人跑 `manage.py labull_contract --out .labull/contract.json` 再提交,
6
+ 画布去 `git show` 那份 JSON。代价是一份**必须人记得更新**的平行产物 ——
7
+ 忘了跑 / 忘了提交 / 模型改了而 JSON 没重导,画布要么满屏"新增"、要么一点差异都没有
8
+ (这个仓库就是这样:那份 JSON 还停在初始提交,30 张表里只有 3 张)。
9
+ 现在改成分两步读**那个提交里的源码**(`canvas/shape_ast.py` 用 `ast` 解析):
10
+ 上一版长什么样,本来就在 git 里。(原话:*"上一版的模型形状本来就在 git 里"*。)
11
+
12
+ 🔴 **比的是 `parent`,不是 `HEAD`**:画布要回答的是"**你这次改了什么**" ——
13
+ 一个干净的 `git commit` 之后,`HEAD` 里那份形状与工作区**逐字相同**,画布会全清;
14
+ 而人真正想看的是"我上一笔提交动了哪些表"。两种口径各有用处,所以
15
+ `LABULL_BASELINE_REV` 可以切(`head` = 只看没提交的改动)。
16
+ ⚠️ 只有一个提交的项目 `parent` 不存在 ⇒ 退回 `HEAD`(新版全是新增,这是**正常状态**)。
17
+ 🔴 **不是仓库 / 那个提交里读不出源码 ⇒ 回 `None`**("没有可比基线"是**正常状态**,不是错误)。
18
+ 🔴 这一层的输出只影响"差异"那几列;**当前形状完全不依赖 git**(它读的是进程内的模型元数据)。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import subprocess
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from django.conf import settings
29
+
30
+ #: 模型形状 JSON 的落点(相对项目根)。它由 `manage.py labull_contract --out` 写出,**可选**:
31
+ #: 现在它只是**兜底**(源码解析认不出来时才用)—— 正常情况下画布不需要它存在。
32
+ DEFAULT_CONTRACT_PATH = '.labull/contract.json'
33
+
34
+ #: git 单次调用的超时(秒):它是画布页面的一个同步依赖,卡住就等于页面卡住。
35
+ _TIMEOUT = 5
36
+
37
+ #: 从**基线那一版**里要读的那几个文件名(每个 app 一份 `models.py` + 它的 `apps.py`)。
38
+ #: ⚠️ `settings.py` **不在这里**:它只在"当前这一份"里读(见 `_project_settings()`)——
39
+ #: `INSTALLED_APPS` / `AUTH_USER_MODEL` 不参与差异判据,读提交里那一份反而会把
40
+ #: "某个 app 刚好叫 settings"这种形状搅进来。
41
+ #: ⚠️ 一个 app 里模型分成多个文件的(`models/` 是个包)**今天不支持**:那种情况下
42
+ #: `git show <rev>:<app>/models.py` 取不到东西 ⇒ 这个 app 从基线里缺席(会报成"整张表新增")。
43
+ #: 要支持它就得多读一棵子树 + 认 `models/__init__.py` 的 re-export,那是另一件事。
44
+ _SOURCE_FILES = ('models.py', 'apps.py')
45
+
46
+ #: 解析结果的记忆(键是"仓库 + 那一版 + 输入的状态",见 `_revision_tables()`)。
47
+ #: ⚠️ **必须缓存**:画布每次请求都要这份基线,而算一次要起两个 git 进程。
48
+ _DERIVED_CACHE: dict[tuple[str, str, str], list[dict[str, Any]] | None] = {}
49
+
50
+ #: 那几个"一次请求里要问好几遍"的 git 事实(仓库根 / 当前提交 / 基线提交)。
51
+ #:
52
+ #: 🔴 为什么连它们也要缓存:**在 Windows 上起一个 git 进程大约 40ms**(实测),
53
+ #: 而一份基线要问三次(仓库根、HEAD、HEAD~1)⇒ 光"问路"就 120ms。
54
+ #: 缓存了"算出来的表"却没缓存"问路"的话,第二次请求照样要起三个进程 ——
55
+ #: 那正是"缓存最没用的写法"(真量过:冷 92ms / 热 96ms,等于没缓存)。
56
+ #: ⚠️ 失效靠 `_sources_state()`(项目 `settings.py` + 本模块与 `shape_ast.py` 的 mtime):
57
+ #: 换了仓库 / 新提交 / 改了这一层的代码都会当场重算,见 `_memo()`。
58
+ _MEMO: dict[str, Any] = {'state': None, 'values': {}}
59
+
60
+
61
+ def _run(args: list[str], cwd: Path) -> str | None:
62
+ """跑一条只读的 git 命令,返回 stdout(失败一律回 `None`)。
63
+
64
+ 🔴 **必须显式 `encoding='utf-8'`**:`text=True` 在 Windows 上默认用**系统 ANSI 代码页(GBK)**
65
+ 解码,而提交说明是中文 UTF-8 ⇒ 解码线程抛 `UnicodeDecodeError`,`stdout` 变成 `None`,
66
+ 于是下一句 `.strip()` 报 `'NoneType' object has no attribute 'strip'` ——
67
+ 症状是 `/canvas` 直接 500,而真因跟"模型"一个字都没关系(真踩过)。
68
+ `errors='replace'` 是第二道保险:真碰上编码诡异的输出,也宁可显示成问号,不要让整页崩掉。
69
+ """
70
+ try:
71
+ done = subprocess.run(
72
+ ['git', *args],
73
+ cwd=str(cwd),
74
+ capture_output=True,
75
+ encoding='utf-8',
76
+ errors='replace',
77
+ timeout=_TIMEOUT,
78
+ check=False,
79
+ )
80
+ except (OSError, subprocess.SubprocessError):
81
+ # git 没装 / 不是仓库 / 超时:都是"这一层拿不到信息",不是错误(差异那几列留空即可)。
82
+ return None
83
+ output = done.stdout or ''
84
+ return output.strip() if done.returncode == 0 else None
85
+
86
+
87
+ def _memo(name: str, compute: Any) -> Any:
88
+ """把一次"要起 git 进程"的结论记下来(失效条件见 `_MEMO` 与 `_sources_state()`)。
89
+
90
+ ⚠️ `compute` 只有真的算的时候才调 —— 命中的路径上**一个进程都不起**。
91
+ """
92
+ state = _sources_state()
93
+ if _MEMO['state'] != state:
94
+ _MEMO['state'] = state
95
+ _MEMO['values'] = {}
96
+ values = _MEMO['values']
97
+ if name not in values:
98
+ values[name] = compute()
99
+ return values[name]
100
+
101
+
102
+ def _root() -> Path | None:
103
+ """项目根(`settings.BASE_DIR`)—— 那条"相对项目"的契约路径是相对它算的。"""
104
+ base = getattr(settings, 'BASE_DIR', None)
105
+ return Path(base) if base else None
106
+
107
+
108
+ def _repo_root() -> Path | None:
109
+ """**git 仓库根**(`git rev-parse --show-toplevel`)。
110
+
111
+ 🔴 为什么不能拿 `BASE_DIR` 当仓库根:`BASE_DIR` 是**项目**根(示例里是 `backend/example`),
112
+ 而 `git show HEAD:<路径>` 里的路径是**相对仓库根**的。仓库根只要在更上面一层
113
+ (这个仓库就是:`BASE_DIR` 在 `backend/example`,仓库根在它上两层),
114
+ 那条命令就会失败、`baseline_tables()` 永远回 `None` ——
115
+ 症状是画布永远显示"没有可比基线",而"提交过契约"这件事一点错都不报(真踩过)。
116
+ ⚠️ 结果按 `_sources_state()` 记忆(见 `_MEMO`):它每次请求都要被问两三遍。
117
+ """
118
+
119
+ def compute() -> Path | None:
120
+ base = _root()
121
+ if base is None:
122
+ return None
123
+ top = _run(['rev-parse', '--show-toplevel'], base)
124
+ return Path(top) if top else None
125
+
126
+ return _memo('repo_root', compute)
127
+
128
+
129
+ def contract_path() -> str:
130
+ """那份 JSON 的落点(项目可用 `LABULL_CONTRACT_PATH` 覆盖)。"""
131
+ from .. import env
132
+
133
+ return env.read_env('LABULL_CONTRACT_PATH') or DEFAULT_CONTRACT_PATH
134
+
135
+
136
+ def baseline_rev() -> str | None:
137
+ """**拿什么当"上一版"**:`HEAD` / `HEAD~1` / 项目自己指定的一个提交号。
138
+
139
+ 默认 `LABULL_BASELINE_REV` 是**空**:那时自动取 `HEAD~1`("上一次提交改了什么"),
140
+ 没有父提交就退回 `HEAD`(第一个提交 ⇒ 全部算新增,这是正常状态)。
141
+ 两个特殊值:`head` = 只看**还没提交**的那些改动;`off` / `none` = 不做差异。
142
+ ⚠️ 结果按 `_sources_state()` 记忆(见 `_MEMO`):它每次请求都要被问两三遍。
143
+ """
144
+
145
+ def compute() -> str | None:
146
+ from .. import env
147
+
148
+ repo = _repo_root()
149
+ if repo is None:
150
+ return None
151
+ head = _run(['rev-parse', 'HEAD'], repo)
152
+ if not head:
153
+ return None
154
+ want = (env.read_env('LABULL_BASELINE_REV') or '').strip()
155
+ low = want.lower()
156
+ if low in ('off', 'none', 'no'):
157
+ return None
158
+ if not want:
159
+ parent = _run(['rev-parse', 'HEAD~1'], repo)
160
+ return (parent or head)[:12]
161
+ if low == 'head':
162
+ return head[:12]
163
+ resolved = _run(['rev-parse', '--verify', f'{want}^{{commit}}'], repo)
164
+ return (resolved or '')[:12] or None
165
+
166
+ return _memo('baseline_rev', compute)
167
+
168
+
169
+ def baseline_source() -> str:
170
+ """这一版基线是**怎么来的**:`code`(从源码算的)/ `contract`(提交里那份 JSON)/ `none`。
171
+
172
+ 画布头部那一行按它说人话 —— "这份差异是哪来的"必须写在页面上,
173
+ 否则人没法判断"为什么这里全清"是"真没改"还是"基线没取到"。
174
+ """
175
+ if baseline_rev() is None:
176
+ return 'none'
177
+ if _revision_tables() is not None:
178
+ return 'code'
179
+ return 'contract' if contract_tables() is not None else 'none'
180
+
181
+
182
+ def head() -> dict[str, Any] | None:
183
+ """头部那一行:提交号 / 备注 / 有没有父提交 / 这次改了哪些文件。
184
+
185
+ 备注优先取中文(`备注:` 那一行,否则含中文的首行)—— 中文项目里英文的 commit 标题没什么信息量。
186
+ """
187
+ root = _root()
188
+ if root is None:
189
+ return None
190
+ full = _run(['rev-parse', 'HEAD'], root)
191
+ if not full:
192
+ return None
193
+ message = _run(['log', '-1', '--pretty=format:%s'], root) or ''
194
+ parent = _run(['rev-parse', f'{full}~1'], root)
195
+ files = (_run(['diff', '--name-only', parent, full], root) or '').splitlines() if parent else []
196
+ # 🔴 `baseRev` 是**基线那一版**(不是"父提交"):页面要能说清"跟哪一版在比"。
197
+ return {
198
+ 'hash': full[:8],
199
+ 'msg': _chinese_remark(message),
200
+ 'hasParent': bool(parent),
201
+ 'files': [line.strip() for line in files if line.strip()],
202
+ 'baseRev': (baseline_rev() or '')[:12],
203
+ }
204
+
205
+
206
+ def _chinese_remark(message: str) -> str:
207
+ for line in message.splitlines():
208
+ head, sep, tail = line.strip().partition(':')
209
+ if sep and head.lower() in ('备注', 'remark') and tail.strip():
210
+ return tail.strip()
211
+ for line in message.splitlines():
212
+ text = line.strip()
213
+ if text and any('\u4e00' <= ch <= '\u9fa5' for ch in text):
214
+ return text
215
+ return message.splitlines()[0].strip() if message else ''
216
+
217
+
218
+ def baseline_tables() -> list[dict[str, Any]] | None:
219
+ """**上一版**的表定义(从 git 的那个提交里**算**出来)。
220
+
221
+ 三种返回值的语义不同,别混:`None` = 没有可比基线(不是仓库 / 那个提交里读不到源码 /
222
+ 源码写坏了);`[]` = 有基线,但基线里确实一张表都没有;有内容 = 正常逐表比。
223
+
224
+ 🔴 两条来源,**顺序有意义**:
225
+ ① **从那个提交的源码算**(`shape_ast`)—— 它是"上一版的模型形状"的真相,
226
+ 而且不需要任何人记得维护什么文件;
227
+ ② 退到提交里那份 `contract.json`(老做法):只在①读不出来时用
228
+ (源码语法坏掉 / 模型写在 `models/` 包里这种今天不支持的形状)。
229
+ ⚠️ 有一类**已知的**假差异(装了 `django.contrib.admin` 时用户表上那条 `logentry`),
230
+ 见 `_known_limitation()` —— 那是"从源码算"这条路的固有边界,不是可以顺手滤掉的噪声
231
+ (试过滤,越滤越糟)。
232
+ """
233
+ derived = _revision_tables()
234
+ return derived if derived is not None else contract_tables()
235
+
236
+
237
+ def _known_limitation() -> str:
238
+ """这一层**已知**的一类假差异(写给下一个人看,别再去踩)。
239
+
240
+ 装过 `django.contrib.admin` 的项目里,`django.contrib.admin.LogEntry` 有一条外键指向**你的**
241
+ 用户表 ⇒ `_meta.get_fields()` 会在你的用户表上给出 `logentry` 这条反向关联,
242
+ 而它的正向声明在 Django 自己的源码里(这个项目的 git 里一行都没有)——
243
+ 所以"从源码算基线"这条路**永远算不出它**,画布上会常年挂着一条"新增的关联"。
244
+ ⚠️ 试过在基线里把它剔掉(两条路都试过:靠"差分反推来源"、靠 `origin` 标记):
245
+ **越剔越糟** —— 剔掉之后基线里没有、现在这一份里有,差异恰好从"没多出来"变成"新增"。
246
+ 要根治只能"让基线多知道一条 Django 自己那边的关联",那是另一件事(读 Django 的源码 +
247
+ 把它的正向声明也算进来)。今天只留这一条说明。
248
+ """
249
+ return 'labull_user.logentry(装了 admin 时)'
250
+
251
+
252
+ def contract_tables() -> list[dict[str, Any]] | None:
253
+ """提交里那份 `contract.json` 的表定义(**兜底**来源;没有就是 `None`)。"""
254
+ repo = _repo_root()
255
+ if repo is None:
256
+ return None
257
+ relative = _repo_relative_contract(repo)
258
+ if relative is None:
259
+ return None
260
+ revision = baseline_rev()
261
+ if revision is None:
262
+ return None
263
+ text = _run(['show', f'{revision}:{relative}'], repo)
264
+ if not text:
265
+ return None
266
+ try:
267
+ parsed = json.loads(text)
268
+ except ValueError:
269
+ # 提交里那份坏了(手改过 / 写到一半就提交了)⇒ 当"没有基线",
270
+ # 而不是拿半份数据去比(那会报出一堆假的新增 / 删除)。
271
+ return None
272
+ tables = parsed.get('tables') if isinstance(parsed, dict) else None
273
+ return tables if isinstance(tables, list) else None
274
+
275
+
276
+ def _revision_tables() -> list[dict[str, Any]] | None:
277
+ """从基线那一版的**源码**算出来的表定义(算不出来回 `None`)。
278
+
279
+ 🔴 **缓存键是"仓库 + 那一版 + 输入的状态"**(与 `data._BASELINE_CACHE` 同一套形状):
280
+ 算一次要起 2 个 git 进程(`ls-tree` + 批量 `cat-file`),而画布每次请求都要它、
281
+ 全量体检还会对每个块各问一次。**先查缓存再读 git** 是这一层的全部意义所在
282
+ (反过来的话,每次请求都白起一轮进程 —— 那正是"缓存"最没用的写法)。
283
+ ⚠️ 状态取的是 `_sources_state()`(项目 `settings.py` + 这一层自己的 mtime),
284
+ 不是"源码的 blob 号":**源码在 git 里,由提交号管**;而"换了仓库 / 改了这一层的代码"
285
+ 由状态管(少了后一项,开发这一层时会量到已经修掉的旧结果)。
286
+ """
287
+ repo = _repo_root()
288
+ revision = baseline_rev()
289
+ if repo is None or revision is None:
290
+ return None
291
+ state = _sources_state()
292
+ key = (str(repo), revision, f'{state:.6f}')
293
+ cached = _DERIVED_CACHE.get(key)
294
+ if cached is not None or key in _DERIVED_CACHE:
295
+ return cached
296
+ files = _revision_sources(repo, revision)
297
+ apps = _group_apps(files) if files else {}
298
+ tables: list[dict[str, Any]] | None = None
299
+ if apps:
300
+ try:
301
+ from . import shape_ast
302
+
303
+ tables = shape_ast.derive(apps) or None
304
+ except Exception: # noqa: BLE001 —— 这一层出错只是"没有可比基线",不该让画布挂掉
305
+ tables = None
306
+ # ⚠️ 一次只留最新那几份;**`None` 也缓存**("这个提交里算不出来"是有意义的结论)。
307
+ if len(_DERIVED_CACHE) > 8:
308
+ _DERIVED_CACHE.clear()
309
+ _DERIVED_CACHE[key] = tables
310
+ return tables
311
+
312
+
313
+ def _sources_state() -> float:
314
+ """这一层的输入**现在的**状态(拿不到任何文件时回 `0.0`)。
315
+
316
+ 🔴 **三个东西都要算进来**,缺一个就有一类"改了没生效":
317
+ · 项目那份 `settings.py`(`INSTALLED_APPS` / `AUTH_USER_MODEL` 从它来)—— 换了它就换了输入;
318
+ · **本模块和 `shape_ast.py` 自己**的 mtime —— 改了"怎么算基线"的代码而缓存不失效,
319
+ 症状是"代码明明改了、量到的还是旧结果"(开发这一层时必踩;真踩过,
320
+ 为了一个已经修掉的假差异查了半小时,而进程里的缓存还捧着旧的那份)。
321
+ ⚠️ 基线里的**源码**不用管:它在 git 里,由提交号管。
322
+ """
323
+ base = _root()
324
+ newest = 0.0
325
+ if base is not None:
326
+ for name in ('config/settings.py', 'settings.py'):
327
+ try:
328
+ path = base / name
329
+ if path.is_file():
330
+ newest = max(newest, path.stat().st_mtime)
331
+ except OSError:
332
+ continue
333
+ here = Path(__file__).resolve().parent
334
+ for name in ('gitinfo.py', 'shape_ast.py'):
335
+ try:
336
+ newest = max(newest, (here / name).stat().st_mtime)
337
+ except OSError:
338
+ continue
339
+ return newest
340
+
341
+
342
+ def _group_apps(files: list[tuple[str, str, str]]) -> dict[str, dict[str, str]]:
343
+ """把"仓库里的文件"归到各个 app 名下(`shape_ast.derive()` 的入参形态)。
344
+
345
+ 每个 app 会多带一个 `__package__`:它是"去读这个 app 里某个类的源码"的唯一线索 ——
346
+ 少了它,本 app 的模型继承外部基类(`AbstractUser`)时继承链会断在第一个基类上。
347
+
348
+ 🔴 `__package__` 必须是**能 import 的那个模块路径**,不是仓库里的目录路径:
349
+ 仓库根的目录名(`backend`、`all files`)未必是合法的包名,而 `find_spec()` 只认
350
+ `sys.path` 上的名字。判据是"往上找到第一个带 `__init__.py` 的目录" ——
351
+ 与 Python 的包定义完全一致,所以 `backend/example/orders` 会得到 `orders`
352
+ (`backend/` 里没有 `__init__.py`),而 `backend/labull_framework` 得到 `labull_framework`。
353
+ """
354
+ packages = _package_of_directory(files)
355
+ apps: dict[str, dict[str, str]] = {}
356
+ for path, _blob, text in files:
357
+ parts = path.split('/')
358
+ if len(parts) < 2:
359
+ continue
360
+ directory = '/'.join(parts[:-1])
361
+ package = packages.get(directory, directory.replace('/', '.'))
362
+ app = apps.setdefault(package, {'__package__': package})
363
+ app[parts[-1]] = text
364
+ # `settings.py` 要在**每个** app 眼里都看得见(`AUTH_USER_MODEL` / `INSTALLED_APPS` 在那儿)。
365
+ settings_text = _project_settings()
366
+ if settings_text:
367
+ for app in apps.values():
368
+ app.setdefault('settings.py', settings_text)
369
+ return apps
370
+
371
+
372
+ def _package_of_directory(files: list[tuple[str, str, str]]) -> dict[str, str]:
373
+ """仓库里的目录路径 → **可 import 的包路径**(见 `_group_apps()` 里那条说明)。
374
+
375
+ 🔴 判据用**文件清单本身**里的 `__init__.py`,不去碰文件系统:
376
+ `git ls-tree` 已经把那一版的所有文件都列出来了,"哪个目录是包"就在那份清单里。
377
+ 去看工作区会得出"现在是不是包"的答案,而我们要的是**那一版**的答案。
378
+ 🔴 走法是"**从最上面那个包往下拼**":先一路往上找到最高的那个包目录
379
+ (它的父目录不是包 ⇒ 那里才是 `sys.path` 的入口),再把它下面的各段拼成模块名。
380
+ `backend/example/orders`:`backend/example` 不是包 ⇒ 入口是它,模块名 = `orders`;
381
+ `backend/labull_framework/canvas`:`backend/labull_framework` 是包、它的父目录不是
382
+ ⇒ 入口是 `backend/`,模块名 = `labull_framework.canvas`。
383
+ """
384
+ package_dirs = {
385
+ path.rsplit('/__init__.py', 1)[0]
386
+ for path in (one[0] for one in files)
387
+ if path.endswith('/__init__.py')
388
+ }
389
+
390
+ def package_of(directory: str) -> str:
391
+ parts = directory.split('/') if directory else []
392
+ if not parts:
393
+ return ''
394
+ top = len(parts) - 1
395
+ while top > 0 and '/'.join(parts[:top]) in package_dirs:
396
+ top -= 1
397
+ return '.'.join(parts[top:])
398
+
399
+ return {
400
+ directory: package_of(directory)
401
+ for directory in {'/'.join(path.split('/')[:-1]) for path, _blob, _text in files}
402
+ }
403
+
404
+
405
+ def _project_settings() -> str:
406
+ """项目自己的 `settings.py`(从**工作区**读 —— 只有 `AUTH_USER_MODEL` / `INSTALLED_APPS`
407
+ 这两项从这里取,它们不参与差异判据,所以读当前这一份是安全的)。
408
+
409
+ 🔴 **不读提交里那一份**:万一项目里真有个 app 叫 `settings`,按"哪个 app 的"归类就会造出
410
+ 一个假的 app;而这两项本来也不该跟着提交变(换用户表是一次全局迁移,不是"这次改了什么")。
411
+ ⚠️ 找不到就回空:那时 `INSTALLED_APPS` 读不出来 ⇒ `shape_ast` 一律放行(宁可多画也不要漏)。
412
+ """
413
+ base = _root()
414
+ if base is None:
415
+ return ''
416
+ for name in ('config/settings.py', 'settings.py'):
417
+ path = base / name
418
+ try:
419
+ if path.is_file():
420
+ # 同 `_parse_batch()` / `shape_ast._read_file_of_module()`:带 BOM 也要读得进来。
421
+ return path.read_text(encoding='utf-8-sig', errors='replace')
422
+ except OSError:
423
+ continue
424
+ return ''
425
+
426
+
427
+ def _revision_sources(repo: Path, revision: str) -> list[tuple[str, str, str]]:
428
+ """基线那一版里、**每一个 app 的那几份源码**:`[(仓库相对路径, blob 号, 正文)]`。
429
+
430
+ 🔴 一次 `ls-tree` + 一次批量 `git show --stdin`:**不为每个文件各起一个进程** ——
431
+ 这一层是页面请求里的同步依赖,进程数就是延迟。
432
+ ⚠️ 路径一律**相对仓库根**(`git show` 只认这种):`ls-tree` 的输出是相对**当前目录**的,
433
+ 所以这里用 `--full-tree` 把那个坑直接绕开(两个工具的口径必须对齐,见 `_repo_root()`)。
434
+ """
435
+ listing = _run(['ls-tree', '-r', '--full-tree', revision], repo)
436
+ if not listing:
437
+ return []
438
+ wanted: dict[str, str] = {}
439
+ for line in listing.splitlines():
440
+ meta, _, path = line.partition('\t')
441
+ parts = meta.split()
442
+ if len(parts) < 3 or parts[1] != 'blob':
443
+ continue
444
+ name = path.rsplit('/', 1)[-1]
445
+ # ⚠️ `settings.py` **不从这里取**:它只在"当前这一份"里读(见 `_project_settings()`)——
446
+ # `INSTALLED_APPS` / `AUTH_USER_MODEL` 不参与差异判据,而把它按"哪个 app 的"
447
+ # 归错类(项目里真有个 app 叫 `settings`?)反而会造出一个假的 app。
448
+ if name in _SOURCE_FILES:
449
+ wanted[path] = parts[2]
450
+ if not wanted:
451
+ return []
452
+ stdin = '\n'.join(f'{revision}:{path}' for path in sorted(wanted))
453
+ try:
454
+ done = subprocess.run(
455
+ ['git', 'cat-file', '--batch'],
456
+ cwd=str(repo),
457
+ input=stdin.encode('utf-8'),
458
+ capture_output=True,
459
+ timeout=_TIMEOUT,
460
+ check=False,
461
+ )
462
+ except (OSError, subprocess.SubprocessError):
463
+ return []
464
+ if done.returncode != 0 or not done.stdout:
465
+ return []
466
+ return _parse_batch(done.stdout, wanted)
467
+
468
+
469
+ def _parse_batch(raw: bytes, wanted: dict[str, str]) -> list[tuple[str, str, str]]:
470
+ """`git cat-file --batch` 的输出 → `[(路径, blob 号, 正文)]`(保持请求顺序)。
471
+
472
+ 🔴 用 `cat-file --batch` 而不是 `git show --stdin`:前者是**逐对象**的
473
+ `<sha> blob <字节数>\\n<正文>\\n`,切起来是字节级的、没有歧义;
474
+ 后者的输出里混着 commit 头(`Author` / `Date` / 缩进的说明),靠"第几段"对回文件名
475
+ —— 一旦哪个文件正文里出现一行 `commit ...` 就会切错(真踩过这种歧义)。
476
+ 🔴 **必须按字节切**:头里的 `<字节数>` 是字节数,而这个仓库的源码是中文 UTF-8
477
+ (一个字三字节)—— 按字符切会越切越偏,最后把所有文件的内容错位。
478
+ """
479
+ order = sorted(wanted)
480
+ out: list[tuple[str, str, str]] = []
481
+ cursor = 0
482
+ for path in order:
483
+ line_end = raw.find(b'\n', cursor)
484
+ if line_end < 0:
485
+ break
486
+ header = raw[cursor:line_end].decode('utf-8', 'replace')
487
+ cursor = line_end + 1
488
+ parts = header.split()
489
+ if len(parts) < 3 or parts[1] != 'blob':
490
+ # 这个对象不在(`<rev>:<path>` 取不到)⇒ 停在这里:剩下的对不上号了。
491
+ break
492
+ try:
493
+ size = int(parts[2])
494
+ except ValueError:
495
+ break
496
+ body = raw[cursor:cursor + size]
497
+ cursor += size + 1
498
+ # 🔴 **按 `utf-8-sig` 解码**(不是 `utf-8`):作者那份 `models.py` 可能是"UTF-8 带 BOM"
499
+ # 存下来的(编辑器默认就这样),而 CPython 导入源码时 BOM 是被吃掉的 ⇒ Django 那边
500
+ # 一切正常。我们这边留着那个 `\ufeff` 的话,`ast.parse()` 当场抛
501
+ # `SyntaxError: invalid non-printable character U+FEFF` ⇒ 这个 app 从基线里缺席,
502
+ # 画布报"整张表新增"。见 `shape_ast._read_file_of_module()` 里同一条说明。
503
+ out.append((path, parts[0], body.decode('utf-8-sig', 'replace')))
504
+ return out
505
+
506
+
507
+ def _repo_relative_contract(repo: Path) -> str | None:
508
+ """契约路径换算成**相对仓库根**的形式(`git show` / `git diff` 认这个)。
509
+
510
+ 契约在仓库外面(项目根不在仓库里,或者契约被 `LABULL_CONTRACT_PATH` 指到外面)⇒ `None`:
511
+ 那种"这份文件不受版本控制"的情况就是**没有可比基线**,不是错误。
512
+ """
513
+ base = _root()
514
+ if base is None:
515
+ return None
516
+ absolute = (base / contract_path()).resolve()
517
+ try:
518
+ relative = absolute.relative_to(repo.resolve())
519
+ except ValueError:
520
+ return None
521
+ return relative.as_posix()