From 4d8b99db7b958ec455fe17aee827497bde130c3a Mon Sep 17 00:00:00 2001 From: cruldra Date: Fri, 2 Oct 2026 00:09:53 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E6=8A=8A=20Pydantic=20=E7=9A=84=20BaseModel?= =?UTF-8?q?=20=E5=AD=90=E7=B1=BB=E7=94=BB=E6=88=90=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=8D=A1=E7=89=87=EF=BC=8C=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E6=91=8A=E6=88=90=E5=B1=9E=E6=80=A7=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/codeReader/fixtures/aigcMetadata.py | 29 ++ .../codeReader/fixtures/createShareRequest.py | 29 ++ vscode/src/codeReader/index.ts | 2 + vscode/src/codeReader/rules/children.ts | 24 ++ vscode/src/codeReader/rules/dataclass.ts | 17 +- vscode/src/codeReader/rules/pydantic.test.ts | 234 ++++++++++++ vscode/src/codeReader/rules/pydantic.ts | 336 ++++++++++++++++++ 7 files changed, 659 insertions(+), 12 deletions(-) create mode 100644 vscode/src/codeReader/fixtures/aigcMetadata.py create mode 100644 vscode/src/codeReader/fixtures/createShareRequest.py create mode 100644 vscode/src/codeReader/rules/pydantic.test.ts create mode 100644 vscode/src/codeReader/rules/pydantic.ts diff --git a/vscode/src/codeReader/fixtures/aigcMetadata.py b/vscode/src/codeReader/fixtures/aigcMetadata.py new file mode 100644 index 0000000..04059ce --- /dev/null +++ b/vscode/src/codeReader/fixtures/aigcMetadata.py @@ -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 diff --git a/vscode/src/codeReader/fixtures/createShareRequest.py b/vscode/src/codeReader/fixtures/createShareRequest.py new file mode 100644 index 0000000..4bda207 --- /dev/null +++ b/vscode/src/codeReader/fixtures/createShareRequest.py @@ -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 diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 914f31f..273d855 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -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), ) diff --git a/vscode/src/codeReader/rules/children.ts b/vscode/src/codeReader/rules/children.ts index 38e824c..0ca9de5 100644 --- a/vscode/src/codeReader/rules/children.ts +++ b/vscode/src/codeReader/rules/children.ts @@ -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) diff --git a/vscode/src/codeReader/rules/dataclass.ts b/vscode/src/codeReader/rules/dataclass.ts index ab5ccb4..4ec0557 100644 --- a/vscode/src/codeReader/rules/dataclass.ts +++ b/vscode/src/codeReader/rules/dataclass.ts @@ -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 } } diff --git a/vscode/src/codeReader/rules/pydantic.test.ts b/vscode/src/codeReader/rules/pydantic.test.ts new file mode 100644 index 0000000..5b53843 --- /dev/null +++ b/vscode/src/codeReader/rules/pydantic.test.ts @@ -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 { + 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 }) + }) +}) diff --git a/vscode/src/codeReader/rules/pydantic.ts b/vscode/src/codeReader/rules/pydantic.ts new file mode 100644 index 0000000..9812b84 --- /dev/null +++ b/vscode/src/codeReader/rules/pydantic.ts @@ -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 + /** `default_factory=f`:「每次新建 f()」。 */ + factory: string + /** `ConfigDict(名=True/False)` 的译法,按「名=True」「名=False」查;没有的照写。 */ + options: Record + /** `extra="forbid"` 这类字符串值。 */ + extra: Record + /** `Field(...)` 里常见约束的说法;没有的照写参数名和值。 */ + constraints: Record +} + +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 = { 'zh-cn': ZH } + +interface Env { + context: RuleContext + words: Words + /** pydantic 里的 BaseModel、Field、ConfigDict,含导入时的别名。 */ + names: LibraryNames + /** 注解里 ClassVar 导入时起的别名 → 原名。 */ + aliases: Map +} + +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 { + const models = new Set() + 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 + }, +}