labull-framework 3.0.4__tar.gz → 3.0.5__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 (72) hide show
  1. {labull_framework-3.0.4/labull_framework.egg-info → labull_framework-3.0.5}/PKG-INFO +1 -1
  2. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/access.py +266 -184
  3. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/env.py +8 -13
  4. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/devkit.py +8 -8
  5. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/requires.py +5 -5
  6. {labull_framework-3.0.4 → labull_framework-3.0.5/labull_framework.egg-info}/PKG-INFO +1 -1
  7. {labull_framework-3.0.4 → labull_framework-3.0.5}/pyproject.toml +1 -1
  8. {labull_framework-3.0.4 → labull_framework-3.0.5}/LICENSE +0 -0
  9. {labull_framework-3.0.4 → labull_framework-3.0.5}/MANIFEST.in +0 -0
  10. {labull_framework-3.0.4 → labull_framework-3.0.5}/README-pypi.md +0 -0
  11. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/__init__.py +0 -0
  12. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/admin.py +0 -0
  13. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/apps.py +0 -0
  14. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/auth/__init__.py +0 -0
  15. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/auth/backend.py +0 -0
  16. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/auth/bootstrap.py +0 -0
  17. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/auth/sso.py +0 -0
  18. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/auth/views.py +0 -0
  19. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/canvas/__init__.py +0 -0
  20. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/canvas/data.py +0 -0
  21. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/canvas/gitinfo.py +0 -0
  22. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/canvas/shape_ast.py +0 -0
  23. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/canvas/static/canvas.css +0 -0
  24. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/canvas/static/canvas.js +0 -0
  25. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/canvas/static/page.html +0 -0
  26. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/canvas/views.py +0 -0
  27. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/checks.py +0 -0
  28. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/flow.py +0 -0
  29. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/jsonio.py +0 -0
  30. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/__init__.py +0 -0
  31. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/blockcheck.py +0 -0
  32. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/blockpackage.py +0 -0
  33. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/blocktypes.py +0 -0
  34. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/deepcheck.py +0 -0
  35. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/dispatch.py +0 -0
  36. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/export.py +0 -0
  37. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/health.py +0 -0
  38. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/manifest.py +0 -0
  39. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/parallel.py +0 -0
  40. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/registry.py +0 -0
  41. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/sdk.py +0 -0
  42. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/service.py +0 -0
  43. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/subproc.py +0 -0
  44. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/types/__init__.py +0 -0
  45. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/types/plain.py +0 -0
  46. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/types/workflow.py +0 -0
  47. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/validate.py +0 -0
  48. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/magic_block/views.py +0 -0
  49. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/management/__init__.py +0 -0
  50. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/management/commands/__init__.py +0 -0
  51. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/management/commands/labull_access.py +0 -0
  52. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/management/commands/labull_blocktype.py +0 -0
  53. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/management/commands/labull_contract.py +0 -0
  54. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/management/commands/labull_requires.py +0 -0
  55. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/migrations/0001_initial.py +0 -0
  56. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/migrations/0002_remove_magicblock_prev_files_and_more.py +0 -0
  57. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/migrations/0003_magicblock_tags.py +0 -0
  58. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/migrations/0004_magicblock_detail.py +0 -0
  59. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/migrations/0005_labullrole.py +0 -0
  60. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/migrations/0006_default_groups_permissions.py +0 -0
  61. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/migrations/0007_no_builtin_permissions.py +0 -0
  62. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/migrations/__init__.py +0 -0
  63. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/models.py +0 -0
  64. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/permissions.py +0 -0
  65. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/settings.py +0 -0
  66. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/urls.py +0 -0
  67. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework/virtual.py +0 -0
  68. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework.egg-info/SOURCES.txt +0 -0
  69. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework.egg-info/dependency_links.txt +0 -0
  70. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework.egg-info/requires.txt +0 -0
  71. {labull_framework-3.0.4 → labull_framework-3.0.5}/labull_framework.egg-info/top_level.txt +0 -0
  72. {labull_framework-3.0.4 → labull_framework-3.0.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 3.0.4
3
+ Version: 3.0.5
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
@@ -1,24 +1,32 @@
1
- """权限码与权限包(组)的**声明 → 落库**那一半 —— 声明住在环境变量里。
2
-
3
- 两个变量(格式就是契约,改它要连文档 / 示例 / 判据一起改):
4
-
5
- LABULL_PERMISSIONS=订单.models=approve:可以审批订单,close:可以关单;…
6
- └ 模型(app_label.Model)┘ └ codename:中文名 ┘
7
-
8
- LABULL_GROUPS=财务=orders.approve,orders.close;出纳=orders.close
9
-
10
- 🔴 **为什么 permission 的条目里要写模型**:`Permission` 的行主键是
11
- `(content_type, codename)`,而 content_type 只能从一个**真实模型**拿到;
12
- 环境变量里只有 `app_label.codename` 的话,要么造一个伪模型(admin 里会多一行幽灵,
13
- `remove_stale_contenttypes` 还会把它当垃圾清掉),要么就没有 content_type。
14
- 写模型名是最小的一处额外信息,换来的是 content_type 正确、与 `Meta.permissions` 同一行。
15
- 🔴 权限码本身是 `app_label.codename`(与 `flow.py` 的 `permission:`、`has_perm()` 同一份口径):
16
- 模型那一段只用来定位 content_type,**不进码**。
17
- 🔴 同步**只增不删**:少一个组 / 少一个权限就"从库里删掉"是不做的 ——
18
- 删组会级联掉 `auth_user_groups` 里人的授权,那是人手动配的数据,不是派生值。
19
- 要删只能人自己在库里删(这是用户明确要的语义)。
20
- 🔴 `sync()` **永不抛**:它是启动路径上的一件事(与 `auth/bootstrap.py` 同一个模式),
21
- 库连不上 / 表还没迁移都只该进日志与报告。
1
+ """权限码与权限包(组)的**声明 → 落库**那一半 —— 声明住在项目里的一份 YAML 文件。
2
+
3
+ 默认位置:`<BASE_DIR>/labull-access.yaml`(可以用 `LABULL_ACCESS_FILE` 指到别的路径)。
4
+ 文件长这样(一份普通 YAML,**不是**环境变量里那种一行塞完的串):
5
+
6
+ ```yaml
7
+ # 权限(可选;模型 Meta.permissions 里已经声明过的不必重复)
8
+ permissions:
9
+ canvasdemo.Reimbursement:
10
+ review_reimbursement: 可以审批报销单
11
+ pay_reimbursement: 可以对报销单付款
12
+
13
+ # 权限包(组):组名 → 权限码
14
+ groups:
15
+ 财务:
16
+ - canvasdemo.review_reimbursement
17
+ - canvasdemo.pay_reimbursement
18
+ 出纳:
19
+ - canvasdemo.pay_reimbursement
20
+ ```
21
+
22
+ 🔴 **为什么 permissions 里要写模型**:`Permission` 的行主键是 `(content_type, codename)`,
23
+ 而 content_type 只能从一个**真实模型**拿到。写模型名换来的是 content_type 正确、
24
+ 与 `Meta.permissions` 指向同一行;对外暴露的码仍是 `app_label.codename`。
25
+ 🔴 **不引 PyYAML**:这个仓库不引第三方运行时依赖,所以这里只认上面这一种子集
26
+ (`键:` / `键: 值` / 缩进两格的映射或 `- 项` 列表),不认识的写法**当场报错**。
27
+ 🔴 同步**只增不删**:少一个组 / 少一个权限**不会**从库里删掉 —— 删组会级联掉
28
+ `auth_user_groups` 里人的授权,那是人手动配的数据。要删只能人自己在库里删。
29
+ 🔴 `sync()` **永不抛**:它是启动路径上的一件事(与 `auth/bootstrap.py` 同一个模式)。
22
30
  🔴 组名只是**标签**:代码里任何判据都不许读组名(判权永远看 permission)。
23
31
  """
24
32
 
@@ -27,6 +35,7 @@ from __future__ import annotations
27
35
  import logging
28
36
  import re
29
37
  from dataclasses import dataclass, field
38
+ from pathlib import Path
30
39
  from typing import Any
31
40
 
32
41
  from .env import read_env
@@ -34,28 +43,28 @@ from .permissions import PERMISSION_PATTERN
34
43
 
35
44
  logger = logging.getLogger(__name__)
36
45
 
37
- #: 权限声明(`app_label.Model=codename:中文名[,…]`;多个模型用 `;` 分隔)。
38
- PERMISSIONS_ENV = 'LABULL_PERMISSIONS'
46
+ #: 声明文件的名字(默认落在项目的 `BASE_DIR` 下)。
47
+ ACCESS_FILE_NAME = 'labull-access.yaml'
39
48
 
40
- #: 权限包(组)声明(`组名=app_label.codename[,…]`;多个包用 `;` 分隔)。
41
- GROUPS_ENV = 'LABULL_GROUPS'
49
+ #: 把声明文件指到别处(路径;相对路径按 `BASE_DIR` 解析)。
50
+ ACCESS_FILE_ENV = 'LABULL_ACCESS_FILE'
42
51
 
43
52
  #: 一个标识符(app_label / 模型名 / codename 共用这一份判据)。
44
53
  _IDENT = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
45
54
 
46
- #: Django `Permission.codename` / `Group.name` 的上限(照抄模型那两列,抄错就会在写库时炸)。
55
+ #: Django `Permission.codename` / `Permission.name` / `Group.name` 的上限(照抄模型那三列)。
47
56
  CODENAME_MAX = 100
48
57
  NAME_MAX = 255
49
58
  GROUP_NAME_MAX = 150
50
59
 
51
60
 
52
61
  class AccessError(ValueError):
53
- """声明格式不对 —— 报文是中文原话,直接给人看(不走 `%` 模板)。"""
62
+ """声明文件写得不对 —— 报文是中文原话,直接给人看(带行号)。"""
54
63
 
55
64
 
56
65
  @dataclass(frozen=True)
57
66
  class PermissionSpec:
58
- """一条权限声明:`app_label.Model=codename:中文名`。"""
67
+ """一条权限声明:`app_label.Model` → `codename: 中文名`。"""
59
68
 
60
69
  app_label: str
61
70
  model: str
@@ -76,6 +85,14 @@ class GroupSpec:
76
85
  permissions: tuple[str, ...] = ()
77
86
 
78
87
 
88
+ @dataclass(frozen=True)
89
+ class Declaration:
90
+ """一份声明文件的全部内容(`permissions:` / `groups:` 都可以整块不写)。"""
91
+
92
+ permissions: tuple[PermissionSpec, ...] = ()
93
+ groups: tuple[GroupSpec, ...] = ()
94
+
95
+
79
96
  @dataclass
80
97
  class Report:
81
98
  """一次同步的结论(命令打印它、启动日志挑着打;**空报告 = 什么都没发生**)。"""
@@ -117,154 +134,227 @@ class Report:
117
134
  for item in self.added:
118
135
  out.append(f'{prefix}把 {item} 放进权限包')
119
136
  for item in self.missing:
120
- out.append(f'{prefix}✗ 权限包要的码不存在:{item}(先在模型 Meta.permissions / '
121
- f'{PERMISSIONS_ENV} 里声明,再 migrate)')
137
+ out.append(f'{prefix}✗ 权限包要的码不存在:{item}(先在模型的 Meta.permissions / '
138
+ f'{ACCESS_FILE_NAME} 的 permissions 里声明,再 migrate)')
122
139
  return out
123
140
 
124
141
 
125
- def _split(raw: str) -> list[str]:
126
- """按 `;` 切条目(空条目忽略 —— 结尾多一个 `;` 是手滑,不是错误)。"""
127
- return [item.strip() for item in str(raw or '').split(';') if item.strip()]
142
+ # ──────────────────────────── 读那一份 YAML(只认这一种子集) ────────────────────────────
128
143
 
129
144
 
130
- def _split_codes(raw: str) -> list[str]:
131
- """按 `,` 切权限码(空段**报错**:`a,,b` 多半是手滑,跳过会让一个码悄悄没进包)。"""
132
- items = [item.strip() for item in str(raw or '').split(',')]
133
- if any(not item for item in items):
134
- raise AccessError(f'权限码列表里有空段({raw!r})—— 用逗号分隔,别留空项')
135
- return items
145
+ def _strip_comment(line: str) -> str:
146
+ """去掉行尾注释(`#` 在引号里不算注释 —— 中文名里带 `#` 是合法的)。"""
147
+ quote = ''
148
+ for index, char in enumerate(line):
149
+ if quote:
150
+ if char == quote:
151
+ quote = ''
152
+ elif char in ('"', "'"):
153
+ quote = char
154
+ elif char == '#':
155
+ return line[:index]
156
+ return line
136
157
 
137
158
 
138
- def parse_permissions(raw: str) -> tuple[PermissionSpec, ...]:
139
- """`LABULL_PERMISSIONS` 的值 → 声明(格式不对抛 `AccessError`,中文原话)。"""
140
- specs: list[PermissionSpec] = []
141
- seen: set[str] = set()
142
- for entry in _split(raw):
143
- if '=' not in entry:
144
- raise AccessError(
145
- f'`{PERMISSIONS_ENV}` 的条目 {entry!r} 不是 `app_label.Model=codename:中文名`'
146
- f'(少了那个 `=`)'
147
- )
148
- target, codes = entry.split('=', 1)
149
- target = target.strip()
150
- model_parts = target.split('.')
151
- if len(model_parts) != 2 or not all(_IDENT.match(part) for part in model_parts):
152
- raise AccessError(
153
- f'`{PERMISSIONS_ENV}` 里的 {target!r} 不是 `app_label.Model` 的形状 —— '
154
- f'要写**真实的模型**(权限行必须挂在一个 content_type 上),例如 '
155
- f'`canvasdemo.Reimbursement`'
156
- )
157
- app_label, model = model_parts
158
- for item in _split_codes(codes):
159
- if ':' not in item:
160
- raise AccessError(
161
- f'`{PERMISSIONS_ENV}` 里 {target} 的 {item!r} 少了中文名 —— '
162
- f'写法是 `codename:中文名`(管理员在组里看到的就是它)'
163
- )
164
- codename, name = item.split(':', 1)
165
- codename, name = codename.strip(), name.strip()
166
- if not _IDENT.match(codename) or len(codename) > CODENAME_MAX:
167
- raise AccessError(
168
- f'`{PERMISSIONS_ENV}` 里的 codename {codename!r} 不是标识符'
169
- f'(字母/数字/下划线,≤{CODENAME_MAX})'
170
- )
171
- if not name or len(name) > NAME_MAX:
172
- raise AccessError(
173
- f'`{PERMISSIONS_ENV}` 里 {target}.{codename} 的中文名要写、且 ≤{NAME_MAX} 字'
174
- )
175
- code = f'{app_label}.{codename}'
176
- if code in seen:
177
- raise AccessError(
178
- f'`{PERMISSIONS_ENV}` 里权限码 {code} 声明了两次(同一个码只能出现一次)'
179
- )
180
- seen.add(code)
181
- specs.append(PermissionSpec(app_label=app_label, model=model, codename=codename, name=name))
182
- return tuple(specs)
183
-
184
-
185
- def parse_groups(raw: str) -> tuple[GroupSpec, ...]:
186
- """`LABULL_GROUPS` 的值 → 声明(格式不对抛 `AccessError`,中文原话)。"""
187
- specs: list[GroupSpec] = []
188
- seen: set[str] = set()
189
- for entry in _split(raw):
190
- if '=' not in entry:
191
- raise AccessError(
192
- f'`{GROUPS_ENV}` 的条目 {entry!r} 不是 `组名=app_label.codename[,…]`(少了那个 `=`)'
193
- )
194
- name, codes = entry.split('=', 1)
195
- name = name.strip()
196
- if not name or len(name) > GROUP_NAME_MAX:
197
- raise AccessError(
198
- f'`{GROUPS_ENV}` 里的组名 {name!r} 要写、且 ≤{GROUP_NAME_MAX} 字'
199
- f'(Django `Group.name` 的上限)'
200
- )
201
- if name in seen:
202
- raise AccessError(
203
- f'`{GROUPS_ENV}` 里权限包「{name}」声明了两次 —— 一个包的所有码写在同一个条目里'
204
- )
205
- seen.add(name)
206
- codes = codes.strip()
207
- if codes:
208
- items = _split_codes(codes)
209
- for code in items:
210
- if not PERMISSION_PATTERN.match(code):
211
- raise AccessError(
212
- f'`{GROUPS_ENV}` 里「{name}」的 {code!r} 不是 `app_label.codename` 的形状 —— '
213
- f'环境变量里没有"当前 app"这个概念,要写全(例如 canvasdemo.review_reimbursement)'
159
+ def _value(text: str) -> str:
160
+ """一个标量值(去掉一对引号;`''` / `""` 是合法的空串)。"""
161
+ value = text.strip()
162
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
163
+ return value[1:-1]
164
+ return value
165
+
166
+
167
+ def _error(line_no: int, message: str) -> AccessError:
168
+ return AccessError(f'{ACCESS_FILE_NAME} 第 {line_no} 行:{message}')
169
+
170
+
171
+ def parse_access(text: str) -> Declaration:
172
+ """把声明文件的正文读成 `Declaration`(格式不对抛 `AccessError`,带行号)。
173
+
174
+ 只认这一种子集(**不引 YAML 库**):
175
+
176
+ ```yaml
177
+ permissions:
178
+ app.Model:
179
+ codename: 中文名
180
+ groups:
181
+ 组名:
182
+ - app_label.codename
183
+ ```
184
+ """
185
+ permissions: list[PermissionSpec] = []
186
+ groups: list[GroupSpec] = []
187
+ seen_codes: set[str] = set()
188
+ seen_groups: set[str] = set()
189
+ section = ''
190
+ model: tuple[str, str] | None = None # (app_label, model)
191
+ group: str = '' # 当前正在收权限码的组名
192
+
193
+ for index, raw in enumerate(str(text or '').splitlines(), start=1):
194
+ line = _strip_comment(raw).rstrip()
195
+ if not line.strip():
196
+ continue
197
+ indent = len(line) - len(line.lstrip())
198
+ content = line.strip()
199
+
200
+ if indent == 0:
201
+ if not content.endswith(':'):
202
+ raise _error(index, f'顶层只许写 `permissions:` 或 `groups:`(读到 {content!r})')
203
+ key = content[:-1].strip()
204
+ if key not in ('permissions', 'groups'):
205
+ raise _error(index, f'顶层不认识的键 `{key}`:只有 permissions / groups 两块')
206
+ section = key
207
+ model = None
208
+ group = ''
209
+ continue
210
+
211
+ if not section:
212
+ raise _error(index, '这一行缩进了,但上面还没有 `permissions:` / `groups:` 那一行')
213
+
214
+ if section == 'permissions':
215
+ if indent == 2:
216
+ target = content[:-1].strip() if content.endswith(':') else ''
217
+ parts = target.split('.')
218
+ if len(parts) != 2 or not all(_IDENT.match(part) for part in parts):
219
+ raise _error(
220
+ index,
221
+ f'{content!r} 不是 `app_label.Model:` 的形状 —— 要写**真实的模型**'
222
+ f'(权限行必须挂在一个 content_type 上),例如 `canvasdemo.Reimbursement:`',
223
+ )
224
+ model = (parts[0], parts[1])
225
+ continue
226
+ if indent == 4:
227
+ if model is None:
228
+ raise _error(index, '这一行上面少了 `app_label.Model:` 那一行')
229
+ if ':' not in content:
230
+ raise _error(index, f'`{content}` 少了中文名(写法是 `codename: 中文名`)')
231
+ codename, name = content.split(':', 1)
232
+ codename, name = codename.strip(), _value(name)
233
+ if not _IDENT.match(codename) or len(codename) > CODENAME_MAX:
234
+ raise _error(
235
+ index,
236
+ f'codename `{codename}` 不是标识符(字母/数字/下划线,≤{CODENAME_MAX})',
214
237
  )
215
- specs.append(GroupSpec(name=name, permissions=tuple(items)))
216
- else:
217
- # 空包是合法的(先建组、权限后补);只是它一个码都不带。
218
- specs.append(GroupSpec(name=name, permissions=()))
219
- return tuple(specs)
238
+ if not name or len(name) > NAME_MAX:
239
+ raise _error(index, f'`{codename}` 的中文名要写、且 ≤{NAME_MAX} 字')
240
+ code = f'{model[0]}.{codename}'
241
+ if code in seen_codes:
242
+ raise _error(index, f'权限码 `{code}` 写了两次(同一个码只能出现一次)')
243
+ seen_codes.add(code)
244
+ permissions.append(PermissionSpec(
245
+ app_label=model[0], model=model[1], codename=codename, name=name,
246
+ ))
247
+ continue
248
+ raise _error(index, f'缩进不对(读到 {indent} 格):permissions 下只许两格 / 四格')
249
+
250
+ # section == 'groups'
251
+ if indent == 2:
252
+ if not content.endswith(':'):
253
+ raise _error(index, f'{content!r} 不是 `组名:` 的形状(组名后面要冒号、值缩进在下面)')
254
+ name = content[:-1].strip()
255
+ if not name or len(name) > GROUP_NAME_MAX:
256
+ raise _error(index, f'组名要写、且 ≤{GROUP_NAME_MAX} 字(Django `Group.name` 的上限)')
257
+ if name in seen_groups:
258
+ raise _error(index, f'权限包「{name}」写了两次')
259
+ seen_groups.add(name)
260
+ group = name
261
+ groups.append(GroupSpec(name=name, permissions=()))
262
+ continue
263
+ if indent == 4:
264
+ if not group:
265
+ raise _error(index, '这一行上面少了 `组名:` 那一行')
266
+ if not content.startswith('- '):
267
+ raise _error(index, f'{content!r} 要写成 `- app_label.codename`(列表项)')
268
+ code = content[2:].strip()
269
+ if not PERMISSION_PATTERN.match(code):
270
+ raise _error(
271
+ index,
272
+ f'`{code}` 不是 `app_label.codename` 的形状 —— 要写全'
273
+ f'(例如 canvasdemo.review_reimbursement)',
274
+ )
275
+ current = groups[-1]
276
+ groups[-1] = GroupSpec(name=current.name, permissions=(*current.permissions, code))
277
+ continue
278
+ raise _error(index, f'缩进不对(读到 {indent} 格):groups 下只许两格 / 四格')
279
+
280
+ return Declaration(permissions=tuple(permissions), groups=tuple(groups))
220
281
 
221
282
 
222
- def declared_permissions() -> tuple[PermissionSpec, ...]:
223
- """环境里声明的权限(格式错抛 `AccessError`;没配就是空)。"""
224
- return parse_permissions(read_env(PERMISSIONS_ENV))
283
+ # ──────────────────────────── 文件在哪 ────────────────────────────
225
284
 
226
285
 
227
- def declared_groups() -> tuple[GroupSpec, ...]:
228
- """环境里声明的权限包(格式错抛 `AccessError`;没配就是空)。"""
229
- return parse_groups(read_env(GROUPS_ENV))
286
+ def _base_dir() -> Path | None:
287
+ """项目的 `BASE_DIR`(没设就退回当前工作目录 —— `manage.py` 通常就在项目根跑)。"""
288
+ try:
289
+ from django.conf import settings
290
+
291
+ base = getattr(settings, 'BASE_DIR', None)
292
+ if base:
293
+ return Path(base)
294
+ except Exception: # noqa: BLE001 —— 没配 Django 时退回 cwd
295
+ pass
296
+ try:
297
+ return Path.cwd()
298
+ except Exception: # noqa: BLE001
299
+ return None
230
300
 
231
301
 
232
- def declared_permission_codes() -> set[str]:
233
- """声明的权限码(**不碰数据库**;格式坏掉就当空 —— 判据那一层另有地方报它)。"""
302
+ def access_file() -> tuple[Path | None, bool]:
303
+ """声明文件在哪:`(路径, 是不是显式配的)`。
304
+
305
+ `LABULL_ACCESS_FILE` 配了就用它(相对路径按 `BASE_DIR` 解析);没配就用
306
+ `<BASE_DIR>/labull-access.yaml`。取不到任何基准目录 ⇒ `(None, False)`。
307
+ """
308
+ raw = read_env(ACCESS_FILE_ENV).strip()
309
+ base = _base_dir()
310
+ if raw:
311
+ path = Path(raw)
312
+ if not path.is_absolute() and base is not None:
313
+ path = base / path
314
+ return path, True
315
+ if base is not None:
316
+ return base / ACCESS_FILE_NAME, False
317
+ return None, False
318
+
319
+
320
+ def load() -> tuple[Declaration, list[str]]:
321
+ """读声明文件:`(声明, 错误清单)`。文件不在 ⇒ 空声明(默认位置)或一条错误(显式配的)。"""
322
+ path, explicit = access_file()
323
+ if path is None:
324
+ return Declaration(), []
325
+ if not path.is_file():
326
+ if explicit:
327
+ return Declaration(), [
328
+ f'{ACCESS_FILE_ENV} 指向 `{path}`,但那个文件不存在 —— 改对路径、或把这一项删掉'
329
+ ]
330
+ return Declaration(), []
234
331
  try:
235
- return {spec.code for spec in declared_permissions()}
236
- except AccessError:
237
- return set()
332
+ return parse_access(path.read_text(encoding='utf-8-sig')), []
333
+ except AccessError as exc:
334
+ return Declaration(), [str(exc)]
335
+ except Exception as exc: # noqa: BLE001 —— 读文件失败也只进错误清单(启动路径不抛)
336
+ return Declaration(), [f'读不了 `{path}`:{type(exc).__name__}: {exc}']
238
337
 
239
338
 
240
339
  def declaration_errors() -> list[str]:
241
- """两个变量各自的格式问题(**不碰数据库**:系统检查用,`migrate` / `runserver` 就能看见)。"""
242
- errors: list[str] = []
243
- for env_name, parser in ((PERMISSIONS_ENV, parse_permissions), (GROUPS_ENV, parse_groups)):
244
- raw = read_env(env_name)
245
- if not raw:
246
- continue
247
- try:
248
- parser(raw)
249
- except AccessError as exc:
250
- errors.append(str(exc))
251
- return errors
340
+ """声明文件的**格式**问题(不碰数据库:系统检查用,`migrate` / `runserver` 就能看见)。"""
341
+ return load()[1]
342
+
343
+
344
+ def declared_permission_codes() -> set[str]:
345
+ """声明文件里的权限码(**不碰数据库**;读不出来就当空 —— 判据那一层另有地方报它)。"""
346
+ return {spec.code for spec in load()[0].permissions}
252
347
 
253
348
 
254
349
  def known_group_names() -> set[str] | None:
255
- """项目里"有哪些组"(声明的 ∪ 库里现有的);取不到任何一处 ⇒ `None`(调用方跳过判据)。
350
+ """项目里"有哪些组"(声明文件里的 ∪ 库里现有的);取不到任何一处 ⇒ `None`(调用方跳过判据)。
256
351
 
257
- 🔴 为什么要并上库里的:组是**只增不删**的 —— 没在 `LABULL_GROUPS` 里声明的组
258
- (管理员手建的)照样合法;把"没声明"当成"不存在"会把它误判掉。
352
+ 🔴 为什么要并上库里的:组是**只增不删**的 —— 没在文件里声明的组(管理员手建的)照样合法;
353
+ 把"没声明"当成"不存在"会把它误判掉。
259
354
  """
260
- names: set[str] = set()
261
- known = False
262
- if read_env(GROUPS_ENV).strip():
263
- try:
264
- names.update(spec.name for spec in declared_groups())
265
- known = True
266
- except AccessError:
267
- pass
355
+ declaration, errors = load()
356
+ names = {spec.name for spec in declaration.groups}
357
+ known = bool(names) or not errors
268
358
  try:
269
359
  from django.contrib.auth.models import Group
270
360
 
@@ -276,13 +366,13 @@ def known_group_names() -> set[str] | None:
276
366
 
277
367
 
278
368
  def permissions_catalog() -> list[dict[str, str]]:
279
- """项目里**声明过的业务权限**(按码排序):`Meta.permissions` ∪ `.env` 的 `LABULL_PERMISSIONS`。
369
+ """项目里**声明过的业务权限**(按码排序):`Meta.permissions` ∪ 声明文件的 `permissions:`。
280
370
 
281
371
  每条:`{'code', 'app_label', 'model', 'name', 'source'}`。
282
372
 
283
373
  🔴 Django 自动生成的 `add_/change_/delete_/view_` **不在里面**:那是模型级 CRUD,
284
374
  不是业务权限(拿它当业务权限目录是越界,见 `devkit` 的硬规矩第 1 条)。
285
- 🔴 不读数据库(导出不该依赖"这台机器上库迁移到哪一步了"):真源是代码与环境变量。
375
+ 🔴 不读数据库(导出不该依赖"这台机器上库迁移到哪一步了"):真源是代码与声明文件。
286
376
  """
287
377
  rows: dict[str, dict[str, str]] = {}
288
378
  try:
@@ -299,36 +389,30 @@ def permissions_catalog() -> list[dict[str, str]]:
299
389
  'name': str(name),
300
390
  'source': 'Meta.permissions',
301
391
  })
302
- except Exception: # noqa: BLE001 —— 没有 Django 时就只剩环境变量那一半
303
- pass
304
- try:
305
- for spec in declared_permissions():
306
- # 环境变量那一份**覆盖**同名码的展示信息(它是显式声明的,人写的那一份为准)。
307
- rows[spec.code] = {
308
- 'code': spec.code,
309
- 'app_label': spec.app_label,
310
- 'model': spec.model,
311
- 'name': spec.name,
312
- 'source': PERMISSIONS_ENV,
313
- }
314
- except AccessError:
392
+ except Exception: # noqa: BLE001 —— 没有 Django 时就只剩声明文件那一半
315
393
  pass
394
+ for spec in load()[0].permissions:
395
+ # 声明文件那一份**覆盖**同名码的展示信息(它是显式声明的,人写的那一份为准)。
396
+ rows[spec.code] = {
397
+ 'code': spec.code,
398
+ 'app_label': spec.app_label,
399
+ 'model': spec.model,
400
+ 'name': spec.name,
401
+ 'source': ACCESS_FILE_NAME,
402
+ }
316
403
  return [rows[code] for code in sorted(rows)]
317
404
 
318
405
 
319
406
  def groups_catalog() -> list[dict[str, Any]]:
320
- """环境里声明的权限包:`[{'name': 组名, 'permissions': [码, …]}, …]`(没配就是空)。
407
+ """声明文件里的权限包:`[{'name': 组名, 'permissions': [码, …]}, …]`(没配就是空)。
321
408
 
322
409
  🔴 只读**声明**、不读库:导出那一刻铺给块作者的是"这个项目声明了哪些包";
323
410
  库里手建的组不进来(它们是操作动作,不是契约)。
324
411
  """
325
- try:
326
- return [
327
- {'name': spec.name, 'permissions': list(spec.permissions)}
328
- for spec in declared_groups()
329
- ]
330
- except AccessError:
331
- return []
412
+ return [
413
+ {'name': spec.name, 'permissions': list(spec.permissions)}
414
+ for spec in load()[0].groups
415
+ ]
332
416
 
333
417
 
334
418
  def _model_class(app_label: str, model: str) -> Any:
@@ -348,13 +432,11 @@ def sync(*, dry_run: bool = False) -> Report:
348
432
  🔴 库还没迁移 / 连不上 ⇒ 结论进 `errors`,不抛:这是启动路径上的一件事。
349
433
  """
350
434
  report = Report(dry_run=dry_run)
351
- try:
352
- permission_specs = declared_permissions()
353
- group_specs = declared_groups()
354
- except AccessError as exc:
355
- report.errors.append(str(exc))
435
+ declaration, errors = load()
436
+ report.errors.extend(errors)
437
+ if errors:
356
438
  return report
357
- if not permission_specs and not group_specs:
439
+ if not declaration.permissions and not declaration.groups:
358
440
  return report
359
441
  try:
360
442
  from django.contrib.auth.models import Group, Permission
@@ -369,7 +451,7 @@ def sync(*, dry_run: bool = False) -> Report:
369
451
  #: dry-run 下"这一次会建出来"的那些码:组那一段要把它们当成"可用",
370
452
  #: 否则一次全新环境的 dry-run 会把每个码都报成 missing(那是假错)。
371
453
  planned: set[str] = set()
372
- for spec in permission_specs:
454
+ for spec in declaration.permissions:
373
455
  existing = Permission.objects.filter(
374
456
  content_type__app_label=spec.app_label, codename=spec.codename,
375
457
  ).first()
@@ -397,7 +479,7 @@ def sync(*, dry_run: bool = False) -> Report:
397
479
  report.permission_name_differs.append(spec.code)
398
480
 
399
481
  # ── ② 组:缺就建;权限只 add 缺的那些 ──
400
- for spec in group_specs:
482
+ for spec in declaration.groups:
401
483
  group = Group.objects.filter(name=spec.name).first()
402
484
  if group is None:
403
485
  if dry_run:
@@ -224,22 +224,17 @@ ENV_ITEMS: tuple[EnvItem, ...] = (
224
224
  hint='LABULL_BLOCK_WORKERS=2',
225
225
  group='平台开关',
226
226
  ),
227
- # ── 授权(权限码 / 权限包):**声明在这里,落库由框架同步** ──
228
- # 🔴 为什么是环境变量而不是迁移:迁移里的 `RunPython` 跑在 `create_permissions` **之前**,
227
+ # ── 授权(权限码 / 权限包):**声明在项目里的一份 YAML 文件**,落库由框架同步 ──
228
+ # 🔴 为什么不是迁移:迁移里的 `RunPython` 跑在 `create_permissions` **之前**,
229
229
  # 要拿权限行去塞组就得自己再建一遍权限行(那是第二份真源)。这里只在启动时对一次账。
230
+ # 🔴 为什么不是环境变量:`group → 权限码` 是一张映射,塞进一行环境变量既难读、又容易写错
231
+ # (见 `labull-access.yaml` 的写法)。
230
232
  # 🔴 同步**只增不删**:要删组 / 删权限,只能人自己在库里删(删组会级联掉人的授权)。
231
233
  EnvItem(
232
- name='LABULL_PERMISSIONS',
233
- brief='权限声明:`app_label.Model=codename:中文名[,codename:中文名…]`,多个模型用 `;` 分隔'
234
- '(模型那一段只用来定位 content_type,对外暴露的码仍是 `app_label.codename`)',
235
- hint='LABULL_PERMISSIONS=canvasdemo.Reimbursement=review_reimbursement:可以审批报销单',
236
- group='授权(权限码 / 权限包)',
237
- ),
238
- EnvItem(
239
- name='LABULL_GROUPS',
240
- brief='权限包(组)声明:`组名=app_label.codename[,app_label.codename…]`,多个包用 `;` 分隔'
241
- '(组名只是标签;启动时把组与它的权限同步进库,只增不删)',
242
- hint='LABULL_GROUPS=财务=canvasdemo.review_reimbursement,canvasdemo.pay_reimbursement',
234
+ name='LABULL_ACCESS_FILE',
235
+ brief='授权声明文件的路径(默认就是 `BASE_DIR/labull-access.yaml`;相对路径按 `BASE_DIR` 解析)。'
236
+ '文件里写 `permissions:`(权限码 + 中文名)与 `groups:`(组名 → 权限码)',
237
+ hint='LABULL_ACCESS_FILE=config/labull-access.yaml',
243
238
  group='授权(权限码 / 权限包)',
244
239
  ),
245
240
  # ── 块的配置面 ──
@@ -1404,8 +1404,8 @@ def backend_lock_pyi(
1404
1404
  for item in virtuals:
1405
1405
  lines.extend(_table_block(item, virtual=True))
1406
1406
  # ── ⑥ 授权(权限码 / 权限包):块里写 `permission:` 与 `requires.permissions` 用的就是它们 ──
1407
- # 🔴 这一段**不读数据库**:权限码来自模型的 `Meta.permissions` 与 `.env` 的 `LABULL_PERMISSIONS`,
1408
- # 权限包来自 `.env` 的 `LABULL_GROUPS` —— 导出不该依赖"这台机器上库迁移到哪一步了"。
1407
+ # 🔴 这一段**不读数据库**:权限码来自模型的 `Meta.permissions` 与 `labull-access.yaml` 的
1408
+ # `permissions:`,权限包来自同一份文件的 `groups:` —— 导出不该依赖"这台机器上库迁移到哪一步了"。
1409
1409
  lines.append('')
1410
1410
  lines.append('# ── ⑥ 权限码与权限包(块里 `permission:` / 清单的 `requires.permissions` 用它们)──')
1411
1411
  lines.extend(_catalog_lines())
@@ -1417,7 +1417,7 @@ def _catalog_lines() -> list[str]:
1417
1417
  out: list[str] = []
1418
1418
  catalog = permissions_catalog()
1419
1419
  if catalog:
1420
- out.append('# 业务权限码(声明在模型的 `Meta.permissions` 或 `.env` 的 LABULL_PERMISSIONS):')
1420
+ out.append('# 业务权限码(声明在模型的 `Meta.permissions` 或 labull-access.yaml 的 permissions:):')
1421
1421
  for row in catalog:
1422
1422
  out.append(f"# {row['code']} —— {row['name']}({row['app_label']}.{row['model']})")
1423
1423
  else:
@@ -1428,14 +1428,14 @@ def _catalog_lines() -> list[str]:
1428
1428
  groups = groups_catalog()
1429
1429
  if groups:
1430
1430
  out.append('#')
1431
- out.append('# 权限包(组名 → 权限码;来自 `.env` 的 LABULL_GROUPS,启动时框架会同步进库、只增不删):')
1431
+ out.append('# 权限包(组名 → 权限码;来自 labull-access.yaml 的 groups:,启动时框架会同步进库、只增不删):')
1432
1432
  for item in groups:
1433
1433
  codes = '、'.join(item['permissions']) or '(空包)'
1434
1434
  out.append(f"# {item['name']}:{codes}")
1435
1435
  out.append('# 🔴 组名只是标签:块里**不许**按组名判人,判权只看 permission 码。')
1436
1436
  else:
1437
1437
  out.append('#')
1438
- out.append('# (没有声明权限包:管理员只能逐个给人授权;要打包就在 `.env` 的 LABULL_GROUPS 里写)')
1438
+ out.append('# (没有声明权限包:管理员只能逐个给人授权;要打包就在 labull-access.yaml 的 groups: 里写)')
1439
1439
  return out
1440
1440
 
1441
1441
  def _access_doc() -> str:
@@ -1466,14 +1466,14 @@ def _access_doc() -> str:
1466
1466
  else:
1467
1467
  out.append('')
1468
1468
  out.append('(这个项目没有声明权限包:管理员只能逐个给人授权;'
1469
- '要打包就在 `.env` 的 `LABULL_GROUPS` 里写)')
1469
+ '要打包就在 `labull-access.yaml` 的 `groups:` 里写)')
1470
1470
  out.append('')
1471
1471
  out.append('**怎么引用它们**:`flow.py` 里写 `permission:<码>`(`confirm_permissions=(\'<码>\',)` 同理);'
1472
1472
  '块的 `requires.permissions` 里**用到就必须写**,`requires.groups` 只报备'
1473
1473
  '"这个块假定这些包存在"(它不参与任何授权判据)。')
1474
1474
  out.append('')
1475
- out.append('🔴 声明真源:权限码在模型的 `Meta.permissions` 或 `.env` 的 `LABULL_PERMISSIONS`;'
1476
- '权限包在 `.env` 的 `LABULL_GROUPS`。框架启动时把它们**同步进库**(权限行 + 组),'
1475
+ out.append('🔴 声明真源:权限码在模型的 `Meta.permissions` 或 `labull-access.yaml` 的 `permissions:`;'
1476
+ '权限包在同一份文件的 `groups:`。框架启动时把它们**同步进库**(权限行 + 组),'
1477
1477
  '**只增不删**(删只能人自己在库里删)。')
1478
1478
  return '\n'.join(out)
1479
1479
 
@@ -21,7 +21,7 @@ from typing import Any
21
21
  from . import blockpackage
22
22
  from .blockpackage import line_of
23
23
  from .manifest import read_manifest
24
- from ..access import GROUPS_ENV, PERMISSIONS_ENV
24
+ from ..access import ACCESS_FILE_NAME
25
25
  from ..permissions import PERMISSION_PATTERN, permission_list_text
26
26
  from .sdk import (
27
27
  BLOCK_CONFIG_PREFIX,
@@ -830,20 +830,20 @@ def check_requires(
830
830
  if code not in table:
831
831
  hard.append(
832
832
  f'{prefix}requires.permissions 声明了 `{code}`,但这个项目里没有这个权限码 —— '
833
- f'改法:在模型的 `Meta.permissions` 里声明它,或在 `.env` 的 '
834
- f'`{PERMISSIONS_ENV}` 里加一条(启动时框架会建出来);'
833
+ f'改法:在模型的 `Meta.permissions` 里声明它,或在 `{ACCESS_FILE_NAME}` 的 '
834
+ f'`permissions:` 里加一条(启动时框架会建出来);'
835
835
  f'当前项目有的是 {permission_list_text(table)}'
836
836
  )
837
837
 
838
838
  # ⑦ **权限包**(`requires.groups`):只报备"这个块假定这些包存在",**不判权**(组名只是标签)。
839
- # 🔴 存在性 = 声明的(`LABULL_GROUPS`)∪ 库里现有的(只增不删 ⇒ 手建的组也合法)。
839
+ # 🔴 存在性 = 声明的(`labull-access.yaml` 的 `groups:`)∪ 库里现有的(只增不删 ⇒ 手建的组也合法)。
840
840
  names = _known_group_names()
841
841
  if names is not None:
842
842
  for name in _declared_list(declared, 'groups'):
843
843
  if name not in names:
844
844
  hard.append(
845
845
  f'{prefix}requires.groups 声明了权限包「{name}」,但这个项目里没有这个包 —— '
846
- f'改法:在 `.env` 的 `{GROUPS_ENV}` 里声明它(启动时框架会建出来),'
846
+ f'改法:在 `{ACCESS_FILE_NAME}` 的 `groups:` 里声明它(启动时框架会建出来),'
847
847
  f'或把这一条删掉。组名只是标签,它不参与任何授权判据。'
848
848
  )
849
849
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 3.0.4
3
+ Version: 3.0.5
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "labull-framework"
7
- version = "3.0.4"
7
+ version = "3.0.5"
8
8
  description = "Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks"
9
9
  readme = "README-pypi.md"
10
10
  requires-python = ">=3.10"