Skip to content

Commit cc60d2e

Browse files
fix: improved leading comments in front of a type as well as extensible interface keys (#46)
* fix(cddl2ts,cddl2py): preserve comments and extensible maps * test(cddl2ts): update comment placement snapshots * test(cddl2ts,cddl2py): add extensible metadata snapshots * test(cddl2ts,cddl2py): record extensible metadata snapshots
1 parent fcb6c7b commit cc60d2e

13 files changed

Lines changed: 392 additions & 59 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
MetadataScalar = null / bool / int / float / text
2+
MessageMetadata = {
3+
? provider: text,
4+
? model: text,
5+
? modelType: text,
6+
? runId: text,
7+
? threadId: text,
8+
? systemFingerprint: text,
9+
? serviceTier: text,
10+
* text => MetadataScalar,
11+
}

packages/cddl2py/src/index.ts

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ interface ResolveTypeOptions {
2828
quoteForwardReferences?: boolean
2929
}
3030

31+
const STRING_RECORD_KEY_TYPES = new Set(['str', 'text', 'tstr'])
32+
3133
export function transform (assignments: Assignment[], options?: TransformOptions): string {
3234
const ctx: Context = {
3335
pydantic: options?.pydantic ?? false,
@@ -135,6 +137,7 @@ function generateGroup (group: Group, ctx: Context): string {
135137
}
136138

137139
const props = properties as Property[]
140+
const extraItemsType = getExtraItemsType(props, ctx)
138141

139142
if (props.length === 1) {
140143
const prop = props[0]
@@ -150,7 +153,7 @@ function generateGroup (group: Group, ctx: Context): string {
150153
}
151154

152155
const mixins = props.filter(isUnNamedProperty)
153-
const ownProps = props.filter(p => !isUnNamedProperty(p))
156+
const ownProps = props.filter(p => !isUnNamedProperty(p) && !isExtensibleRecordProperty(p))
154157

155158
const simpleMixinBases: string[] = []
156159
const unionMixinGroups: string[][] = []
@@ -186,10 +189,10 @@ function generateGroup (group: Group, ctx: Context): string {
186189
}
187190

188191
if (unionMixinGroups.length > 0) {
189-
return comments + generateGroupWithUnionMixins(name, simpleMixinBases, unionMixinGroups, ownProps, ctx)
192+
return comments + generateGroupWithUnionMixins(name, simpleMixinBases, unionMixinGroups, ownProps, extraItemsType, ctx)
190193
}
191194

192-
return comments + generateClass(name, simpleMixinBases, ownProps, ctx)
195+
return comments + generateClass(name, simpleMixinBases, ownProps, ctx, extraItemsType)
193196
}
194197

195198
function generateGroupWithChoices (name: string, properties: (Property | Property[])[], ctx: Context): string {
@@ -259,6 +262,7 @@ function generateGroupWithUnionMixins (
259262
simpleBases: string[],
260263
unionGroups: string[][],
261264
ownProps: Property[],
265+
extraItemsType: string | undefined,
262266
ctx: Context
263267
): string {
264268
if (ownProps.length === 0 && simpleBases.length === 0) {
@@ -278,7 +282,7 @@ function generateGroupWithUnionMixins (
278282

279283
if (ownProps.length > 0) {
280284
const baseName = `_${name}Fields`
281-
blocks.push(generateClass(baseName, [], ownProps, ctx))
285+
blocks.push(generateClass(baseName, [], ownProps, ctx, extraItemsType))
282286

283287
for (let i = 0; i < unionTypes.length; i++) {
284288
const variantName = `_${name}Variant${i}`
@@ -291,7 +295,7 @@ function generateGroupWithUnionMixins (
291295
const variantName = `_${name}Variant${i}`
292296
variantNames.push(variantName)
293297
const bases = [unionTypes[i], ...simpleBases]
294-
blocks.push(generateClass(variantName, bases, [], ctx))
298+
blocks.push(generateClass(variantName, bases, [], ctx, extraItemsType))
295299
}
296300
}
297301
} else {
@@ -366,7 +370,13 @@ function generateArrayAssignment (arr: CDDLArray, ctx: Context): string {
366370
// Class generation (TypedDict or Pydantic BaseModel)
367371
// ---------------------------------------------------------------------------
368372

369-
function generateClass (name: string, bases: string[], props: Property[], ctx: Context): string {
373+
function generateClass (
374+
name: string,
375+
bases: string[],
376+
props: Property[],
377+
ctx: Context,
378+
extraItemsType?: string
379+
): string {
370380
const lines: string[] = []
371381

372382
let classDecl: string
@@ -381,17 +391,25 @@ function generateClass (name: string, bases: string[], props: Property[], ctx: C
381391
} else {
382392
ctx.typingExtensionsImports.add('TypedDict')
383393
const typedDictBases = bases.filter((base) => isModelCompatibleBase(base, ctx))
384-
if (typedDictBases.length > 0) {
385-
classDecl = `class ${name}(${typedDictBases.join(', ')}):`
386-
} else {
387-
classDecl = `class ${name}(TypedDict):`
388-
}
394+
const baseList = typedDictBases.length > 0 ? typedDictBases.join(', ') : 'TypedDict'
395+
classDecl = extraItemsType
396+
? `class ${name}(${baseList}, extra_items=${extraItemsType}):`
397+
: `class ${name}(${baseList}):`
389398
}
390399

391400
lines.push(classDecl)
392401

402+
if (ctx.pydantic && extraItemsType) {
403+
ctx.pydanticImports.add('ConfigDict')
404+
ctx.pydanticImports.add('Field')
405+
lines.push(` __pydantic_extra__: dict[str, ${extraItemsType}] = Field(init=False)`)
406+
lines.push(` model_config = ConfigDict(extra='allow')`)
407+
}
408+
393409
if (props.length === 0) {
394-
lines.push(' pass')
410+
if (lines.length === 1) {
411+
lines.push(' pass')
412+
}
395413
return lines.join('\n')
396414
}
397415

@@ -470,6 +488,34 @@ function generateField (prop: Property, ctx: Context): string | null {
470488
return ` ${propName}: ${typeStr}${commentSuffix}`
471489
}
472490

491+
function isExtensibleRecordProperty (prop: Property): boolean {
492+
return !isUnNamedProperty(prop) &&
493+
prop.Occurrence.m === Infinity &&
494+
!prop.HasCut &&
495+
STRING_RECORD_KEY_TYPES.has(prop.Name)
496+
}
497+
498+
function getExtraItemsType (props: Property[], ctx: Context): string | undefined {
499+
const types = props
500+
.filter(isExtensibleRecordProperty)
501+
.flatMap((prop) => {
502+
const cddlTypes = Array.isArray(prop.Type) ? prop.Type : [prop.Type]
503+
return cddlTypes.map((type) => resolveType(type, ctx))
504+
})
505+
506+
if (types.length === 0) {
507+
return
508+
}
509+
510+
const uniqueTypes = [...new Set(types)]
511+
if (uniqueTypes.length === 1) {
512+
return uniqueTypes[0]
513+
}
514+
515+
ctx.typingImports.add('Union')
516+
return `Union[${uniqueTypes.join(', ')}]`
517+
}
518+
473519
// ---------------------------------------------------------------------------
474520
// Type resolution
475521
// ---------------------------------------------------------------------------
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`extensible metadata > should render extensible metadata as a TypedDict with extra_items 1`] = `
4+
"# compiled with https://www.npmjs.com/package/cddl2py
5+
6+
from __future__ import annotations
7+
8+
from typing import Union
9+
from typing_extensions import NotRequired, TypedDict
10+
11+
MetadataScalar = Union[None, bool, int, float, str]
12+
13+
class MessageMetadata(TypedDict, extra_items=MetadataScalar):
14+
provider: NotRequired[str]
15+
model: NotRequired[str]
16+
model_type: NotRequired[str]
17+
run_id: NotRequired[str]
18+
thread_id: NotRequired[str]
19+
system_fingerprint: NotRequired[str]
20+
service_tier: NotRequired[str]
21+
"
22+
`;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import url from 'node:url'
2+
import path from 'node:path'
3+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
4+
5+
import cli from '../src/cli.js'
6+
import { normalizeSnapshotOutput } from './snapshot.js'
7+
8+
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
9+
const cddlFile = path.join(__dirname, '..', '..', '..', 'examples', 'commons', 'extensible_metadata.cddl')
10+
11+
vi.mock('../src/constants', () => ({
12+
pkg: {
13+
name: 'cddl2py',
14+
version: '0.1.0',
15+
author: 'Test Author',
16+
description: 'Generate Python types from CDDL'
17+
},
18+
NATIVE_TYPE_MAP: {
19+
any: 'Any',
20+
number: 'Union[int, float]',
21+
int: 'int',
22+
uint: 'int',
23+
nint: 'int',
24+
float: 'float',
25+
float16: 'float',
26+
float32: 'float',
27+
float64: 'float',
28+
bool: 'bool',
29+
bstr: 'bytes',
30+
bytes: 'bytes',
31+
tstr: 'str',
32+
text: 'str',
33+
str: 'str',
34+
nil: 'None',
35+
null: 'None',
36+
}
37+
}))
38+
39+
describe('extensible metadata', () => {
40+
let exitOrig = process.exit
41+
let logOrig = console.log
42+
let errorOrig = console.error
43+
44+
beforeEach(() => {
45+
process.exit = vi.fn() as any
46+
console.log = vi.fn()
47+
console.error = vi.fn()
48+
})
49+
50+
afterEach(() => {
51+
process.exit = exitOrig
52+
console.log = logOrig
53+
console.error = errorOrig
54+
})
55+
56+
it('should render extensible metadata as a TypedDict with extra_items', async () => {
57+
await cli([cddlFile])
58+
59+
expect(process.exit).not.toHaveBeenCalledWith(1)
60+
expect(console.error).not.toHaveBeenCalled()
61+
62+
const output = vi.mocked(console.log).mock.calls.flat().join('\n')
63+
64+
expect(output).toContain('class MessageMetadata(TypedDict, extra_items=MetadataScalar):')
65+
expect(output).toContain('provider: NotRequired[str]')
66+
expect(output).toContain('model_type: NotRequired[str]')
67+
expect(output).toContain('system_fingerprint: NotRequired[str]')
68+
expect(output).not.toContain('text: NotRequired[MetadataScalar]')
69+
expect(normalizeSnapshotOutput(output)).toMatchSnapshot()
70+
})
71+
})

packages/cddl2py/tests/transform_edge_cases.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,48 @@ describe('transform edge cases', () => {
281281
expect(output).toContain('maybe_enabled: Optional[bool] = Field(default=False)')
282282
})
283283

284+
it('should emit extensible TypedDict properties as extra_items', () => {
285+
const output = transform([
286+
variable('metadata-scalar', ['null', 'bool', 'int', 'float', 'text']),
287+
group('message-metadata', [
288+
property('provider', 'text', {
289+
Occurrence: { n: 0, m: 1 }
290+
}),
291+
property('model', 'text', {
292+
Occurrence: { n: 0, m: 1 }
293+
}),
294+
property('text', groupRef('metadata-scalar'), {
295+
Occurrence: { n: 0, m: Infinity }
296+
})
297+
])
298+
])
299+
300+
expect(output).toContain('class MessageMetadata(TypedDict, extra_items=MetadataScalar):')
301+
expect(output).toContain('provider: NotRequired[str]')
302+
expect(output).toContain('model: NotRequired[str]')
303+
expect(output).not.toContain('text: NotRequired[MetadataScalar]')
304+
})
305+
306+
it('should emit typed extra fields for pydantic models', () => {
307+
const output = transform([
308+
variable('metadata-scalar', ['null', 'bool', 'int', 'float', 'text']),
309+
group('message-metadata', [
310+
property('provider', 'text', {
311+
Occurrence: { n: 0, m: 1 }
312+
}),
313+
property('text', groupRef('metadata-scalar'), {
314+
Occurrence: { n: 0, m: Infinity }
315+
})
316+
])
317+
], { pydantic: true })
318+
319+
expect(output).toContain('from pydantic import BaseModel, ConfigDict, Field')
320+
expect(output).toContain('class MessageMetadata(BaseModel):')
321+
expect(output).toContain('__pydantic_extra__: dict[str, MetadataScalar] = Field(init=False)')
322+
expect(output).toContain(`model_config = ConfigDict(extra='allow')`)
323+
expect(output).not.toContain('text: MetadataScalar')
324+
})
325+
284326
it('should cover direct type-resolution edge cases', () => {
285327
const output = transform([
286328
variable('direct-range', {

0 commit comments

Comments
 (0)