✨ feat(vscode): 代码阅读把 Pydantic 的 BaseModel 子类画成数据模型卡片,字段摊成属性表

This commit is contained in:
cruldra committed 2026-10-03 03:11:47 +08:00
1 parent 2a11d5a19f
commit 4d8b99db7b
7 files changed
+659 -12

No files matched your search

@@ -0,0 +1,234 @@
import type { BlockView } from '../blockView'
import type { PropertyRow, Segment } from '../contract'
import { describe, expect, it } from 'vitest'
import { fileAttributes } from '../attributes'
import { toBlockViews } from '../blockView'
import { diffBlockTrees } from '../diff/blockDiff'
import { createPythonAdapter } from '../languages/python'
import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { celeryRule } from './celery'
import { dataclassRule } from './dataclass'
import { enumRule } from './enum'
import { fastapiRule } from './fastapi'
import { pydanticRule } from './pydantic'
import { sqlalchemyRule } from './sqlalchemy'
const py = createPythonAdapter(WASM_DIR)
const AIGC = readFixture('aigcMetadata.py')
const SHARE = readFixture('createShareRequest.py')
/** 和扩展里注册的顺序一样。 */
const RULES = [sqlalchemyRule, celeryRule, enumRule, dataclassRule, pydanticRule, fastapiRule]
async function views(source: string, reports: string[] = []): Promise<BlockView[]> {
return withBlocks(py, 'python', source, blocks =>
toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: RULES, report: message => reports.push(message) }))
}
function properties(view: BlockView | undefined): PropertyRow[] {
if (!view || !('properties' in view))
throw new Error(`块 ${view?.id} 不是属性表`)
return view.properties
}
function named(list: BlockView[], name: string): BlockView {
const found = list.find(view => 'properties' in view && propertyLines(view.properties)[0] === `名称: ${name}`)
if (!found)
throw new Error(`没有 ${name}`)
return found
}
/** 属性表里所有带下标的词(连表格里的格子)。 */
function linkedSegments(rows: PropertyRow[]): Segment[] {
const pieces = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
])
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
}
describe('pydantic 库规则(Python)', () => {
it('luffy 的 AIGCMetadata:数据模型卡片,7 个无默认值字段,选项是严格、禁止多余字段;model_config 不单成一块', async () => {
const reports: string[] = []
const list = await views(AIGC, reports)
expect(reports).toEqual([])
const metadata = named(list, 'AIGCMetadata')
expect(metadata.kind).toBe('pydantic.model')
expect(metadata.badge).toEqual({ label: '数据模型', color: { dark: '#c678dd', light: '#8b3db5' }, icon: 'braces' })
expect(propertyLines(properties(metadata))).toEqual([
'名称: AIGCMetadata',
'说明: 生成内容来源标识。',
'访问级别: 公共',
'继承: BaseModel',
'选项: 严格 · 禁止多余字段',
'字段: 7 个',
' Label | 字符串 | | ',
' ContentProducer | 字符串 | | ',
' ProduceID | 字符串 | | ',
' ReservedCode1 | 字符串 | | ',
' ContentPropagator | 字符串 | | ',
' PropagateID | 字符串 | | ',
' ReservedCode2 | 字符串 | | ',
])
expect(properties(metadata).at(-1)?.columns).toEqual(['字段', '类型', '默认值', '说明'])
expect(metadata.children).toEqual([])
})
it('luffy 的 TextTrace:可空字段的默认值是空', async () => {
const trace = named(await views(AIGC), 'TextTrace')
expect(propertyLines(properties(trace))).toEqual([
'名称: TextTrace',
'说明: 生成文本及其来源标识。',
'访问级别: 公共',
'继承: BaseModel',
'字段: 7 个',
' content_key | 字符串 | | ',
' text | 字符串 | | ',
' reasoning_content | 字符串 | 空 | 空 | ',
' metadata | AIGCMetadataEnvelope | | ',
' model_id | 字符串 | 空 | 空 | ',
' served_model | 字符串 | 空 | 空 | ',
' generated_at | datetime | 空 | 空 | ',
])
})
it('luffy 的 CreateShareRequest:Field 的说明和长度约束写进行,校验方法仍是子块', async () => {
const request = named(await views(SHARE), 'CreateShareRequest')
expect(propertyLines(properties(request))).toEqual([
'名称: CreateShareRequest',
'访问级别: 公共',
'继承: BaseModel',
'选项: 可从对象读取',
'字段: 6 个',
' target_type | ShareTargetType | ShareTargetType.SESSION_ARTIFACT | ',
' session_id | 字符串 | 至少 1 个字符 | 作品所属 session id',
' preview_url | 字符串 | 至少 1 个字符 · 最长 2048 | 作品当前 preview URL(前端从预览态取)',
' title | 字符串 | 至少 1 个字符 · 最长 255 | 分享卡片标题',
' description | 字符串 | 空 | 空 · 最长 500 | ',
' thumbnail_url | 字符串 | 空 | 空 · 最长 2048 | ',
])
const lines = SHARE.split('\n')
expect(request.children.map(child => [child.kind, lines[child.startLine].trim()])).toEqual([
['function', '@field_validator("preview_url", "thumbnail_url")'],
])
expect(request.code).toContain('def validate_http_scheme')
})
it('同一文件里继承模型的也是模型;TypedDict、NamedTuple、普通类、没导入 pydantic 的不是', async () => {
const withChild = `${AIGC}\n\nclass Child(AIGCMetadata):\n extra: int = 1\n`
const child = named(await views(withChild), 'Child')
expect(child.kind).toBe('pydantic.model')
expect(propertyLines(properties(child)).slice(0, 4)).toEqual([
'名称: Child',
'访问级别: 公共',
'继承: AIGCMetadata',
'字段: 1 个',
])
expect(propertyLines(properties(child)).at(-1)).toBe(' extra | 整数 | 1 | ')
const others = [
'from typing import NamedTuple, TypedDict',
'from pydantic import BaseModel',
'',
'class ViaDict(TypedDict):',
' x: str',
'',
'class ViaTuple(NamedTuple):',
' x: str',
'',
'class Plain:',
' x: str',
'',
'class Model(BaseModel):',
' x: str',
'',
].join('\n')
expect((await views(others)).map(view => view.kind)).toEqual(['import', 'import', 'class', 'class', 'class', 'pydantic.model'])
expect((await views('class Model(BaseModel):\n x: str\n')).map(view => view.kind)).toEqual(['class'])
})
it('别名、Field 的默认值和约束、说明优先于注释;ClassVar 和没注解的类属性留下', async () => {
const source = [
'from pydantic import BaseModel as Model, Field',
'from typing import ClassVar',
'',
'class Limits(Model):',
' """界限。"""',
' registry: ClassVar[dict] = {}',
' # 个数',
' n: int = Field(default=0, ge=0, le=10)',
' # 忽略这行',
' name: str = Field(min_length=1, max_length=255, description="名字")',
' tags: list[str] = Field(default_factory=list)',
' step: int = Field(default=3, multiple_of=3, pattern="[0-9]+", unknown=1)',
' label = "a"',
'',
' def ping(self) -> None:',
' return None',
'',
].join('\n')
const limits = named(await views(source), 'Limits')
expect(propertyLines(properties(limits))).toEqual([
'名称: Limits',
'说明: 界限。',
'访问级别: 公共',
'继承: Model',
'字段: 4 个',
' n | 整数 | 0 · ≥ 0 · ≤ 10 | 个数',
' name | 字符串 | 至少 1 个字符 · 最长 255 | 名字',
' tags | 列表[字符串] | 每次新建 list() | ',
' step | 整数 | 3 · 3 的倍数 · 匹配 "[0-9]+" · unknown=1 | ',
])
const lines = source.split('\n')
expect(limits.children.map(child => lines[child.startLine].trim())).toEqual([
'registry: ClassVar[dict] = {}',
'label = "a"',
'def ping(self) -> None:',
])
})
it('筛选:种类是 pydantic.model、叫「数据模型」;类型里的名字带着源码下标', async () => {
const list = await views(AIGC)
const metadata = named(list, 'AIGCMetadata')
expect(metadata.attributes).toEqual({ kind: 'pydantic.model', access: 'public' })
expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values)
.toContainEqual({ id: 'pydantic.model', name: '数据模型', count: 2 })
const linked = linkedSegments(properties(metadata))
expect(linked.map(segment => segment.text)).toEqual(expect.arrayContaining(['AIGCMetadata', 'BaseModel', 'Label']))
for (const segment of linked)
expect(AIGC.slice(segment.offset, (segment.offset ?? 0) + segment.text.length), segment.text).toBe(segment.text)
const trace = named(list, 'TextTrace')
const typeNames = linkedSegments(properties(trace)).filter(segment => segment.text === 'AIGCMetadataEnvelope')
expect(typeNames.map(segment => AIGC.slice(segment.offset, (segment.offset ?? 0) + segment.text.length))).toEqual(['AIGCMetadataEnvelope'])
const share = named(await views(SHARE), 'CreateShareRequest')
expect(linkedSegments(properties(share)).map(segment => segment.text)).toEqual(expect.arrayContaining(['ShareTargetType']))
})
it('给模型加一个字段、改一个字段的默认值:字段行标新增,默认值那一格标改过', async () => {
const after = AIGC
.replace(' ReservedCode2: str\n', ' ReservedCode2: str\n Extra: str\n')
.replace(' reasoning_content: str | None = None\n', ' reasoning_content: str | None = ""\n')
expect(after).not.toBe(AIGC)
const before = await views(AIGC)
const now = await views(after)
const diff = diffBlockTrees({ blocks: before, source: AIGC }, { blocks: now, source: after })
const metadata = named(now, 'AIGCMetadata')
const trace = named(now, 'TextTrace')
const metadataChange = diff.blocks[metadata.anchor]
const traceChange = diff.blocks[trace.anchor]
expect(metadataChange).toMatchObject({ change: 'mod', own: true })
expect(traceChange).toMatchObject({ change: 'mod', own: true })
const fieldsIndex = properties(metadata).findIndex(row => row.label === '字段')
const added = metadataChange.marks?.rows?.changed[fieldsIndex].items?.changed
expect(added?.[7]).toEqual({ added: true })
expect(Object.keys(added ?? {}).filter(index => added?.[Number(index)].added)).toEqual(['7'])
const traceFields = properties(trace).findIndex(row => row.label === '字段')
const cell = traceChange.marks?.rows?.changed[traceFields].items?.changed[2].cells?.[2]
expect(cell?.map(part => part.change)).toEqual(['del', 'add'])
expect(cell?.[0].segments.map(segment => segment.text).join('')).toBe('空')
expect(cell?.[1].segments.map(segment => segment.text).join('')).toBe('""')
expect(diff.stats).toEqual({ add: 0, mod: 2, del: 0 })
})
})