Skip to content

Commit d9e0b3f

Browse files
fix(cddl2py): emit valid Python for .regexp operator
The template-annotated pattern logic produced invalid expressions like `Annotated[str, "custom:" + str]`, which fails at runtime because you cannot concatenate a string literal with the str type object. Complex patterns (e.g. `[^@]+@[^@]+`) were also silently downgraded to bare `str`, losing the constraint even in pydantic mode. Store the regex pattern as a plain string annotation instead (`Annotated[str, "custom:.+"]`), which is valid Python and preserves the constraint as inspectable metadata.
1 parent c6d0a86 commit d9e0b3f

2 files changed

Lines changed: 23 additions & 46 deletions

File tree

packages/cddl2py/src/index.ts

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -517,37 +517,6 @@ function getExtraItemsType (props: Property[], ctx: Context): string | undefined
517517
return `Union[${uniqueTypes.join(', ')}]`
518518
}
519519

520-
function stringifyPythonLiteral (value: string) {
521-
return JSON.stringify(value)
522-
}
523-
524-
function getTemplateAnnotatedPattern (regexpPattern: string): string | undefined {
525-
const wildcard = '.+'
526-
if (!regexpPattern.includes(wildcard) || /[\\()[\]{}|?*^$]/.test(regexpPattern.replaceAll(wildcard, ''))) {
527-
return
528-
}
529-
530-
const segments = regexpPattern.split(wildcard)
531-
const parts: string[] = []
532-
533-
for (let i = 0; i < segments.length; i++) {
534-
const segment = segments[i]
535-
if (segment.length > 0) {
536-
parts.push(stringifyPythonLiteral(segment))
537-
}
538-
539-
if (i < segments.length - 1) {
540-
parts.push('str')
541-
}
542-
}
543-
544-
if (parts.length === 0 || !parts.includes('str')) {
545-
return
546-
}
547-
548-
return `Annotated[str, ${parts.join(' + ')}]`
549-
}
550-
551520
function resolveNativeTypeWithOperator (t: NativeTypeWithOperator, ctx: Context): string | undefined {
552521
if (typeof t.Type !== 'string') {
553522
return
@@ -566,21 +535,13 @@ function resolveNativeTypeWithOperator (t: NativeTypeWithOperator, ctx: Context)
566535
return mapped
567536
}
568537

569-
const templateAnnotatedPattern = getTemplateAnnotatedPattern(regexpPattern)
570-
if (!templateAnnotatedPattern) {
571-
if (mapped === 'Any') {
572-
ctx.typingImports.add('Any')
573-
}
574-
return mapped
575-
}
576-
577538
ctx.typingImports.add('Annotated')
578539
if (ctx.pydantic) {
579540
ctx.pydanticImports.add('StringConstraints')
580541
return `Annotated[${mapped}, StringConstraints(pattern=${JSON.stringify(regexpPattern)})]`
581542
}
582543

583-
return templateAnnotatedPattern
544+
return `Annotated[${mapped}, ${JSON.stringify(regexpPattern)}]`
584545
}
585546

586547
// ---------------------------------------------------------------------------

packages/cddl2py/tests/transform_edge_cases.test.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,18 +205,34 @@ describe('transform edge cases', () => {
205205
Type: 'regexp',
206206
Value: literal('custom:.+')
207207
}
208+
} as any),
209+
variable('sandwiched-name', {
210+
Type: 'tstr',
211+
Operator: {
212+
Type: 'regexp',
213+
Value: literal('some_.+_name')
214+
}
215+
} as any),
216+
variable('email-address', {
217+
Type: 'tstr',
218+
Operator: {
219+
Type: 'regexp',
220+
Value: literal('[^@]+@[^@]+')
221+
}
208222
} as any)
209223
], { pydantic: true })
210224

211225
expect(typedDictOutput).toContain('from typing import Annotated')
212-
expect(typedDictOutput).toContain('Channel = Annotated[str, "custom:" + str]')
213-
expect(typedDictOutput).toContain('PrefixedName = Annotated[str, "foo_" + str]')
214-
expect(typedDictOutput).toContain('SandwichedName = Annotated[str, "some_" + str + "_name"]')
215-
expect(typedDictOutput).toContain('MultiSlotName = Annotated[str, "pre_" + str + "_mid_" + str + "_post"]')
216-
expect(typedDictOutput).toContain('channel: Annotated[str, "custom:" + str]')
217-
expect(typedDictOutput).toContain('EmailAddress = str')
226+
expect(typedDictOutput).toContain('Channel = Annotated[str, "custom:.+"]')
227+
expect(typedDictOutput).toContain('PrefixedName = Annotated[str, "foo_.+"]')
228+
expect(typedDictOutput).toContain('SandwichedName = Annotated[str, "some_.+_name"]')
229+
expect(typedDictOutput).toContain('MultiSlotName = Annotated[str, "pre_.+_mid_.+_post"]')
230+
expect(typedDictOutput).toContain('channel: Annotated[str, "custom:.+"]')
231+
expect(typedDictOutput).toContain('EmailAddress = Annotated[str, "[^@]+@[^@]+"]')
218232
expect(pydanticOutput).toContain('from pydantic import StringConstraints')
219233
expect(pydanticOutput).toContain('Channel = Annotated[str, StringConstraints(pattern="custom:.+")]')
234+
expect(pydanticOutput).toContain('SandwichedName = Annotated[str, StringConstraints(pattern="some_.+_name")]')
235+
expect(pydanticOutput).toContain('EmailAddress = Annotated[str, StringConstraints(pattern="[^@]+@[^@]+")]')
220236
})
221237

222238
it('should collapse multiple union mixin groups into a single alias', () => {

0 commit comments

Comments
 (0)