✨ feat(vscode): 代码阅读把 Pydantic 的 BaseModel 子类画成数据模型卡片,字段摊成属性表
This commit is contained in:
1 parent
2a11d5a19f
commit
4d8b99db7b
7 files changed
+659
-12
No files matched your search
@@ -0,0 +1,29 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class AIGCMetadata(BaseModel):
|
||||
"""生成内容来源标识。"""
|
||||
|
||||
model_config = ConfigDict(strict=True, extra="forbid")
|
||||
|
||||
Label: str
|
||||
ContentProducer: str
|
||||
ProduceID: str
|
||||
ReservedCode1: str
|
||||
ContentPropagator: str
|
||||
PropagateID: str
|
||||
ReservedCode2: str
|
||||
|
||||
|
||||
class TextTrace(BaseModel):
|
||||
"""生成文本及其来源标识。"""
|
||||
|
||||
content_key: str
|
||||
text: str
|
||||
reasoning_content: str | None = None
|
||||
metadata: AIGCMetadataEnvelope
|
||||
model_id: str | None = None
|
||||
served_model: str | None = None
|
||||
generated_at: datetime | None = None
|
||||
@@ -0,0 +1,29 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from luffy_agent.share.enums import ShareTargetType
|
||||
|
||||
|
||||
class CreateShareRequest(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
target_type: ShareTargetType = ShareTargetType.SESSION_ARTIFACT
|
||||
session_id: str = Field(min_length=1, description="作品所属 session id")
|
||||
preview_url: str = Field(
|
||||
min_length=1,
|
||||
max_length=2048,
|
||||
description="作品当前 preview URL(前端从预览态取)",
|
||||
)
|
||||
title: str = Field(min_length=1, max_length=255, description="分享卡片标题")
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
thumbnail_url: str | None = Field(default=None, max_length=2048)
|
||||
|
||||
@field_validator("preview_url", "thumbnail_url")
|
||||
@classmethod
|
||||
def validate_http_scheme(cls, value: str | None) -> str | None:
|
||||
"""强制 http(s):// scheme,拒绝 javascript: 等可执行 URL 注入落地页 iframe。"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if not value.startswith(("http://", "https://")):
|
||||
raise ValueError("URL 必须以 http:// 或 https:// 开头")
|
||||
return value
|
||||
@@ -29,6 +29,7 @@ import { celeryRule } from './rules/celery'
|
||||
import { dataclassRule } from './rules/dataclass'
|
||||
import { enumRule } from './rules/enum'
|
||||
import { fastapiRule } from './rules/fastapi'
|
||||
import { pydanticRule } from './rules/pydantic'
|
||||
import { reactRule } from './rules/react'
|
||||
import { sqlalchemyRule } from './rules/sqlalchemy'
|
||||
import { bundledWasmDir, TreeSitterRuntime } from './runtime'
|
||||
@@ -143,6 +144,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
api.registerLibraryRule(celeryRule),
|
||||
api.registerLibraryRule(enumRule),
|
||||
api.registerLibraryRule(dataclassRule),
|
||||
api.registerLibraryRule(pydanticRule),
|
||||
api.registerLibraryRule(fastapiRule),
|
||||
api.registerLibraryRule(reactRule),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,30 @@ import { syntaxChildren } from './pythonTree'
|
||||
|
||||
/** 库规则共用的挑子块的办法。 */
|
||||
|
||||
/** 类主体里带注解的赋值(`x: int`、`x: int = 1`),左边是一个名字的,照源码顺序。 */
|
||||
export interface AnnotatedAssignment {
|
||||
block: CodeBlock
|
||||
name: Node
|
||||
annotation: Node
|
||||
value?: Node
|
||||
}
|
||||
|
||||
/** 类主体里带注解的赋值,照源码顺序。不是这种形状的字段块跳过。 */
|
||||
export function annotatedAssignments(children: readonly CodeBlock[]): AnnotatedAssignment[] {
|
||||
const found: AnnotatedAssignment[] = []
|
||||
for (const block of children) {
|
||||
if (block.kind !== 'field')
|
||||
continue
|
||||
const [assignment] = syntaxChildren(block.node)
|
||||
const target = assignment?.type === 'assignment' ? assignment.childForFieldName('left') : null
|
||||
const annotation = assignment?.childForFieldName('type')
|
||||
if (!assignment || target?.type !== 'identifier' || !annotation)
|
||||
continue
|
||||
found.push({ block, name: target, annotation, value: assignment.childForFieldName('right') ?? undefined })
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/** 紧挨在 child 前面的注释块(连着几块都算,中间没隔别的子块),照源码顺序:讲的是它下面的这个 child。 */
|
||||
export function leadingComments(children: readonly CodeBlock[], child: CodeBlock): CodeBlock[] {
|
||||
const at = children.indexOf(child)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Access, BlockConcept, ClassBlock, CodeBlock, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
|
||||
import type { Call, LibraryNames } from './pythonTree'
|
||||
import { memberDocs, withLeadingComments } from './children'
|
||||
import { annotatedAssignments, memberDocs, withLeadingComments } from './children'
|
||||
import { aliasesOf, callOf, canonicalName, genericOf, libraryName, libraryNames, stringValue, syntaxChildren, unwrapType } from './pythonTree'
|
||||
import { hint, joined, name, plain, range, word } from './segments'
|
||||
|
||||
@@ -132,23 +132,16 @@ function fieldsOf(children: readonly CodeBlock[], env: Env): { fields: Field[],
|
||||
const fields: Field[] = []
|
||||
const markers: CodeBlock[] = []
|
||||
let afterKwOnly = false
|
||||
for (const block of children) {
|
||||
if (block.kind !== 'field')
|
||||
continue
|
||||
const [assignment] = syntaxChildren(block.node)
|
||||
const target = assignment?.type === 'assignment' ? assignment.childForFieldName('left') : null
|
||||
const annotation = assignment?.childForFieldName('type')
|
||||
if (!assignment || target?.type !== 'identifier' || !annotation)
|
||||
continue
|
||||
const kind = annotationName(annotation, env)
|
||||
for (const assignment of annotatedAssignments(children)) {
|
||||
const kind = annotationName(assignment.annotation, env)
|
||||
if (kind === 'ClassVar')
|
||||
continue
|
||||
if (kind === 'KW_ONLY') {
|
||||
afterKwOnly = true
|
||||
markers.push(block)
|
||||
markers.push(assignment.block)
|
||||
continue
|
||||
}
|
||||
fields.push({ block, name: target, annotation, value: assignment.childForFieldName('right') ?? undefined, afterKwOnly })
|
||||
fields.push({ ...assignment, afterKwOnly })
|
||||
}
|
||||
return { fields, markers }
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { Access, ClassBlock, CodeBlock, ImportFacts, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
|
||||
import type { Call, LibraryNames } from './pythonTree'
|
||||
import { fromLibrary } from '../libraryRules'
|
||||
import { annotatedAssignments, memberDocs, withLeadingComments } from './children'
|
||||
import { aliasesOf, callOf, canonicalName, genericOf, libraryName, libraryNames, stringValue, syntaxChildren, unwrapType } from './pythonTree'
|
||||
import { hint, joined, name, plain, range, word } from './segments'
|
||||
|
||||
/**
|
||||
* Pydantic 的 `BaseModel` 子类整块换成「数据模型」卡片:名称 / 说明 / 访问级别 / 继承 / 选项 / 字段表,有才显示。
|
||||
* 父类链上有 pydantic 的 BaseModel(含别名、`pydantic.BaseModel`),或同一文件里继承另一个模型,都算。
|
||||
* 字段是类主体里带注解的赋值,表和数据类同一套列;`model_config = ConfigDict(...)` 是选项,不是字段。
|
||||
* 方法、`@field_validator`、`ClassVar`、没注解的类属性照常是子块。
|
||||
*/
|
||||
|
||||
const LIBRARY = 'pydantic'
|
||||
/** ClassVar 从这几个库导入时的别名要换回原名。 */
|
||||
const TYPE_LIBRARIES = ['typing', 'typing_extensions']
|
||||
const KIND = 'pydantic.model'
|
||||
const COLOR: ThemeColor = { dark: '#c678dd', light: '#8b3db5' }
|
||||
const ICON: KindIconName = 'braces'
|
||||
/** `Field(...)` 里讲默认值、说明的参数,不进标记。 */
|
||||
const FIELD_VALUES = new Set(['default', 'default_factory', 'description'])
|
||||
|
||||
/** 一种约束的说法:值前面的词、值后面的词,有才写。 */
|
||||
interface Phrase { before?: string, after?: string }
|
||||
|
||||
/** 一种显示语言的用词。 */
|
||||
interface Words {
|
||||
kind: string
|
||||
labels: Record<'name' | 'doc' | 'access' | 'bases' | 'options' | 'fields', string>
|
||||
heads: string[]
|
||||
count: (n: number) => string
|
||||
access: Record<Access, string>
|
||||
/** `default_factory=f`:「每次新建 f()」。 */
|
||||
factory: string
|
||||
/** `ConfigDict(名=True/False)` 的译法,按「名=True」「名=False」查;没有的照写。 */
|
||||
options: Record<string, string>
|
||||
/** `extra="forbid"` 这类字符串值。 */
|
||||
extra: Record<string, string>
|
||||
/** `Field(...)` 里常见约束的说法;没有的照写参数名和值。 */
|
||||
constraints: Record<string, Phrase>
|
||||
}
|
||||
|
||||
const ZH: Words = {
|
||||
kind: '数据模型',
|
||||
labels: { name: '名称', doc: '说明', access: '访问级别', bases: '继承', options: '选项', fields: '字段' },
|
||||
heads: ['字段', '类型', '默认值', '说明'],
|
||||
count: n => `${n} 个`,
|
||||
access: { public: '公共', private: '私有', protected: '受保护' },
|
||||
factory: '每次新建',
|
||||
options: {
|
||||
'strict=True': '严格',
|
||||
'strict=False': '不严格',
|
||||
'frozen=True': '不可修改',
|
||||
'frozen=False': '可修改',
|
||||
'from_attributes=True': '可从对象读取',
|
||||
'from_attributes=False': '不从对象读取',
|
||||
'populate_by_name=True': '可用字段名填',
|
||||
'validate_assignment=True': '赋值时也校验',
|
||||
'arbitrary_types_allowed=True': '允许任意类型',
|
||||
'use_enum_values=True': '用枚举的值',
|
||||
'str_strip_whitespace=True': '去掉首尾空白',
|
||||
'validate_default=True': '也校验默认值',
|
||||
},
|
||||
extra: { forbid: '禁止多余字段', allow: '允许多余字段', ignore: '忽略多余字段' },
|
||||
constraints: {
|
||||
min_length: { before: '至少', after: ' 个字符' },
|
||||
max_length: { before: '最长' },
|
||||
ge: { before: '≥' },
|
||||
le: { before: '≤' },
|
||||
gt: { before: '>' },
|
||||
lt: { before: '<' },
|
||||
multiple_of: { after: ' 的倍数' },
|
||||
pattern: { before: '匹配' },
|
||||
min_items: { before: '至少', after: ' 项' },
|
||||
max_items: { before: '最多', after: ' 项' },
|
||||
max_digits: { before: '最多', after: ' 位数字' },
|
||||
decimal_places: { before: '小数', after: ' 位' },
|
||||
},
|
||||
}
|
||||
|
||||
const WORDS: Record<string, Words> = { 'zh-cn': ZH }
|
||||
|
||||
interface Env {
|
||||
context: RuleContext
|
||||
words: Words
|
||||
/** pydantic 里的 BaseModel、Field、ConfigDict,含导入时的别名。 */
|
||||
names: LibraryNames
|
||||
/** 注解里 ClassVar 导入时起的别名 → 原名。 */
|
||||
aliases: Map<string, string>
|
||||
}
|
||||
|
||||
interface Field {
|
||||
block: CodeBlock
|
||||
name: Node
|
||||
annotation: Node
|
||||
value?: Node
|
||||
}
|
||||
|
||||
/** pydantic 以及它的子模块(`pydantic.main`)里导入的名字。 */
|
||||
function pydanticNames(imports: readonly ImportFacts[]): LibraryNames {
|
||||
const names = libraryNames(imports, LIBRARY)
|
||||
for (const facts of imports) {
|
||||
if (!facts.sources.some(source => fromLibrary(source.text, LIBRARY) && source.text !== LIBRARY))
|
||||
continue
|
||||
for (const item of facts.items) {
|
||||
if (item.kind === 'named')
|
||||
names.imported.set(item.alias?.text ?? item.name.text, item.name.text)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** 模块顶层的类:名字和父类。装饰器包着的类也算。 */
|
||||
function moduleClasses(root: Node): { name: string, bases: Node[] }[] {
|
||||
const classes: { name: string, bases: Node[] }[] = []
|
||||
for (const statement of syntaxChildren(root)) {
|
||||
const definition = statement.type === 'decorated_definition' ? statement.childForFieldName('definition') : statement
|
||||
if (definition?.type !== 'class_definition')
|
||||
continue
|
||||
const className = definition.childForFieldName('name')
|
||||
if (className)
|
||||
classes.push({ name: className.text, bases: syntaxChildren(definition.childForFieldName('superclasses')) })
|
||||
}
|
||||
return classes
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一文件里能确定是模型的类名:父类是 pydantic 的 BaseModel,或是本文件里另一个模型。
|
||||
* 父类是从别的模块导入的名字时这里不算,那要等工作区索引。
|
||||
*/
|
||||
function localModels(classes: readonly { name: string, bases: Node[] }[], names: LibraryNames): Set<string> {
|
||||
const models = new Set<string>()
|
||||
let grew = true
|
||||
while (grew) {
|
||||
grew = false
|
||||
for (const cls of classes) {
|
||||
if (models.has(cls.name))
|
||||
continue
|
||||
const hit = cls.bases.some(base => libraryName(base, names) === 'BaseModel' || (base.type === 'identifier' && models.has(base.text)))
|
||||
if (hit) {
|
||||
models.add(cls.name)
|
||||
grew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
function isModel(block: ClassBlock, env: Env): boolean {
|
||||
const className = block.facts.name?.text
|
||||
if (!className)
|
||||
return false
|
||||
return localModels(moduleClasses(block.node.tree.rootNode), env.names).has(className)
|
||||
}
|
||||
|
||||
/** 注解最外层的名字(`ClassVar[int]` → ClassVar),导入时的别名换回原名。 */
|
||||
function annotationName(annotation: Node, env: Env): string | undefined {
|
||||
return canonicalName(genericOf(annotation)?.head ?? unwrapType(annotation), env.aliases)
|
||||
}
|
||||
|
||||
/** 调用的是 pydantic 里的这个名字(`Field(...)`、`ConfigDict(...)`,含别名)时给出实参。 */
|
||||
function namedCall(value: Node | null | undefined, expected: string, names: LibraryNames): Call | undefined {
|
||||
const callee = value?.type === 'call' ? value.childForFieldName('function') : null
|
||||
return libraryName(callee, names) === expected ? callOf(value, new Map()) : undefined
|
||||
}
|
||||
|
||||
function isEllipsis(node: Node): boolean {
|
||||
return node.type === 'ellipsis'
|
||||
}
|
||||
|
||||
/** 类主体里的字段:带注解的赋值,去掉 ClassVar 和 `model_config`。 */
|
||||
function fieldsOf(children: readonly CodeBlock[], env: Env): Field[] {
|
||||
return annotatedAssignments(children).filter(field => field.name.text !== 'model_config' && annotationName(field.annotation, env) !== 'ClassVar')
|
||||
}
|
||||
|
||||
/** `model_config = ...`:没注解的类属性,或写成带注解的赋值。不是字段。 */
|
||||
function configOf(children: readonly CodeBlock[]): { block: CodeBlock, value?: Node } | undefined {
|
||||
for (const block of children) {
|
||||
if (block.kind === 'variable' || block.kind === 'const') {
|
||||
const [declarator, ...rest] = block.facts.declarators
|
||||
if (rest.length === 0 && declarator?.name.type === 'identifier' && declarator.name.text === 'model_config')
|
||||
return { block, value: declarator.value }
|
||||
}
|
||||
const [field] = block.kind === 'field' ? annotatedAssignments([block]) : []
|
||||
if (field?.name.text === 'model_config')
|
||||
return { block, value: field.value }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** `ConfigDict(...)` 里写了的项;不是这个调用就整段照译。没写的不出现。 */
|
||||
function optionsText(value: Node | undefined, env: Env): Inline[] {
|
||||
const call = namedCall(value, 'ConfigDict', env.names)
|
||||
if (!call)
|
||||
return value ? env.context.translate(value) : []
|
||||
const parts = [...call.keywords].map(([key, arg]) => optionMark(key, arg, env))
|
||||
return joined([
|
||||
...parts,
|
||||
...call.positional.map(arg => env.context.translate(arg)),
|
||||
...call.splats.map(arg => [plain('**'), ...env.context.translate(arg)]),
|
||||
], hint(' · '))
|
||||
}
|
||||
|
||||
function optionMark(key: string, value: Node, env: Env): Inline[] {
|
||||
const flag = value.type === 'true' ? 'True' : value.type === 'false' ? 'False' : undefined
|
||||
const known = flag ? env.words.options[`${key}=${flag}`] : undefined
|
||||
if (known)
|
||||
return [word(known)]
|
||||
if (key === 'extra') {
|
||||
const content = stringValue(value)
|
||||
const text = content ? env.words.extra[content] : undefined
|
||||
if (text)
|
||||
return [word(text)]
|
||||
}
|
||||
return [plain(`${key}=`), ...env.context.translate(value)]
|
||||
}
|
||||
|
||||
/** `Field(...)` 的默认值:关键字 `default=` 优先,否则第一个位置参数;`...` 表示必填,不算默认值。 */
|
||||
function defaultNode(call: Call): Node | undefined {
|
||||
const keyword = call.keywords.get('default')
|
||||
if (keyword)
|
||||
return isEllipsis(keyword) ? undefined : keyword
|
||||
const [positional] = call.positional
|
||||
return positional && !isEllipsis(positional) ? positional : undefined
|
||||
}
|
||||
|
||||
/** `default_factory=f`:「每次新建 f()」;没参数的 lambda 直接写它的结果。 */
|
||||
function factoryText(factory: Node, env: Env): Inline[] {
|
||||
const body = factory.type === 'lambda' && !factory.childForFieldName('parameters') ? factory.childForFieldName('body') : null
|
||||
return [word(env.words.factory), plain(' '), ...(body ? env.context.translate(body) : [...env.context.translate(factory), plain('()')])]
|
||||
}
|
||||
|
||||
function constraintMark(key: string, value: Node, env: Env): Inline[] {
|
||||
const phrase = env.words.constraints[key]
|
||||
if (!phrase)
|
||||
return [plain(`${key}=`), ...env.context.translate(value)]
|
||||
return [
|
||||
...(phrase.before ? [word(phrase.before), plain(' ')] : []),
|
||||
...env.context.translate(value),
|
||||
...(phrase.after ? [plain(phrase.after)] : []),
|
||||
]
|
||||
}
|
||||
|
||||
/** 默认值那一格:字面量、`Field(default=...)`、`每次新建 f()`,后面接约束标记。 */
|
||||
function defaultCell(field: Field, call: Call | undefined, env: Env): Inline[] {
|
||||
const parts: Inline[][] = []
|
||||
if (!call && field.value)
|
||||
parts.push(env.context.translate(field.value))
|
||||
const value = call && defaultNode(call)
|
||||
if (value)
|
||||
parts.push(env.context.translate(value))
|
||||
const factory = call?.keywords.get('default_factory')
|
||||
if (factory)
|
||||
parts.push(factoryText(factory, env))
|
||||
for (const [key, arg] of call?.keywords ?? []) {
|
||||
if (!FIELD_VALUES.has(key))
|
||||
parts.push(constraintMark(key, arg, env))
|
||||
}
|
||||
return joined(parts, hint(' · '))
|
||||
}
|
||||
|
||||
/** 说明:`Field(description=...)` 优先;没有就用字段上方注释、行尾注释、属性 docstring。 */
|
||||
function docCell(field: Field, children: readonly CodeBlock[], call: Call | undefined, env: Env): Inline[] {
|
||||
const description = call?.keywords.get('description')
|
||||
const literal = stringValue(description)
|
||||
if (literal)
|
||||
return [{ text: literal, role: 'comment' }]
|
||||
if (description)
|
||||
return env.context.translate(description)
|
||||
const text = memberDocs(children, field.block).map(node => env.context.doc(node)?.markdown).filter(Boolean).join(' ')
|
||||
return text ? [{ text, role: 'comment' }] : []
|
||||
}
|
||||
|
||||
function fieldItem(field: Field, children: readonly CodeBlock[], env: Env): PropertyItem {
|
||||
const call = namedCall(field.value, 'Field', env.names)
|
||||
return {
|
||||
cells: [
|
||||
[name(field.name, 'variable')],
|
||||
env.context.translate(field.annotation),
|
||||
defaultCell(field, call, env),
|
||||
docCell(field, children, call, env),
|
||||
],
|
||||
defines: range(field.name),
|
||||
}
|
||||
}
|
||||
|
||||
function modelCard(block: ClassBlock, env: Env): RuleCard {
|
||||
const { words, context } = env
|
||||
const { labels } = words
|
||||
const { facts, children } = block
|
||||
const config = configOf(children)
|
||||
const fields = fieldsOf(children, env)
|
||||
const doc = facts.doc && context.doc(facts.doc)
|
||||
const options = optionsText(config?.value, env)
|
||||
const rows: PropertyRow[] = [
|
||||
...(facts.name ? [{ label: labels.name, value: [name(facts.name, 'type')] }] : []),
|
||||
...(doc ? [{ label: labels.doc, value: doc }] : []),
|
||||
...(facts.access ? [{ label: labels.access, value: [{ text: words.access[facts.access], role: 'keyword' as const }] }] : []),
|
||||
...(facts.bases.length > 0 ? [{ label: labels.bases, value: joined(facts.bases.map(base => context.translate(base.value)), plain(', ')) }] : []),
|
||||
...(options.length > 0 ? [{ label: labels.options, value: options }] : []),
|
||||
...(fields.length > 0
|
||||
? [{
|
||||
label: labels.fields,
|
||||
value: [hint(words.count(fields.length))],
|
||||
columns: words.heads,
|
||||
items: fields.map(field => fieldItem(field, children, env)),
|
||||
}]
|
||||
: []),
|
||||
]
|
||||
return {
|
||||
kind: KIND,
|
||||
badge: { label: words.kind, color: COLOR, icon: ICON },
|
||||
rows,
|
||||
...(facts.name && { defines: range(facts.name) }),
|
||||
absorbs: withLeadingComments(children, [...fields.map(field => field.block), ...(config ? [config.block] : [])]),
|
||||
}
|
||||
}
|
||||
|
||||
export const pydanticRule: LibraryRule = {
|
||||
id: LIBRARY,
|
||||
label: 'Pydantic 数据模型',
|
||||
languageIds: ['python'],
|
||||
libraries: [LIBRARY],
|
||||
claim: (block, context) => {
|
||||
if (block.kind !== 'class')
|
||||
return undefined
|
||||
const env: Env = {
|
||||
context,
|
||||
words: WORDS[context.displayLanguage ?? ''] ?? ZH,
|
||||
names: pydanticNames(context.imports),
|
||||
aliases: aliasesOf(context.imports, TYPE_LIBRARIES),
|
||||
}
|
||||
return isModel(block, env) ? modelCard(block, env) : undefined
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user