refactor(tools): type the py-types render frame and tighten the note

Follow-ups from the bot's review of the trusted-after-validation revert:
- renderType's Frame now carries JsonSchemaNode (the root schema is asserted
  before any frame is built), dropping the `as Record<string, unknown>` casts,
  the `node.oneOf as unknown[]` cast, and the runtime `required` filter — the
  same typed-frame shape as the sibling ts-types renderer, so the "symmetric
  with ts-types" claim holds structurally, not just behaviorally.
- The language-dispatch note broadens the trusted-input argument to cover all
  real sources (first-party defineTool/raw registration and wire-derived plain
  JSON), and the zh side uses full-width punctuation per translation-rules.md.
py-types.ts stays at 100% per-file coverage.
This commit is contained in:
Chinesezjc
2026-08-02 16:42:32 +08:00
parent f61b138e08
commit cabeaed1eb
4 changed files with 28 additions and 24 deletions

View File

@@ -14,7 +14,7 @@
*/
import { assertSupportedJsonSchema } from './json-schema.ts'
import type { JsonSchemaScalar } from './json-schema.ts'
import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts'
import type { ToolSdkSchema } from './ts-types.ts'
/** Property names that are valid bare Python identifiers; anything else is subscripted. */
@@ -183,14 +183,14 @@ function pyScalar(value: JsonSchemaScalar): string {
* the stub is advisory prompt text, only required to parse — and keeping the
* exact value communicates the constraint to the model.
*/
function renderConstrainedScalar(node: Record<string, unknown>, broad: string, state: RenderState): string {
if (Object.hasOwn(node, 'const')) {
function renderConstrainedScalar(node: JsonSchemaNode, broad: string, state: RenderState): string {
if (node.const !== undefined) {
state.typing.add('Literal')
return `Literal[${pyScalar(node.const as JsonSchemaScalar)}]`
return `Literal[${pyScalar(node.const)}]`
}
if (Object.hasOwn(node, 'enum')) {
if (node.enum !== undefined) {
state.typing.add('Literal')
return `Literal[${(node.enum as JsonSchemaScalar[]).map(pyScalar).join(', ')}]`
return `Literal[${node.enum.map(pyScalar).join(', ')}]`
}
return broad
}
@@ -208,18 +208,22 @@ function renderConstrainedScalar(node: Record<string, unknown>, broad: string, s
*/
function renderType(schema: unknown, className: string, state: RenderState): string {
interface Frame {
schema: unknown
// A validated JSON-schema node past the root `assertSupportedJsonSchema`
// (the root frame's schema is asserted before any frame is built), so the
// walk reads its fields without casts — the same typed-frame shape as the
// sibling ts-types renderer.
schema: JsonSchemaNode
className: string
phase: 'start' | 'children'
kind?: 'oneOf' | 'array' | 'typeddict'
node?: Record<string, unknown>
children: { schema: unknown; className: string }[]
node?: JsonSchemaNode
children: { schema: JsonSchemaNode; className: string }[]
childIndex: number
childTypes: string[]
entries: [string, unknown][]
entries: [string, JsonSchemaNode][]
allocated?: string
}
const newFrame = (schema: unknown, className: string): Frame =>
const newFrame = (schema: JsonSchemaNode, className: string): Frame =>
({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [] })
try {
// Validate the WHOLE tree once, then trust it — the same contract the
@@ -272,7 +276,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str
const name = frame.allocated
/* v8 ignore next -- typeddict frames always set node and allocated at start. */
if (node === undefined || name === undefined) throw new Error('missing typeddict frame state')
const required = new Set(Array.isArray(node.required) ? node.required.filter((n): n is string => typeof n === 'string') : [])
const required = new Set(node.required)
const lines = [`class ${name}(TypedDict):`]
for (let index = 0; index < frame.entries.length; index++) {
const entry = frame.entries[index]
@@ -281,8 +285,8 @@ function renderType(schema: unknown, className: string, state: RenderState): str
if (entry === undefined || fieldType === undefined) throw new Error('missing typeddict field type')
const [field, fieldSchema] = entry
// The parent node passed assertSupportedJsonSchema, so every property
// value is a validated schema node (an object).
const description = describe(fieldSchema as object)
// value is a validated schema node.
const description = describe(fieldSchema)
if (description !== undefined) lines.push(`${pad(1)}# ${description}`)
if (required.has(field)) {
lines.push(`${pad(1)}${field}: ${fieldType}`)
@@ -307,13 +311,13 @@ function renderType(schema: unknown, className: string, state: RenderState): str
}
frame.phase = 'children'
const node = frame.schema as Record<string, unknown>
if (Object.hasOwn(node, 'oneOf')) {
const node = frame.schema
if (node.oneOf !== undefined) {
frame.kind = 'oneOf'
frame.children = (node.oneOf as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` }))
frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` }))
continue
}
if (!Object.hasOwn(node, 'type')) {
if (node.type === undefined) {
state.typing.add('Any')
finish('Any')
continue
@@ -325,7 +329,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str
case 'boolean': finish(renderConstrainedScalar(node, 'bool', state)); break
case 'null': finish('None'); break
case 'array': {
if (!Object.hasOwn(node, 'items')) {
if (node.items === undefined) {
state.typing.add('Any')
finish('list[Any]')
break
@@ -341,7 +345,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str
// shape. The openness of the resulting empty object is decided below,
// so a closed empty object still declares an empty TypedDict rather
// than a permissive `dict[str, Any]`.
const entries = Object.entries((node.properties ?? {}) as Record<string, unknown>)
const entries = Object.entries(node.properties ?? {})
// An empty `className` marks the context-free `jsonSchemaToPy` entry:
// there is no naming context to declare into, so degrade. A field
// name that is not a legal Python attribute is inexpressible as a