Files
superwork/vscode/src/codeReader/rules/pydantic.test.ts
T

270 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { BlockView } from '../blockView'
import type { IndexEntry, PropertyRow, RuleContext, 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 { MODEL_INDEX, 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[] = [], lookup?: RuleContext['lookup']): Promise<BlockView[]> {
return withBlocks(py, 'python', source, blocks =>
toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: RULES, report: message => reports.push(message), lookup }))
}
async function indexEntries(source: string): Promise<IndexEntry[]> {
return withBlocks(py, 'python', source, blocks => MODEL_INDEX.entries(blocks[0].node.tree.rootNode, { path: 'sample.py', source, exists: () => false }))
}
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 () => {
expect(MODEL_INDEX.mentions(AIGC)).toBe(true)
expect(MODEL_INDEX.mentions('from luffy_agent.model.aigc import AIGCMetadata\n\nclass Child(AIGCMetadata):\n x: int\n')).toBe(false)
const entries = await indexEntries(`${AIGC}\n\nclass Plain:\n x: str\n`)
expect(entries.map(entry => [entry.key, entry.value])).toEqual([['AIGCMetadata', 'model'], ['TextTrace', 'model']])
const alias = await indexEntries('from pydantic import BaseModel as Model\n\nclass Alias(Model):\n x: int\n')
expect(alias.map(entry => entry.key)).toEqual(['Alias'])
const child = [
'from luffy_agent.model.aigc import AIGCMetadata',
'',
'class Child(AIGCMetadata):',
' extra: int = 1',
'',
'class Other(NotAModel):',
' x: int',
'',
].join('\n')
const pending = await views(child)
expect(pending.map(view => view.kind)).toEqual(['import', 'class', 'class'])
const model = (index: { id: string }, node: { text: string }): boolean => index.id === MODEL_INDEX.id && node.text === 'AIGCMetadata'
const found = await views(child, [], (index, node) => model(index, node)
? { key: node.text, name: { startIndex: node.startIndex, endIndex: node.endIndex }, value: 'model' }
: undefined)
const card = named(found, 'Child')
expect(card.kind).toBe('pydantic.model')
expect(propertyLines(properties(card)).slice(-2)).toEqual(['字段: 1 个', ' extra | 整数 | 1 | '])
expect(named(found, 'Other').kind).toBe('class')
})
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 })
})
})