labull-framework 0.1.6__tar.gz → 0.1.7__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 (48) hide show
  1. {labull_framework-0.1.6/labull_framework.egg-info → labull_framework-0.1.7}/PKG-INFO +1 -1
  2. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/magic_block/devkit.py +31 -2
  3. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/magic_block/dispatch.py +14 -0
  4. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/magic_block/export.py +12 -1
  5. {labull_framework-0.1.6 → labull_framework-0.1.7/labull_framework.egg-info}/PKG-INFO +1 -1
  6. {labull_framework-0.1.6 → labull_framework-0.1.7}/pyproject.toml +1 -1
  7. {labull_framework-0.1.6 → labull_framework-0.1.7}/LICENSE +0 -0
  8. {labull_framework-0.1.6 → labull_framework-0.1.7}/MANIFEST.in +0 -0
  9. {labull_framework-0.1.6 → labull_framework-0.1.7}/README-pypi.md +0 -0
  10. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/__init__.py +0 -0
  11. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/admin.py +0 -0
  12. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/apps.py +0 -0
  13. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/auth/__init__.py +0 -0
  14. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/auth/backend.py +0 -0
  15. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/auth/bootstrap.py +0 -0
  16. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/auth/sso.py +0 -0
  17. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/auth/views.py +0 -0
  18. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/canvas/__init__.py +0 -0
  19. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/canvas/data.py +0 -0
  20. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/canvas/gitinfo.py +0 -0
  21. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/canvas/static/canvas.css +0 -0
  22. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/canvas/static/canvas.js +0 -0
  23. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/canvas/static/page.html +0 -0
  24. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/canvas/views.py +0 -0
  25. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/env.py +0 -0
  26. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/jsonio.py +0 -0
  27. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/magic_block/__init__.py +0 -0
  28. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/magic_block/blockpackage.py +0 -0
  29. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/magic_block/deepcheck.py +0 -0
  30. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/magic_block/health.py +0 -0
  31. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/magic_block/registry.py +0 -0
  32. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/magic_block/sdk.py +0 -0
  33. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/magic_block/service.py +0 -0
  34. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/magic_block/validate.py +0 -0
  35. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/magic_block/views.py +0 -0
  36. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/management/__init__.py +0 -0
  37. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/management/commands/__init__.py +0 -0
  38. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/management/commands/labull_contract.py +0 -0
  39. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/migrations/0001_initial.py +0 -0
  40. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/migrations/__init__.py +0 -0
  41. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/models.py +0 -0
  42. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/settings.py +0 -0
  43. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework/urls.py +0 -0
  44. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework.egg-info/SOURCES.txt +0 -0
  45. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework.egg-info/dependency_links.txt +0 -0
  46. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework.egg-info/requires.txt +0 -0
  47. {labull_framework-0.1.6 → labull_framework-0.1.7}/labull_framework.egg-info/top_level.txt +0 -0
  48. {labull_framework-0.1.6 → labull_framework-0.1.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 0.1.6
3
+ Version: 0.1.7
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
@@ -331,7 +331,7 @@ EXAMPLE_FILES: tuple[tuple[str, str], ...] = (
331
331
  #: 块自检脚本的正文:真源是仓库里的 `tools/smoke.mjs`(能直接 `node` 跑、也能改),
332
332
  #: 这里逐字抄一份铺进开发目录 —— 作者在**发布前**本地就能把块过一遍。
333
333
  #: ⚠️ 与 `EXAMPLE_FILES` 一样是**生成物**:改脚本请改 `tools/smoke.mjs`,然后重跑生成器。
334
- SMOKE_SCRIPT: str = '/* 块包自检(**本地就能跑**):把"发布前一定会后悔的那几条"提前抓出来。\n *\n * 用法(在开发目录里,或者指向它):\n * node smoke.mjs # 只查"有改动的那几个块"(跟 git 比),没有就查全部\n * node smoke.mjs --all # 查全部块\n * node smoke.mjs <块文件夹|块 uuid> # 只查一个块(可以给多个)\n * node smoke.mjs --workspace <开发目录>\n * LABULL_SMOKE_PYTHON=<python> node smoke.mjs # 指一个装了 Django 的解释器 ⇒ 多跑一层\n *\n * 它查两类东西:\n * ① **纯文本层(不需要任何依赖)**:清单四个键、文件名形状、前端引用链\n * (`<script src>` / `<link href>` 指向的文件在不在包里)、"包里的文件没人引用"、\n * 以及**后端文件之间不许互相引用**这条规矩。\n * ② **平台那一层(有 Python + Django 时才跑)**:真的按平台自己的判据加载一次\n * (`labull_framework.magic_block.validate`)—— 那份判据是**同一份代码**,\n * 不是在这里抄一遍(抄一遍的下场是"本地说没事、管理台说不合格")。\n *\n * 🔴 退了就报清楚:没装 Python / 没装 Django 时,第 ② 层会明说"这一层没跑",\n * **不假装自己全绿**(自检的意义就是提前发现,不是给个安心)。\n * 🔴 输出不用 emoji:中文 Windows 控制台是 cp936,emoji 会变成乱码让整行不可读。\n */\n\nimport { readdirSync, readFileSync, statSync } from \'node:fs\'\nimport { basename, extname, join, relative, resolve, sep } from \'node:path\'\nimport { existsSync } from \'node:fs\'\nimport { spawnSync } from \'node:child_process\'\n\nconst ENTRY = \'index.open.html\'\nconst MANIFEST = \'magic-block.open.yaml\'\nconst BACKEND = \'backend_open.py\'\n/** 平台随开发目录铺下来的生成物:它们**不进块包**,引用不到也不算错。 */\nconst PLATFORM_FILES = new Set([\n \'api.open.d.ts\', \'VERSION.lock\', \'README.lock.md\', \'GUIDE.lock.md\', \'backend.lock.pyi\',\n \'pages.lock.ts\', \'labull.css\', \'CONTROLS.lock.html\', \'requirements.lock.txt\',\n])\n/** 爬目录时跳过的目录(与后端 `export.SKIP_DIRS` 同一份口径)。 */\nconst SKIP_DIRS = new Set([\'.git\', \'.venv\', \'venv\', \'__pycache__\', \'node_modules\', \'dist\', \'build\', \'.idea\', \'.vscode\', \'example\'])\n/** 认哪些扩展名算"能进包的文本"。 */\nconst TEXT_EXT = new Set([\'.html\', \'.htm\', \'.js\', \'.mjs\', \'.cjs\', \'.ts\', \'.css\', \'.json\', \'.yaml\', \'.yml\', \'.md\', \'.txt\', \'.py\', \'.svg\'])\nconst MAX_DEPTH = 3\nconst MAX_FILE_BYTES = 512 * 1024\n\nconst argv = process.argv.slice(2)\nconst flags = { all: false, workspace: \'\', python: process.env.LABULL_SMOKE_PYTHON || \'\', targets: [] }\nfor (let i = 0; i < argv.length; i += 1) {\n if (argv[i] === \'--all\') flags.all = true\n else if (argv[i] === \'--workspace\') flags.workspace = argv[++i] || \'\'\n else if (argv[i] === \'--python\') flags.python = argv[++i] || \'\'\n else flags.targets.push(argv[i])\n}\n\nconst pass = []\nconst fail = []\nconst skip = []\nconst ok = (message) => { pass.push(message); console.log(` [OK] ${message}`) }\nconst ng = (message) => { fail.push(message); console.log(` [NG] ${message}`) }\nconst note = (message) => { skip.push(message); console.log(` [--] ${message}`) }\nconst info = (message) => console.log(message)\n\nfunction readText(path) {\n const raw = readFileSync(path)\n if (raw.length > MAX_FILE_BYTES) return { text: \'\', tooBig: true }\n return { text: raw.toString(\'utf8\').replace(/^\\uFEFF/, \'\'), tooBig: false }\n}\n\n// ──────────────────────────── 找开发目录与块 ────────────────────────────\n\nfunction findWorkspace(start) {\n let dir = resolve(start)\n for (let depth = 0; depth < 6; depth += 1) {\n const workspace = join(dir, \'workspace\')\n if (existsSync(workspace) && statSync(workspace).isDirectory()) return workspace\n if (basename(dir) === \'workspace\') return dir\n const parent = resolve(dir, \'..\')\n if (parent === dir) break\n dir = parent\n }\n return \'\'\n}\n\nfunction listBlocks(workspace) {\n const out = []\n for (const name of readdirSync(workspace).sort()) {\n const full = join(workspace, name)\n if (!statSync(full).isDirectory()) continue\n // 🔴 只认**块文件夹**(里面有清单):开发目录里还有 `example/` 这种平台铺下来的东西,\n // 把它也当成一个块去查,只会平白报一堆"缺这个缺那个"。\n if (!existsSync(join(full, MANIFEST)) && !existsSync(join(full, ENTRY))) continue\n out.push(full)\n }\n return out\n}\n\n/** 一个块文件夹 → `{相对路径: 正文}`(与平台 `export` 的扫描口径一致)。 */\nfunction readBlock(dir) {\n const files = {}\n const walk = (current, segments, depth) => {\n for (const name of readdirSync(current).sort()) {\n const full = join(current, name)\n const stat = statSync(full)\n const at = [...segments, name]\n if (stat.isDirectory()) {\n if (SKIP_DIRS.has(name) || depth >= MAX_DEPTH) continue\n walk(full, at, depth + 1)\n continue\n }\n if (!TEXT_EXT.has(extname(name).toLowerCase())) continue\n if (stat.size > MAX_FILE_BYTES) { files[at.join(\'/\')] = \'\'; continue }\n files[at.join(\'/\')] = readText(full).text\n }\n }\n walk(dir, [], 0)\n return files\n}\n\n// ──────────────────────────── ① 纯文本层 ────────────────────────────\n\nfunction parseManifest(text) {\n const found = {}\n const issues = []\n const stripComment = (line) => {\n let quote = \'\'\n for (let i = 0; i < line.length; i += 1) {\n const char = line[i]\n if (quote) { if (char === quote) quote = \'\' }\n else if (char === \'"\' || char === "\'") quote = char\n else if (char === \'#\') return line.slice(0, i)\n }\n return line\n }\n text.split(/\\r?\\n/).forEach((raw, index) => {\n const line = stripComment(raw).trim()\n if (!line) return\n const at = line.indexOf(\':\')\n if (at < 0) { issues.push(`第 ${index + 1} 行不是「键: 值」:${line}`); return }\n const key = line.slice(0, at).trim()\n const value = line.slice(at + 1).trim().replace(/^([\'"])(.*)\\1$/, \'$2\')\n if (![\'id\', \'module\', \'name\', \'brief\'].includes(key)) { issues.push(`第 ${index + 1} 行有不认识的键:${key}`); return }\n if (found[key] !== undefined) { issues.push(`第 ${index + 1} 行重复写了 ${key}`); return }\n found[key] = value\n })\n for (const key of [\'id\', \'module\', \'name\', \'brief\']) {\n if (!found[key]) issues.push(`清单缺 ${key}(或者是空的)`)\n }\n if (found.id && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(found.id)) {\n issues.push(`清单里的 id 不是 uuid:${found.id}`)\n }\n if (found.name && found.name.length > 40) issues.push(`name 超过 40 个字:${found.name.length}`)\n return { found, issues }\n}\n\n/** 前端引用链:入口里 `src=` / `href=` 指向的**包内**文件在不在。回 `{missing, referenced}`。 */\nfunction frontendRefs(files) {\n const referenced = new Map() // 引用者 → 被引用的相对路径\n const missing = []\n const pick = (ref) => {\n if (/^(?:[a-z][a-z0-9+.-]*:|\\/\\/)/i.test(ref)) return undefined // 外链 / data: → 不是包内文件\n const clean = ref.split(/[?#]/)[0].replace(/^\\.\\//, \'\').replace(/^\\/+/, \'\')\n if (!clean) return undefined\n if (files[clean] !== undefined) return clean\n const base = clean.split(\'/\').pop()\n return base ? Object.keys(files).find((name) => name.split(\'/\').pop() === base) : undefined\n }\n const scan = (owner, body) => {\n for (const match of body.matchAll(/<(?:script|link)\\b[^>]*?(?:src|href)\\s*=\\s*(["\'])(.*?)\\1[^>]*>/gi)) {\n const ref = match[2]\n const hit = pick(ref)\n if (hit) referenced.set(owner, [...(referenced.get(owner) || []), hit])\n else if (!/^(?:[a-z][a-z0-9+.-]*:|\\/\\/)/i.test(ref) && basename(ref) !== \'labull.css\') {\n missing.push(`${owner} 引用的 ${ref} 不在包里`)\n }\n }\n }\n for (const [name, body] of Object.entries(files)) {\n if (name.endsWith(\'.html\')) scan(name, body)\n }\n return { missing, referenced }\n}\n\n/** 每个 `.py` 里 import 的**包内**文件("文件之间不许互相引用"这条规矩的判据)。 */\nfunction siblingImports(files) {\n const known = new Map()\n for (const name of Object.keys(files)) {\n if (!name.endsWith(\'.py\')) continue\n const stem = name.slice(0, -3)\n known.set(stem, name)\n known.set(stem.split(\'/\').pop(), name)\n }\n const out = []\n for (const [name, body] of Object.entries(files)) {\n if (!name.endsWith(\'.py\')) continue\n for (const line of body.split(/\\r?\\n/)) {\n const trimmed = line.trim()\n if (trimmed.startsWith(\'#\')) continue\n const from = /^from\\s+([A-Za-z_][\\w.]*)\\s+import\\b/.exec(trimmed)\n const plain = /^import\\s+(.+)$/.exec(trimmed)\n const names = []\n if (from) names.push(from[1])\n else if (plain) names.push(...plain[1].split(\',\').map((item) => item.trim().split(/\\s+as\\s+/)[0]))\n for (const imported of names) {\n const hit = known.get(imported) || known.get(imported.split(\'.\')[0])\n if (hit && hit !== name) out.push(`${name} import 了包内的 ${imported}(就是 ${hit})`)\n }\n }\n }\n return out\n}\n\nfunction checkBlock(dir) {\n const label = basename(dir)\n info(`\\n── ${label} ──`)\n const files = readBlock(dir)\n const names = Object.keys(files)\n\n if (!files[ENTRY]) ng(`${label}:缺入口 ${ENTRY}`)\n else ok(`${label}:有入口 ${ENTRY}`)\n\n if (files[MANIFEST] === undefined) {\n ng(`${label}:缺清单 ${MANIFEST}`)\n } else {\n const { found, issues } = parseManifest(files[MANIFEST])\n if (issues.length) ng(`${label}:清单有问题 —— ${issues.join(\';\')}`)\n else ok(`${label}:清单四个键齐(${found.module} / ${found.name})`)\n }\n\n for (const name of names) {\n if (!/^(?![A-Za-z]:)(?!\\/)[A-Za-z0-9._\\-/]+$/.test(name) || name.split(\'/\').includes(\'..\')) {\n ng(`${label}:文件名不合法 ${name}`)\n }\n }\n\n const { missing, referenced } = frontendRefs(files)\n for (const item of missing) ng(`${label}:${item}`)\n if (!missing.length && files[ENTRY]) ok(`${label}:前端的引用都指得到(入口里的 script/link 都在包里)`)\n\n // "包里的文件没人引用":只提示(入口文件与清单、后端本来就不该被引用)。\n const used = new Set([ENTRY, MANIFEST, BACKEND, ...[...referenced.values()].flat()])\n const orphans = names.filter((name) => !used.has(name) && !PLATFORM_FILES.has(name)\n && !name.endsWith(\'.py\') && name !== \'README.md\')\n if (orphans.length) note(`${label}:这几个文件没有任何引用(写进包里也没人用):${orphans.join(\' / \')}`)\n\n const cross = siblingImports(files)\n if (cross.length) {\n for (const item of cross) {\n ng(`${label}:${item} —— **块里文件之间不能互相引用**(平台把每个 .py 各自独立加载):`\n + \'要么把那段逻辑复制过去,要么放进项目里的公共模块再 import\')\n }\n } else if (names.some((name) => name.endsWith(\'.py\'))) {\n ok(`${label}:后端那些 .py 之间没有互相引用(${names.filter((n) => n.endsWith(\'.py\')).join(\' / \')})`)\n }\n return files\n}\n\n// ──────────────────────────── ② 平台那一层 ────────────────────────────\n\nfunction findPython() {\n if (flags.python) return flags.python\n const candidates = [process.env.LABULL_PYTHON, \'python\', \'python3\', \'py\'].filter(Boolean)\n const probe = \'import django, labull_framework; print("ok")\'\n for (const candidate of candidates) {\n const done = spawnSync(candidate, [\'-c\', probe], {\n encoding: \'utf-8\',\n env: { ...process.env, PYTHONIOENCODING: \'utf-8\' },\n })\n if (done.status === 0 && String(done.stdout).includes(\'ok\')) return candidate\n }\n return \'\'\n}\n\nfunction platformCheck(workspace, python, payload) {\n const script = [\n \'import json, os, sys\',\n \'from labull_framework.magic_block import validate\',\n \'payload = json.loads(os.environ["LABULL_SMOKE_PAYLOAD"])\',\n \'out = []\',\n \'for item in payload:\',\n \' issues = validate.check_block(item["files"])\',\n \' out.append({"name": item["name"], "issues": [list(pair) for pair in issues]})\',\n \'print("LABULL_SMOKE" + json.dumps(out, ensure_ascii=False))\',\n ].join(\'\\n\')\n const done = spawnSync(python, [\'-c\', script], {\n encoding: \'utf-8\',\n env: { ...process.env, PYTHONIOENCODING: \'utf-8\', LABULL_SMOKE_PAYLOAD: JSON.stringify(payload) },\n })\n const text = `${done.stdout || \'\'}${done.stderr || \'\'}`\n const at = text.indexOf(\'LABULL_SMOKE\')\n if (done.status !== 0 || at < 0) {\n note(`平台那一层没跑起来(退出码 ${done.status}):${text.trim().split(\'\\n\').slice(-1)[0] || \'没有输出\'}`)\n return\n }\n const results = JSON.parse(text.slice(at + \'LABULL_SMOKE\'.length))\n for (const item of results) {\n if (!item.issues.length) { ok(`${item.name}:过平台那一层判据(清单/结构/试加载/引用深检)`); continue }\n for (const [stage, message] of item.issues) ng(`${item.name}:[${stage}] ${message}`)\n }\n}\n\n// ──────────────────────────── 主流程 ────────────────────────────\n\nconst workspace = flags.workspace\n ? (findWorkspace(flags.workspace) || resolve(flags.workspace))\n : findWorkspace(process.cwd())\nif (!workspace) {\n console.log(\'找不到开发目录(里面应该有 workspace/ 那一层)。用法:node smoke.mjs [--workspace <开发目录>] [块…]\')\n process.exit(2)\n}\nlet blocks = listBlocks(workspace)\nif (flags.targets.length) {\n blocks = blocks.filter((dir) => flags.targets.some((target) => basename(dir).includes(target)))\n if (!blocks.length) { console.log(`按 ${flags.targets.join(\' / \')} 没找到块(在 ${workspace} 里)`); process.exit(2) }\n}\ninfo(`开发目录:${workspace}`)\ninfo(`要查的块:${blocks.length} 个${flags.targets.length ? `(按 ${flags.targets.join(\' / \')} 筛的)` : flags.all ? \'(全部)\' : \'\'}`)\n\nconst payload = []\nfor (const dir of blocks) {\n const files = checkBlock(dir)\n payload.push({ name: basename(dir), files })\n}\n\nconst python = findPython()\nif (!python) {\n note(\'没找到装了 Django 的 Python ⇒ **平台那一层没跑**(试加载 / 引用深检 / 路由那几条没验)。\'\n + \'设 LABULL_SMOKE_PYTHON=<项目解释器> 或 --python <python> 再跑一次。\')\n} else {\n info(`\\n── 平台判据(用 ${python})──`)\n platformCheck(workspace, python, payload)\n}\n\ninfo(`\\n合计:过 ${pass.length} 条、不过 ${fail.length} 条${skip.length ? `、跳过 ${skip.length} 条` : \'\'}`)\nif (fail.length) {\n info(\'不过的:\')\n for (const item of fail) info(` - ${item}`)\n process.exit(1)\n}\ninfo(\'都过了。\')\n'
334
+ SMOKE_SCRIPT: str = '/* 块包自检(**本地就能跑**):把"发布前一定会后悔的那几条"提前抓出来。\n *\n * 用法(在开发目录里,或者指向它):\n * node smoke.mjs # 只查"有改动的那几个块"(跟 git 比),没有就查全部\n * node smoke.mjs --all # 查全部块\n * node smoke.mjs <块文件夹|块 uuid> # 只查一个块(可以给多个)\n * node smoke.mjs --workspace <开发目录>\n * LABULL_SMOKE_PYTHON=<python> node smoke.mjs # 指一个装了 Django 的解释器 ⇒ 多跑一层\n *\n * 它查两类东西:\n * ① **纯文本层(不需要任何依赖)**:清单四个键、文件名形状、前端引用链\n * (`<script src>` / `<link href>` 指向的文件在不在包里)、"包里的文件没人引用"、\n * 以及**后端文件之间不许互相引用**这条规矩。\n * ② **平台那一层(有 Python + Django 时才跑)**:真的按平台自己的判据加载一次\n * (`labull_framework.magic_block.validate`)—— 那份判据是**同一份代码**,\n * 不是在这里抄一遍(抄一遍的下场是"本地说没事、管理台说不合格")。\n *\n * 🔴 退了就报清楚:没装 Python / 没装 Django 时,第 ② 层会明说"这一层没跑",\n * **不假装自己全绿**(自检的意义就是提前发现,不是给个安心)。\n * 🔴 输出不用 emoji:中文 Windows 控制台是 cp936,emoji 会变成乱码让整行不可读。\n */\n\nimport { readdirSync, readFileSync, statSync } from \'node:fs\'\nimport { basename, extname, join, relative, resolve, sep } from \'node:path\'\nimport { existsSync } from \'node:fs\'\nimport { spawnSync } from \'node:child_process\'\n\nconst ENTRY = \'index.open.html\'\nconst MANIFEST = \'magic-block.open.yaml\'\nconst BACKEND = \'backend_open.py\'\n/** 平台随开发目录铺下来的生成物:它们**不进块包**,引用不到也不算错。 */\nconst PLATFORM_FILES = new Set([\n \'api.open.d.ts\', \'VERSION.lock\', \'README.lock.md\', \'GUIDE.lock.md\', \'backend.lock.pyi\',\n \'pages.lock.ts\', \'labull.css\', \'CONTROLS.lock.html\', \'requirements.lock.txt\',\n])\n/** 爬目录时跳过的目录(与后端 `export.SKIP_DIRS` 同一份口径)。 */\nconst SKIP_DIRS = new Set([\'.git\', \'.venv\', \'venv\', \'__pycache__\', \'node_modules\', \'dist\', \'build\', \'.idea\', \'.vscode\', \'example\'])\n/** 认哪些扩展名算"能进包的文本"。 */\nconst TEXT_EXT = new Set([\'.html\', \'.htm\', \'.js\', \'.mjs\', \'.cjs\', \'.ts\', \'.css\', \'.json\', \'.yaml\', \'.yml\', \'.md\', \'.txt\', \'.py\', \'.svg\'])\nconst MAX_DEPTH = 3\nconst MAX_FILE_BYTES = 512 * 1024\n\nconst argv = process.argv.slice(2)\nconst flags = { all: false, workspace: \'\', python: process.env.LABULL_SMOKE_PYTHON || \'\', targets: [] }\nfor (let i = 0; i < argv.length; i += 1) {\n if (argv[i] === \'--all\') flags.all = true\n else if (argv[i] === \'--workspace\') flags.workspace = argv[++i] || \'\'\n else if (argv[i] === \'--python\') flags.python = argv[++i] || \'\'\n else flags.targets.push(argv[i])\n}\n\nconst pass = []\nconst fail = []\nconst skip = []\nconst ok = (message) => { pass.push(message); console.log(` [OK] ${message}`) }\nconst ng = (message) => { fail.push(message); console.log(` [NG] ${message}`) }\nconst note = (message) => { skip.push(message); console.log(` [--] ${message}`) }\nconst info = (message) => console.log(message)\n\nfunction readText(path) {\n const raw = readFileSync(path)\n if (raw.length > MAX_FILE_BYTES) return { text: \'\', tooBig: true }\n return { text: raw.toString(\'utf8\').replace(/^\\uFEFF/, \'\'), tooBig: false }\n}\n\n// ──────────────────────────── 找开发目录与块 ────────────────────────────\n\nfunction findWorkspace(start) {\n let dir = resolve(start)\n for (let depth = 0; depth < 6; depth += 1) {\n const workspace = join(dir, \'workspace\')\n if (existsSync(workspace) && statSync(workspace).isDirectory()) return workspace\n if (basename(dir) === \'workspace\') return dir\n const parent = resolve(dir, \'..\')\n if (parent === dir) break\n dir = parent\n }\n return \'\'\n}\n\nfunction listBlocks(workspace) {\n const out = []\n for (const name of readdirSync(workspace).sort()) {\n const full = join(workspace, name)\n if (!statSync(full).isDirectory()) continue\n // 🔴 只认**块文件夹**(里面有清单):开发目录里还有 `example/` 这种平台铺下来的东西,\n // 把它也当成一个块去查,只会平白报一堆"缺这个缺那个"。\n if (!existsSync(join(full, MANIFEST)) && !existsSync(join(full, ENTRY))) continue\n out.push(full)\n }\n return out\n}\n\n/** 一个块文件夹 → `{相对路径: 正文}`(与平台 `export` 的扫描口径一致)。 */\nfunction readBlock(dir) {\n const files = {}\n const walk = (current, segments, depth) => {\n for (const name of readdirSync(current).sort()) {\n const full = join(current, name)\n const stat = statSync(full)\n const at = [...segments, name]\n if (stat.isDirectory()) {\n if (SKIP_DIRS.has(name) || depth >= MAX_DEPTH) continue\n walk(full, at, depth + 1)\n continue\n }\n if (!TEXT_EXT.has(extname(name).toLowerCase())) continue\n if (stat.size > MAX_FILE_BYTES) { files[at.join(\'/\')] = \'\'; continue }\n files[at.join(\'/\')] = readText(full).text\n }\n }\n walk(dir, [], 0)\n return files\n}\n\n// ──────────────────────────── ① 纯文本层 ────────────────────────────\n\nfunction parseManifest(text) {\n const found = {}\n const issues = []\n const stripComment = (line) => {\n let quote = \'\'\n for (let i = 0; i < line.length; i += 1) {\n const char = line[i]\n if (quote) { if (char === quote) quote = \'\' }\n else if (char === \'"\' || char === "\'") quote = char\n else if (char === \'#\') return line.slice(0, i)\n }\n return line\n }\n text.split(/\\r?\\n/).forEach((raw, index) => {\n const line = stripComment(raw).trim()\n if (!line) return\n const at = line.indexOf(\':\')\n if (at < 0) { issues.push(`第 ${index + 1} 行不是「键: 值」:${line}`); return }\n const key = line.slice(0, at).trim()\n const value = line.slice(at + 1).trim().replace(/^([\'"])(.*)\\1$/, \'$2\')\n if (![\'id\', \'module\', \'name\', \'brief\'].includes(key)) { issues.push(`第 ${index + 1} 行有不认识的键:${key}`); return }\n if (found[key] !== undefined) { issues.push(`第 ${index + 1} 行重复写了 ${key}`); return }\n found[key] = value\n })\n for (const key of [\'id\', \'module\', \'name\', \'brief\']) {\n if (!found[key]) issues.push(`清单缺 ${key}(或者是空的)`)\n }\n if (found.id && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(found.id)) {\n issues.push(`清单里的 id 不是 uuid:${found.id}`)\n }\n if (found.name && found.name.length > 40) issues.push(`name 超过 40 个字:${found.name.length}`)\n return { found, issues }\n}\n\n/** 前端引用链:入口里 `src=` / `href=` 指向的**包内**文件在不在。回 `{missing, referenced}`。 */\nfunction frontendRefs(files) {\n const referenced = new Map() // 引用者 → 被引用的相对路径\n const missing = []\n const pick = (ref) => {\n if (/^(?:[a-z][a-z0-9+.-]*:|\\/\\/)/i.test(ref)) return undefined // 外链 / data: → 不是包内文件\n const clean = ref.split(/[?#]/)[0].replace(/^\\.\\//, \'\').replace(/^\\/+/, \'\')\n if (!clean) return undefined\n if (files[clean] !== undefined) return clean\n const base = clean.split(\'/\').pop()\n return base ? Object.keys(files).find((name) => name.split(\'/\').pop() === base) : undefined\n }\n const scan = (owner, body) => {\n for (const match of body.matchAll(/<(?:script|link)\\b[^>]*?(?:src|href)\\s*=\\s*(["\'])(.*?)\\1[^>]*>/gi)) {\n const ref = match[2]\n const hit = pick(ref)\n if (hit) referenced.set(owner, [...(referenced.get(owner) || []), hit])\n else if (!/^(?:[a-z][a-z0-9+.-]*:|\\/\\/)/i.test(ref) && basename(ref) !== \'labull.css\') {\n missing.push(`${owner} 引用的 ${ref} 不在包里`)\n }\n }\n }\n for (const [name, body] of Object.entries(files)) {\n if (name.endsWith(\'.html\')) scan(name, body)\n }\n return { missing, referenced }\n}\n\n/** 每个 `.py` 里 import 的**包内**文件("文件之间不许互相引用"这条规矩的判据)。 */\nfunction siblingImports(files) {\n const known = new Map()\n for (const name of Object.keys(files)) {\n if (!name.endsWith(\'.py\')) continue\n const stem = name.slice(0, -3)\n known.set(stem, name)\n known.set(stem.split(\'/\').pop(), name)\n }\n const out = []\n for (const [name, body] of Object.entries(files)) {\n if (!name.endsWith(\'.py\')) continue\n for (const line of body.split(/\\r?\\n/)) {\n const trimmed = line.trim()\n if (trimmed.startsWith(\'#\')) continue\n const from = /^from\\s+([A-Za-z_][\\w.]*)\\s+import\\b/.exec(trimmed)\n const plain = /^import\\s+(.+)$/.exec(trimmed)\n const names = []\n if (from) names.push(from[1])\n else if (plain) names.push(...plain[1].split(\',\').map((item) => item.trim().split(/\\s+as\\s+/)[0]))\n for (const imported of names) {\n const hit = known.get(imported) || known.get(imported.split(\'.\')[0])\n if (hit && hit !== name) out.push(`${name} import 了包内的 ${imported}(就是 ${hit})`)\n }\n }\n }\n return out\n}\n\n/**\n * 从开发目录顶层那份 `backend.lock.pyi` 里读"**你现在配了哪些键**"。\n *\n * 🔴 为什么要从清单里读,而不是自己去扫环境:块作者本地通常**没有** Django(那份清单是平台\n * 导出时算的)。清单里那一块就是"导出这一刻真的存在的 `BLOCK_` 键名",是这里唯一的真源。\n * 格式约定:说明那行是「你现在配了哪些键」,紧跟其后的一串 `# KEY` 就是键名。\n */\nfunction readConfigKeys(workspace) {\n const path = join(workspace, \'..\', \'backend.lock.pyi\')\n if (!existsSync(path)) return null\n const lines = readText(path).text.split(/\\r?\\n/)\n const out = []\n let inside = false\n for (const line of lines) {\n if (line.includes(\'你现在配了哪些键\')) { inside = true; continue }\n if (!inside) continue\n const hit = /^#\\s{3}([A-Za-z_][A-Za-z0-9_]*)\\s*$/.exec(line)\n if (hit) { out.push(hit[1]); continue }\n if (line.trim() && !line.startsWith(\'#\')) break\n if (line.startsWith(\'# (\')) break // "(一个都没有:…)"\n }\n return out\n}\n\n/** 在开发目录附近找 `.env`(`.env` / `.env.local` / `.env.dev`),返回找到的第一个的键名集合。 */\nfunction readEnvKeys(workspace) {\n const names = new Set()\n let dir = resolve(workspace)\n for (let depth = 0; depth < 4; depth += 1) {\n for (const file of [\'.env\', \'.env.local\', \'.env.dev\']) {\n const full = join(dir, file)\n if (!existsSync(full)) continue\n for (const raw of readText(full).text.split(/\\r?\\n/)) {\n const line = raw.trim()\n if (!line || line.startsWith(\'#\')) continue\n const at = line.indexOf(\'=\')\n if (at <= 0) continue\n names.add(line.slice(0, at).trim())\n }\n return { file: full, names }\n }\n const parent = resolve(dir, \'..\')\n if (parent === dir) break\n dir = parent\n }\n return null\n}\n\n/** 一份 `.py` 里用到的配置键:`ctx.config[\'KEY\']` / `ctx.config.get(\'KEY\')` / `"KEY" in ctx.config`。 */\nfunction configUsage(files) {\n const out = []\n const patterns = [\n /config\\[\\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)[\'"]\\s*\\]/g,\n /config\\.get\\(\\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)[\'"]/g,\n /[\'"]([A-Za-z_][A-Za-z0-9_]*)[\'"]\\s+in\\s+config/g,\n ]\n for (const [name, body] of Object.entries(files)) {\n if (!name.endsWith(\'.py\')) continue\n for (const pattern of patterns) {\n for (const match of body.matchAll(pattern)) {\n if (!out.some((item) => item.file === name && item.key === match[1])) {\n out.push({ file: name, key: match[1] })\n }\n }\n }\n }\n return out\n}\n\n/**\n * 查"块用到的配置键对不对"——\n * ① 代码里用到的键必须在清单里(清单空着就跳过,不然没配过的人会被满屏报错吓到);\n * ② 找到了 `.env` 就再对一遍:写了却没配的键是**必错项**(运行期取不到),配了却没人用的只提示。\n */\nfunction checkConfig(label, files, workspace) {\n const known = readConfigKeys(workspace)\n const env = readEnvKeys(workspace)\n const usage = configUsage(files)\n let bad = 0\n for (const { file, key } of usage) {\n if (Array.isArray(known) && known.length > 0 && !known.includes(key)) {\n bad += 1\n ng(`${label}:${file} 里用了 ctx.config[\'${key}\'],但它不在清单里 —— `\n + `导出那一刻环境里配的是:${known.join(\' / \')}(写错的键运行期只会取到 None,不报错)`)\n }\n if (env && !env.names.has(`BLOCK_${key}`)) {\n bad += 1\n ng(`${label}:${file} 里用了 ctx.config[\'${key}\'],但 ${basename(env.file)} 里没有 BLOCK_${key}(运行期取不到)`)\n }\n }\n if (!env) {\n note(`${label}:附近的 .env 没找到 ⇒ "这个键配了没有"那一半没查(代码用到的键仍与清单对过)`)\n } else {\n const used = new Set(usage.map((item) => item.key))\n const unused = [...env.names]\n .filter((name) => name.startsWith(\'BLOCK_\') && !used.has(name.slice(\'BLOCK_\'.length)))\n .map((name) => name.slice(\'BLOCK_\'.length))\n if (unused.length) note(`${label}:${basename(env.file)} 里这几个 BLOCK_ 键没有被用到:${unused.join(\' / \')}`)\n }\n if (usage.length && bad === 0) {\n ok(`${label}:代码里用到的配置键都对得上(${usage.map((item) => item.key).join(\' / \')})`)\n }\n}\n\nfunction checkBlock(dir) {\n const label = basename(dir)\n info(`\\n── ${label} ──`)\n const files = readBlock(dir)\n const names = Object.keys(files)\n\n if (!files[ENTRY]) ng(`${label}:缺入口 ${ENTRY}`)\n else ok(`${label}:有入口 ${ENTRY}`)\n\n if (files[MANIFEST] === undefined) {\n ng(`${label}:缺清单 ${MANIFEST}`)\n } else {\n const { found, issues } = parseManifest(files[MANIFEST])\n if (issues.length) ng(`${label}:清单有问题 —— ${issues.join(\';\')}`)\n else ok(`${label}:清单四个键齐(${found.module} / ${found.name})`)\n }\n\n for (const name of names) {\n if (!/^(?![A-Za-z]:)(?!\\/)[A-Za-z0-9._\\-/]+$/.test(name) || name.split(\'/\').includes(\'..\')) {\n ng(`${label}:文件名不合法 ${name}`)\n }\n }\n\n const { missing, referenced } = frontendRefs(files)\n for (const item of missing) ng(`${label}:${item}`)\n if (!missing.length && files[ENTRY]) ok(`${label}:前端的引用都指得到(入口里的 script/link 都在包里)`)\n\n // "包里的文件没人引用":只提示(入口文件与清单、后端本来就不该被引用)。\n const used = new Set([ENTRY, MANIFEST, BACKEND, ...[...referenced.values()].flat()])\n const orphans = names.filter((name) => !used.has(name) && !PLATFORM_FILES.has(name)\n && !name.endsWith(\'.py\') && name !== \'README.md\')\n if (orphans.length) note(`${label}:这几个文件没有任何引用(写进包里也没人用):${orphans.join(\' / \')}`)\n\n const cross = siblingImports(files)\n if (cross.length) {\n for (const item of cross) {\n ng(`${label}:${item} —— **块里文件之间不能互相引用**(平台把每个 .py 各自独立加载):`\n + \'要么把那段逻辑复制过去,要么放进项目里的公共模块再 import\')\n }\n } else if (names.some((name) => name.endsWith(\'.py\'))) {\n ok(`${label}:后端那些 .py 之间没有互相引用(${names.filter((n) => n.endsWith(\'.py\')).join(\' / \')})`)\n }\n\n checkConfig(label, files, workspace)\n return files\n}\n\n// ──────────────────────────── ② 平台那一层 ────────────────────────────\n\nfunction findPython() {\n if (flags.python) return flags.python\n const candidates = [process.env.LABULL_PYTHON, \'python\', \'python3\', \'py\'].filter(Boolean)\n const probe = \'import django, labull_framework; print("ok")\'\n for (const candidate of candidates) {\n const done = spawnSync(candidate, [\'-c\', probe], {\n encoding: \'utf-8\',\n env: { ...process.env, PYTHONIOENCODING: \'utf-8\' },\n })\n if (done.status === 0 && String(done.stdout).includes(\'ok\')) return candidate\n }\n return \'\'\n}\n\nfunction platformCheck(workspace, python, payload) {\n const script = [\n \'import json, os, sys\',\n \'from labull_framework.magic_block import validate\',\n \'payload = json.loads(os.environ["LABULL_SMOKE_PAYLOAD"])\',\n \'out = []\',\n \'for item in payload:\',\n \' issues = validate.check_block(item["files"])\',\n \' out.append({"name": item["name"], "issues": [list(pair) for pair in issues]})\',\n \'print("LABULL_SMOKE" + json.dumps(out, ensure_ascii=False))\',\n ].join(\'\\n\')\n const done = spawnSync(python, [\'-c\', script], {\n encoding: \'utf-8\',\n env: { ...process.env, PYTHONIOENCODING: \'utf-8\', LABULL_SMOKE_PAYLOAD: JSON.stringify(payload) },\n })\n const text = `${done.stdout || \'\'}${done.stderr || \'\'}`\n const at = text.indexOf(\'LABULL_SMOKE\')\n if (done.status !== 0 || at < 0) {\n note(`平台那一层没跑起来(退出码 ${done.status}):${text.trim().split(\'\\n\').slice(-1)[0] || \'没有输出\'}`)\n return\n }\n const results = JSON.parse(text.slice(at + \'LABULL_SMOKE\'.length))\n for (const item of results) {\n if (!item.issues.length) { ok(`${item.name}:过平台那一层判据(清单/结构/试加载/引用深检)`); continue }\n for (const [stage, message] of item.issues) ng(`${item.name}:[${stage}] ${message}`)\n }\n}\n\n// ──────────────────────────── 主流程 ────────────────────────────\n\nconst workspace = flags.workspace\n ? (findWorkspace(flags.workspace) || resolve(flags.workspace))\n : findWorkspace(process.cwd())\nif (!workspace) {\n console.log(\'找不到开发目录(里面应该有 workspace/ 那一层)。用法:node smoke.mjs [--workspace <开发目录>] [块…]\')\n process.exit(2)\n}\nlet blocks = listBlocks(workspace)\nif (flags.targets.length) {\n blocks = blocks.filter((dir) => flags.targets.some((target) => basename(dir).includes(target)))\n if (!blocks.length) { console.log(`按 ${flags.targets.join(\' / \')} 没找到块(在 ${workspace} 里)`); process.exit(2) }\n}\ninfo(`开发目录:${workspace}`)\ninfo(`要查的块:${blocks.length} 个${flags.targets.length ? `(按 ${flags.targets.join(\' / \')} 筛的)` : flags.all ? \'(全部)\' : \'\'}`)\n\nconst payload = []\nfor (const dir of blocks) {\n const files = checkBlock(dir)\n payload.push({ name: basename(dir), files })\n}\n\nconst python = findPython()\nif (!python) {\n note(\'没找到装了 Django 的 Python ⇒ **平台那一层没跑**(试加载 / 引用深检 / 路由那几条没验)。\'\n + \'设 LABULL_SMOKE_PYTHON=<项目解释器> 或 --python <python> 再跑一次。\')\n} else {\n info(`\\n── 平台判据(用 ${python})──`)\n platformCheck(workspace, python, payload)\n}\n\ninfo(`\\n合计:过 ${pass.length} 条、不过 ${fail.length} 条${skip.length ? `、跳过 ${skip.length} 条` : \'\'}`)\nif (fail.length) {\n info(\'不过的:\')\n for (const item of fail) info(` - ${item}`)\n process.exit(1)\n}\ninfo(\'都过了。\')\n'
335
335
 
336
336
 
337
337
  def example_files(parts: dict[str, str] | None = None) -> dict[str, str]:
@@ -368,10 +368,17 @@ def example_files(parts: dict[str, str] | None = None) -> dict[str, str]:
368
368
  return out
369
369
 
370
370
 
371
- def backend_lock_pyi(tables: list[dict[str, Any]] | None = None, items: tuple[EnvItem, ...] = ENV_ITEMS) -> str:
371
+ def backend_lock_pyi(
372
+ tables: list[dict[str, Any]] | None = None,
373
+ items: tuple[EnvItem, ...] = ENV_ITEMS,
374
+ config_keys: list[str] | None = None,
375
+ ) -> str:
372
376
  """`backend.lock.pyi` 的正文:后端能 import 的库 + `ctx` + 环境变量 + 项目里的模型。
373
377
 
374
378
  :param tables: `canvas.data.shape()['tables']`(模型形状);不给就只铺前面三段。
379
+ :param config_keys: 开发机上**真的配了**的块配置键(`dispatch.config_keys()`,已剥前缀)。
380
+ 不给就写成"一个都没有" —— 有这一份,作者才知道 `ctx.config` 里该写哪个键名,
381
+ 而不是靠猜(写错的键在运行期只会取到 `None`,不报错)。
375
382
  """
376
383
  lines: list[str] = [
377
384
  '"""生成物:每次导出都会重写,别手改(改了下次导出就没了)。',
@@ -429,6 +436,26 @@ def backend_lock_pyi(tables: list[dict[str, Any]] | None = None, items: tuple[En
429
436
  f'# ⚠️ `{sdk.BLOCK_CONFIG_PREFIX}` 那一族是块**唯一**读得到的配置:前缀剥掉当键进 `ctx.config`',
430
437
  '# (环境里 `' + sdk.BLOCK_CONFIG_PREFIX + 'OCR_KEY=a` ⇒ `ctx.config["OCR_KEY"] == "a"`);',
431
438
  '# 没配的键**不存在**(先判 `"OCR_KEY" in ctx.config`),别的变量一个都不给块。',
439
+ '#',
440
+ '# 🔴 **怎么写才不出错**(`ctx.config` 的键是**字符串**,写错了不会报错、只会在运行期取到 `None`):',
441
+ '# · 先看下面「你现在配了哪些键」那一块 —— 那是**导出这一刻**开发机上真的存在的键名;',
442
+ f'# · 提交前跑 `node {SMOKE}`:它会把你代码里 `ctx.config[\'…\']` 用到的键与这份清单、',
443
+ '# 以及开发目录旁边的 `.env` 对一遍 —— 写错的键、没配的键、配了却没用的键都会列出来;',
444
+ '# · 想加一个新键:在 `.env` 里写 `' + sdk.BLOCK_CONFIG_PREFIX + 'XXX=…` 再**重新导出一次**,',
445
+ '# 这份清单里就会多出 `XXX`(清单是导出那一刻算的,不是猜的)。',
446
+ ]
447
+ )
448
+ # 🔴 机器可读的"你现在能用哪些键":给 `smoke.lock.mjs` 解析(块开发者本地没有 Django,
449
+ # 那份脚本只能从这份清单里读"。格式是 `# KEY`,与上面那几条说明的缩进一致。
450
+ lines.append('#')
451
+ lines.append(f'# 你现在配了哪些键(`{sdk.BLOCK_CONFIG_PREFIX}` 剥掉前缀;导出这一刻的环境里存在的):')
452
+ if config_keys:
453
+ for key in config_keys:
454
+ lines.append(f'# {key}')
455
+ else:
456
+ lines.append(f'# (一个都没有:环境里没有 `{sdk.BLOCK_CONFIG_PREFIX}` 开头的变量)')
457
+ lines.extend(
458
+ [
432
459
  '',
433
460
  '# ── ⑤ 项目里的模型(照 .labull/contract.json 那份模型形状生成) ──',
434
461
  '# 枚举按**类属性**用(`Order.Status.OPEN`):成员名不是取值 —— `Order.Status.open` 不存在。',
@@ -589,6 +616,8 @@ def dev_guide() -> str:
589
616
  f'- 抛 `errors.{errors.replace(" / ", "` / `errors.")}` ⇒ 对应的 HTTP 状态码;别的异常一律 500。\n'
590
617
  f'- 配置面:环境变量里**只有带 `{prefix}` 前缀的**才进 `ctx.config`(`{prefix}OCR_KEY=a` ⇒ `ctx.config["OCR_KEY"] == "a"`);\n'
591
618
  ' 没配的键**不存在**(先判 `"OCR_KEY" in ctx.config`)。\n'
619
+ ' 🔴 **键名别猜**:`ctx.config[...]` 的键是字符串,写错了不报错、只在运行期取到 `None`。\n'
620
+ f' `{BACKEND_LOCK}` 的第 ④ 段列着"**你现在配了哪些键**",提交前跑 `node {SMOKE}` 会替你核对。\n'
592
621
  '- 服务端能 import 什么、项目里有哪些模型与字段、还有哪些环境变量:见 `backend.lock.pyi`;\n'
593
622
  f' 后端**装了哪些包**(块后端 import 之前得确认它在):见 `{REQUIREMENTS}`(项目 `requirements.txt` 的副本)。\n'
594
623
  '\n'
@@ -166,6 +166,20 @@ def block_config() -> dict[str, str]:
166
166
  return out
167
167
 
168
168
 
169
+ def config_keys() -> list[str]:
170
+ """开发机上现在**真的配了**哪些块配置键(`BLOCK_` 剥掉前缀,排序)。
171
+
172
+ 🔴 与 `block_config()` 同一份判据(前缀只有一处):这一份是**给清单用的**(`backend.lock.pyi`
173
+ 里列出"你现在能用哪些键"),不是运行期取值。
174
+ 🔴 只回**键名**,永不回值:清单会落到用户自己的目录里,而那一族里可能有密钥。
175
+ """
176
+ return sorted(
177
+ name[len(BLOCK_CONFIG_PREFIX):]
178
+ for name in os.environ
179
+ if name.startswith(BLOCK_CONFIG_PREFIX) and name[len(BLOCK_CONFIG_PREFIX):]
180
+ )
181
+
182
+
169
183
  def build_context(request: HttpRequest, block: Any) -> BlockCtx:
170
184
  """组装交给 handler 的 `ctx`(**4 个键,见 `sdk.CTX_KEYS`**)。
171
185
 
@@ -248,7 +248,7 @@ def package_files(rows: Iterable[Any], tables: list[dict[str, Any]] | None = Non
248
248
  files[f'{DEV_BLOCKS_DIR}/{folder}/api.open.d.ts'] = dts
249
249
  files[f'{DEV_BLOCKS_DIR}/{folder}/VERSION.lock'] = f'{LIB_SDK}\n'
250
250
  blocks += 1
251
- files[devkit.BACKEND_LOCK] = devkit.backend_lock_pyi(tables)
251
+ files[devkit.BACKEND_LOCK] = devkit.backend_lock_pyi(tables, config_keys=_config_keys())
252
252
  files[devkit.GUIDE] = devkit.dev_guide()
253
253
  files[devkit.REQUIREMENTS] = requirements_text()
254
254
  files[devkit.README] = devkit.dev_readme(blocks)
@@ -324,6 +324,17 @@ def scan_rules() -> dict:
324
324
  }
325
325
 
326
326
 
327
+ def _config_keys() -> list[str]:
328
+ """开发机上真的配了哪些块配置键(给 `backend.lock.pyi` 用)。
329
+
330
+ 🔴 与运行期取的是**同一份判据**(`dispatch.config_keys()`):两处各写一份前缀扫描,
331
+ 漂了就是"清单里没有、运行期却有"(作者会以为写错了)。
332
+ """
333
+ from .dispatch import config_keys
334
+
335
+ return config_keys()
336
+
337
+
327
338
  def export_package(blocks: Iterable[str] | None = None) -> dict:
328
339
  """`POST /magic-blocks/export` 的全部答案:开发包 + 读回时要照的规则。
329
340
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 0.1.6
3
+ Version: 0.1.7
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 = "0.1.6"
7
+ version = "0.1.7"
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"