labull-framework 0.1.0__py3-none-any.whl

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 (40) hide show
  1. labull_framework/__init__.py +8 -0
  2. labull_framework/admin.py +63 -0
  3. labull_framework/apps.py +33 -0
  4. labull_framework/auth/__init__.py +4 -0
  5. labull_framework/auth/backend.py +144 -0
  6. labull_framework/auth/bootstrap.py +47 -0
  7. labull_framework/auth/sso.py +259 -0
  8. labull_framework/auth/views.py +184 -0
  9. labull_framework/canvas/__init__.py +19 -0
  10. labull_framework/canvas/data.py +523 -0
  11. labull_framework/canvas/gitinfo.py +155 -0
  12. labull_framework/canvas/static/canvas.css +487 -0
  13. labull_framework/canvas/static/canvas.js +1036 -0
  14. labull_framework/canvas/static/page.html +52 -0
  15. labull_framework/canvas/views.py +121 -0
  16. labull_framework/env.py +187 -0
  17. labull_framework/jsonio.py +57 -0
  18. labull_framework/magic_block/__init__.py +2 -0
  19. labull_framework/magic_block/devkit.py +428 -0
  20. labull_framework/magic_block/dispatch.py +314 -0
  21. labull_framework/magic_block/export.py +773 -0
  22. labull_framework/magic_block/health.py +220 -0
  23. labull_framework/magic_block/registry.py +157 -0
  24. labull_framework/magic_block/sdk.py +245 -0
  25. labull_framework/magic_block/service.py +405 -0
  26. labull_framework/magic_block/validate.py +558 -0
  27. labull_framework/magic_block/views.py +407 -0
  28. labull_framework/management/__init__.py +0 -0
  29. labull_framework/management/commands/__init__.py +0 -0
  30. labull_framework/management/commands/labull_contract.py +52 -0
  31. labull_framework/migrations/0001_initial.py +71 -0
  32. labull_framework/migrations/__init__.py +0 -0
  33. labull_framework/models.py +76 -0
  34. labull_framework/settings.py +223 -0
  35. labull_framework/urls.py +93 -0
  36. labull_framework-0.1.0.dist-info/METADATA +140 -0
  37. labull_framework-0.1.0.dist-info/RECORD +40 -0
  38. labull_framework-0.1.0.dist-info/WHEEL +5 -0
  39. labull_framework-0.1.0.dist-info/licenses/LICENSE +28 -0
  40. labull_framework-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,314 @@
1
+ """
2
+ 块后端的**唯一请求入口**:`/bx/<uuid>/<类名>.<方法名>`,一律 POST。
3
+
4
+ 🔴 七步的**顺序就是判据**:CSRF(中间件已做,不豁免)→ 身份(401)→ 块存在且启用
5
+ (不存在 404 / 停用 403 + 原因)→ 路由名(404,**列出这一块真实有的全部路由名**)→
6
+ 权限(Django 的 `has_perm`;没声明 permission ⇒ 只要登录)→ 读 body → 执行(软超时 504)。
7
+ 🔴 `ctx` 的构造**只在这里**(`build_context()`),5 个键;`user` **只从 `request.user` 来**。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import os
15
+ import threading
16
+ from typing import Any
17
+
18
+ from django.db import close_old_connections
19
+ from django.http import HttpRequest, HttpResponse
20
+
21
+ from ..env import BLOCK_CONFIG_PREFIX
22
+ from ..jsonio import NO_IDENTITY_MESSAGE, problem
23
+ from .registry import REGISTRY
24
+ from .sdk import (
25
+ MAX_BODY_BYTES,
26
+ ROUTE_TIMEOUT_SECONDS,
27
+ BlockCtx,
28
+ BlockUser,
29
+ MagicBackendHTTPError,
30
+ block_json,
31
+ errors,
32
+ )
33
+
34
+ logger = logging.getLogger('labull_framework.magic_block')
35
+
36
+
37
+ class _Timeout(MagicBackendHTTPError):
38
+ """软超时(504)。
39
+
40
+ ⚠️ 它刻意**不在 `errors` 里**:那六个是块作者的书写契约(作者写不出 504 的 handler),
41
+ 这一条是分发器自己的语义。
42
+ """
43
+
44
+ status = 504
45
+
46
+ #: 一律 POST:路径是对的、动词不对 ⇒ **405**(不是 404,报文要说清这一点)。
47
+ ALLOWED_METHOD = 'POST'
48
+
49
+ #: 正文一次读多少(分块读、边读边数)。
50
+ _CHUNK = 64 * 1024
51
+
52
+
53
+ # ──────────────────────────── 正文 ────────────────────────────
54
+
55
+
56
+ def _is_json(content_type: str) -> bool:
57
+ """这次请求的正文是不是 JSON。
58
+
59
+ 🔴 **没写 `Content-Type` 也当 JSON**:页面上那些 `fetch` 常常这么发,
60
+ 当文件字节会让 handler 收到一堆字节而不是参数(最难查的一种"参数全丢")。
61
+ """
62
+ essence = (content_type or '').split(';')[0].strip().lower()
63
+ return essence in ('', 'application/json') or essence.endswith('+json')
64
+
65
+
66
+ def read_call_body(request: HttpRequest) -> Any:
67
+ """拿到这一条调用的 `params`。
68
+
69
+ · JSON(`application/json` / `+json` / **没写**)⇒ 解成 `dict`;解不开就 400 并带原文,
70
+ **绝不静默当空对象**(静默的后果是参数全丢,比报错难查得多);
71
+ 顶层不是对象(数组 / 数字 / null)⇒ 包成 `{'body': 原值}`(params 是不是 dict 对作者恒定)。
72
+ · 其它 `Content-Type` ⇒ **原样回 bytes**(块前端传文件走这条)。
73
+
74
+ 🔴 **不用 `request.body`**:那会先撞上 Django 的 `DATA_UPLOAD_MAX_MEMORY_SIZE`(默认 2.5 MB),
75
+ 超了抛的是与"太大"无关的错。这里分块读、**边读边数**(`Content-Length` 客户端可以伪造)。
76
+ """
77
+ length = request.META.get('CONTENT_LENGTH') or ''
78
+ try:
79
+ declared = int(str(length).strip())
80
+ except (TypeError, ValueError):
81
+ declared = 0
82
+ if declared > MAX_BODY_BYTES:
83
+ raise errors.BadRequest(_too_large(declared))
84
+
85
+ chunks: list[bytes] = []
86
+ total = 0
87
+ while True:
88
+ chunk = request.read(_CHUNK)
89
+ if not chunk:
90
+ break
91
+ total += len(chunk)
92
+ if total > MAX_BODY_BYTES:
93
+ raise errors.BadRequest(_too_large(total))
94
+ chunks.append(chunk)
95
+ raw = b''.join(chunks)
96
+
97
+ if not _is_json(request.META.get('CONTENT_TYPE', '')):
98
+ return raw
99
+ text = raw.decode('utf-8', errors='replace').strip()
100
+ if not text:
101
+ # 空正文 = 一次"没有参数"的合法调用。
102
+ return {}
103
+ try:
104
+ parsed = json.loads(text)
105
+ except ValueError as exc:
106
+ raise errors.BadRequest(f'请求正文不是合法 JSON({exc}):请把参数作为 JSON 放进正文') from exc
107
+ return parsed if isinstance(parsed, dict) else {'body': parsed}
108
+
109
+
110
+ def _too_large(size: int) -> str:
111
+ """正文太大的那句话(提前拦与边读边数给的是同一句)。"""
112
+ return (
113
+ f'请求正文太大:至少 {size} 字节,上限是 {MAX_BODY_BYTES} 字节({MAX_BODY_BYTES // 1024 // 1024} MiB)'
114
+ )
115
+
116
+
117
+ # ──────────────────────────── ctx 的构造(唯一一处) ────────────────────────────
118
+
119
+
120
+ def _names(values: Any) -> tuple[str, ...]:
121
+ """Django 的取值集合(组 / 权限)→ 名字元组(`.values_list` 回的是元组,别的形状一律当空)。"""
122
+ out: list[str] = []
123
+ for item in values or ():
124
+ text = str(item)
125
+ if text and text not in out:
126
+ out.append(text)
127
+ return tuple(out)
128
+
129
+
130
+ def user_of(user: Any) -> BlockUser:
131
+ """`request.user` → `BlockUser`(**认人只认会话**,绝不从 body 取)。
132
+
133
+ 🔴 `get_all_permissions()` 回的**就是** `app_label.codename` 那种字符串(Django 原生:
134
+ 直接权限与组权限已经合在一起了),所以这里只做去重与排序,不自己拼。
135
+ 🔴 匿名用户也映射得出来:块里的判据不用先判空。
136
+ """
137
+ if user is None or not getattr(user, 'is_authenticated', False):
138
+ return BlockUser(id='', username='', work_id='', name='', is_staff=False)
139
+ return BlockUser(
140
+ id=str(user.pk or ''),
141
+ username=str(getattr(user, 'get_username', lambda: '')() or ''),
142
+ work_id=str(getattr(user, 'work_id', '') or ''),
143
+ name=str(getattr(user, 'name', '') or ''),
144
+ is_staff=bool(getattr(user, 'is_staff', False)),
145
+ groups=_names(user.groups.values_list('name', flat=True)),
146
+ permissions=_names(sorted(user.get_all_permissions())),
147
+ )
148
+
149
+
150
+ def block_config() -> dict[str, str]:
151
+ """`ctx.config` —— 环境里**带前缀**的那些(剥掉前缀当键)。
152
+
153
+ 🔴 前缀那一个字面量**只有一处**(`env.BLOCK_CONFIG_PREFIX`):两处各写一份,
154
+ 漂了就是"配了却读不到"(而块作者只会以为自己写错了键名)。
155
+ 🔴 **白名单不是黑名单**:里面有数据库连接串与登录密钥,漏出去任何一条都等于把信任链交出去,
156
+ 而块代码是运行时加载的数据(作者随时改、保存即生效)。
157
+ 🔴 **没配的键不存在**(不造空串):配成空串照给(那是一次明确的配置动作)。
158
+ """
159
+ out: dict[str, str] = {}
160
+ for name, value in os.environ.items():
161
+ if not name.startswith(BLOCK_CONFIG_PREFIX):
162
+ continue
163
+ key = name[len(BLOCK_CONFIG_PREFIX):]
164
+ if key:
165
+ out[key] = str(value)
166
+ return out
167
+
168
+
169
+ def build_context(request: HttpRequest, block: Any) -> BlockCtx:
170
+ """组装交给 handler 的 `ctx`(**4 个键,见 `sdk.CTX_KEYS`**)。
171
+
172
+ 🔴 构造只在这一处:多一个键就是给块代码多一样能力(而契约、文档、校验三边都要跟着改)。
173
+ """
174
+ return BlockCtx(
175
+ user=user_of(getattr(request, 'user', None)),
176
+ config=block_config(),
177
+ request=request,
178
+ block={
179
+ 'id': block.id,
180
+ 'module': block.module or '',
181
+ 'name': block.name or '',
182
+ 'brief': block.brief or '',
183
+ },
184
+ )
185
+
186
+
187
+ # ──────────────────────────── 执行(软超时) ────────────────────────────
188
+
189
+
190
+ def run_with_timeout(handler: Any, ctx: BlockCtx, params: Any) -> Any:
191
+ """跑一条 handler,最多等 `ROUTE_TIMEOUT_SECONDS` 秒。
192
+
193
+ 🔴 **超时只回话、不停别人**(Python 杀不掉正在跑的线程):请求方立刻收到 504,
194
+ handler 继续在后台跑完、结果被丢掉。报文必须说清这一点 —— 否则调用方会以为可以安全重试。
195
+ 🔴 工作线程第一件事与 finally 都 `close_old_connections()`:Django 的数据库连接是线程局部的。
196
+ """
197
+ outcome: dict[str, Any] = {}
198
+
199
+ def work() -> None:
200
+ close_old_connections()
201
+ try:
202
+ outcome['value'] = handler(ctx, params)
203
+ except BaseException as exc: # noqa: BLE001 —— 原样带回主线程去分类
204
+ outcome['error'] = exc
205
+ finally:
206
+ close_old_connections()
207
+
208
+ worker = threading.Thread(target=work, name='labull-block-call', daemon=True)
209
+ worker.start()
210
+ worker.join(ROUTE_TIMEOUT_SECONDS)
211
+ if worker.is_alive():
212
+ raise _Timeout(
213
+ f'调用超时(超过 {ROUTE_TIMEOUT_SECONDS} 秒):这次请求不再等下去,但那个方法**还在后台跑**'
214
+ '(Python 停不掉正在跑的线程)—— 所以别把它当"没执行"直接重试;'
215
+ '长时间任务请改成"先落状态、再异步处理"的形态'
216
+ )
217
+ if 'error' in outcome:
218
+ raise outcome['error']
219
+ return outcome.get('value')
220
+
221
+
222
+ # ──────────────────────────── 一次调用的完整流程 ────────────────────────────
223
+
224
+
225
+ def call_route(request: HttpRequest, uuid: str, route: str) -> tuple[int, Any]:
226
+ """按顺序处理一条块调用,返回 `(状态码, 要回的内容)`(**不抛**:401 / 403 / 404 都是有语义的答复)。"""
227
+ # ② 身份:只认会话(`AuthenticationMiddleware` 挂的 `request.user`)。
228
+ # 🔴 不看 `Authorization` 头:Django 侧的身份只有一个来源,两个来源就是两个真源。
229
+ user = getattr(request, 'user', None)
230
+ if user is None or not getattr(user, 'is_authenticated', False):
231
+ return 401, {'message': NO_IDENTITY_MESSAGE}
232
+
233
+ # ③ 块存在且启用。🔴 停用要**明确拒绝**(403 + 原因),不装成"没有这个块"。
234
+ from ..models import MagicBlock
235
+
236
+ if not uuid:
237
+ return 404, {'message': '路径里没有块的 uuid:要写成 /bx/<uuid>/<类名>.<方法名>'}
238
+ row = (
239
+ MagicBlock.objects.filter(id=uuid)
240
+ .only('id', 'module', 'name', 'brief', 'enabled', 'disabled_reason')
241
+ .first()
242
+ )
243
+ if row is None:
244
+ return 404, {'message': f'没有这个块:{uuid}'}
245
+ if row.enabled is False:
246
+ return 403, {
247
+ 'message': f'这个块已停用:{row.disabled_reason or "体检没通过"}'
248
+ '(管理员在管理台点一次校验、改好之后会自动恢复启用)'
249
+ }
250
+
251
+ # ④ 路由表:版本对不上就在这一步重新编译(**"保存即生效"落在这里**)。
252
+ try:
253
+ backend = REGISTRY.get(uuid)
254
+ except LookupError:
255
+ return 404, {'message': f'没有这个块:{uuid}'}
256
+ except Exception as exc: # noqa: BLE001 —— 读库 / 编译出岔子要说清是哪一步
257
+ return 500, {'message': f'读这个块的后端代码失败:{exc}'}
258
+
259
+ names = backend.route_names()
260
+ known = ' / '.join(names) if names else '(没有带 @MagicBackendAPI 的方法)'
261
+ if '.' not in str(route or ''):
262
+ return 404, {
263
+ 'message': f'路由名要写成「类名.方法名」(例如 Customer.create),收到的是:{route or "(空)"};'
264
+ f'这个块有:{known}'
265
+ }
266
+ compiled = backend.routes.get(route)
267
+ if compiled is None:
268
+ # 🔴 必须列出这一块真实有的**全部**路由名:客户端那侧没有名字白名单,写错名字的反馈全靠这句。
269
+ return 404, {'message': f'没有这个接口:{route};这个块有:{known}'}
270
+
271
+ # ⑤ 权限:没声明 ⇒ 只要登录;声明了就走 Django 的 `has_perm`(组与权限是框架的事)。
272
+ if compiled.permission and not user.has_perm(compiled.permission):
273
+ return 403, {'message': f'没有权限调用 {route}:这条接口要 {compiled.permission}'}
274
+
275
+ # ⑥ 正文只在这里读一次。
276
+ try:
277
+ params = read_call_body(request)
278
+ except MagicBackendHTTPError as exc:
279
+ return exc.status, {'message': str(exc)}
280
+
281
+ # ⑦ 执行。
282
+ ctx = build_context(request, row)
283
+ try:
284
+ value = run_with_timeout(compiled.handler, ctx, params)
285
+ except MagicBackendHTTPError as exc:
286
+ return exc.status, {'message': str(exc)}
287
+ except Exception as exc: # noqa: BLE001 —— 块自己的 bug 一律 500,原文照带
288
+ return 500, {'message': f'{type(exc).__name__}:{exc}'}
289
+
290
+ try:
291
+ text = block_json(value)
292
+ except MagicBackendHTTPError as exc:
293
+ return exc.status, {'message': str(exc)}
294
+ # 🔴 正文已经是编好的 JSON 文本(日期 / 金额已转成字符串):交给出口时**原样**当正文 ——
295
+ # 再让 Django 编一遍,那一步就整个被绕过去了。
296
+ return 200, text
297
+
298
+
299
+ def block_dispatch_view(request: HttpRequest, uuid: str, route: str) -> HttpResponse:
300
+ """`/bx/<uuid>/<类名>.<方法名>` —— 一律 POST。
301
+
302
+ 🔴 **CSRF 由中间件做**(这条路由不豁免):这里没有、也不该有第二道 CSRF 判据。
303
+ """
304
+ if request.method != ALLOWED_METHOD:
305
+ return problem(f'块后端只认 {ALLOWED_METHOD}(参数放进 JSON 正文)', 405)
306
+ status, payload = call_route(request, uuid, route)
307
+ if status == 200:
308
+ # 🔴 正文已经是编好的 JSON 文本:**原样**当正文(再编一遍就绕过 `block_json` 的编码规则)。
309
+ return HttpResponse(str(payload), content_type='application/json; charset=utf-8')
310
+ # 🔴 出错一律走 `problem()`:线格式只有一种(`{statusCode, message}`)——
311
+ # 两处各拼一份的下场是前端要按两种形状判错。
312
+ message = payload.get('message') if isinstance(payload, dict) else str(payload)
313
+ logger.warning('[magic-block] /bx/%s/%s → %s:%s', uuid, route, status, message)
314
+ return problem(str(message or ''), status)