Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion codegen/layouts/partials/resource-dataclass.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@
{{/unless}}
{{memberIndent}} return cls(
{{#each properties}}
{{../memberIndent}} {{pythonIdentifier name}}={{#if isObject}}cls.{{nestedClassName}}.from_dict(d.get("{{name}}")) if d.get("{{name}}") is not None else None{{else}}{{#if isDiscriminatedObjectList}}[_from_discriminated_dict(i, cls._{{nestedClassName}}Variants, "{{discriminator}}") for i in d.get("{{name}}") or []]{{else}}{{#if isObjectList}}[cls.{{nestedClassName}}.from_dict(i) for i in d.get("{{name}}") or []]{{else}}{{#if isDictParam}}DeepAttrDict({{/if}}d.get("{{name}}", None){{#if isDictParam}}){{/if}}{{/if}}{{/if}}{{/if}},
{{../memberIndent}} {{pythonIdentifier name}}={{#if isRequiredObject}}cls.{{nestedClassName}}.from_dict(d.get("{{name}}") or {}){{else}}{{#if isObject}}cls.{{nestedClassName}}.from_dict(d.get("{{name}}")) if d.get("{{name}}") is not None else None{{else}}{{#if isDiscriminatedObjectList}}[_from_discriminated_dict(i, cls._{{nestedClassName}}Variants, "{{discriminator}}") for i in d.get("{{name}}") or []]{{else}}{{#if isObjectList}}[cls.{{nestedClassName}}.from_dict(i) for i in d.get("{{name}}") or []]{{else}}{{#if isDictParam}}DeepAttrDict({{/if}}d.get("{{name}}", None){{#if isDictParam}}){{/if}}{{/if}}{{/if}}{{/if}}{{/if}},
{{/each}}
{{memberIndent}} )
14 changes: 9 additions & 5 deletions codegen/layouts/resource.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Dict, List, Literal, Optional, Union{{#if union}}, cast{{/if}}
from typing import Any, Dict, List, Literal, Optional, {{#if union.secondaryDiscriminator}}Tuple, {{/if}}Union{{#if union}}, cast{{/if}}
from dataclasses import dataclass
from ..deep_attr_dict import DeepAttrDict
from ..resource_mapping import ResourceMapping
Expand All @@ -20,22 +20,26 @@ def _from_discriminated_dict(
{{#if union}}
{{union.className}} = Union[{{#each union.variants}}{{className}}{{#unless @last}}, {{/unless}}{{/each}}]

{{union.variantsName}}: Dict[str, Any] = {
{{#each union.aliases}}
{{className}} = Union[{{#each variantClassNames}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}]

{{/each}}
{{union.variantsName}}: Dict[{{#if union.secondaryDiscriminator}}Tuple[str, str]{{else}}str{{/if}}, Any] = {
{{#each union.variants}}
{{#each values}}
{{pythonString this}}: {{../className}},
{{#if @root.union.secondaryDiscriminator}}({{pythonString this}}, {{pythonString ../secondaryValue}}){{else}}{{pythonString this}}{{/if}}: {{../className}},
{{/each}}
{{/each}}
}


def {{union.fromDictName}}(d: Any) -> {{union.className}}:
"""Deserialize a known {{union.discriminator}} variant.
"""Deserialize a known {{union.discriminator}}{{#if union.secondaryDiscriminator}} and {{union.secondaryDiscriminator}}{{/if}} variant.

Unknown discriminator values return ``DeepAttrDict`` so payloads from a
newer API remain readable. The static return type covers known variants.
"""
variant = {{union.variantsName}}.get(d.get("{{union.discriminator}}"))
variant = {{union.variantsName}}.get({{#if union.secondaryDiscriminator}}(d.get("{{union.discriminator}}"), d.get("{{union.secondaryDiscriminator}}")){{else}}d.get("{{union.discriminator}}"){{/if}})
if variant is None:
return cast({{union.className}}, DeepAttrDict(d))
return variant.from_dict(d)
Expand Down
229 changes: 192 additions & 37 deletions codegen/lib/layouts/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
// Each blueprint resource, along with events, action attempts, and pagination,
// becomes a dataclass in its own module, re-exported from seam/resources/__init__.py.

import type { Blueprint, Property } from '@seamapi/blueprint'
import type {
ActionAttemptStatus,
Blueprint,
EnumProperty,
Property,
} from '@seamapi/blueprint'
import { pascalCase, snakeCase } from 'change-case'

import { convertCustomResourceName } from '../custom-resource-name-conversions.js'
Expand Down Expand Up @@ -38,9 +43,15 @@ interface ResourceClassLayoutContext {
interface DiscriminatedUnionLayoutContext {
className: string
discriminator: string
secondaryDiscriminator?: string
fromDictName: string
variantsName: string
variants: Array<{ className: string; values: string[] }>
variants: Array<{
className: string
values: string[]
secondaryValue?: string
}>
aliases?: Array<{ className: string; variantClassNames: string[] }>
}

interface ResourcePropertyLayoutContext {
Expand All @@ -52,6 +63,7 @@ interface ResourcePropertyLayoutContext {
nestedClassName: string
isDictParam: boolean
isObject: boolean
isRequiredObject: boolean
isObjectList: boolean
isDiscriminatedObjectList: boolean
discriminator: string
Expand Down Expand Up @@ -248,6 +260,17 @@ const reservedClassNames = new Set([
'dataclass',
])

type StatusAnnotatedProperty = Property & {
renderAsNone?: boolean
presentForStatus?: boolean
}

const isRenderedAsNone = (property: Property): boolean =>
(property as StatusAnnotatedProperty).renderAsNone === true

const isPresentForStatus = (property: Property): boolean =>
(property as StatusAnnotatedProperty).presentForStatus === true

const getNestedProperties = (property: Property): Property[] | undefined => {
if (property.format === 'object') return property.properties
if (property.format === 'list' && property.itemFormat === 'object') {
Expand Down Expand Up @@ -296,6 +319,23 @@ const buildClass = (
const takenClassNames = new Set<string>()

const properties = classProperties.map((property) => {
if (isRenderedAsNone(property)) {
return {
name: property.name,
description: property.description,
isDeprecated: property.isDeprecated,
deprecationMessage: property.deprecationMessage,
type: 'None',
nestedClassName: '',
isDictParam: false,
isObject: false,
isRequiredObject: false,
isObjectList: false,
isDiscriminatedObjectList: false,
discriminator: '',
}
}

const nestedProperties = getNestedProperties(property)
const nestedPath = `${path}.${property.name}`
const isDiscriminatedObjectList =
Expand Down Expand Up @@ -379,12 +419,16 @@ const buildClass = (
}

const isObject = nestedClassName != null && property.format === 'object'
// A nested object is read as None whenever the payload omits it, and the
// schema is not a reliable guide to when that happens: an action attempt
// documents both error and result as required, yet a pending one carries
// neither. Constructing them unconditionally would fail on those payloads,
// so from_dict keeps its None fallback and the field stays Optional.
const type = mapPropertyToPythonType(property, nestedClassName, isObject)
const isRequiredObject =
isObject &&
isPresentForStatus(property) &&
!property.isOptional &&
!property.isNullable
const type = mapPropertyToPythonType(
property,
nestedClassName,
isObject && !isRequiredObject,
)
const requiredType = mapRequiredPropertyToPythonType(
property,
nestedClassName,
Expand All @@ -400,6 +444,7 @@ const buildClass = (
nestedClassName: nestedClassName ?? '',
isDictParam: requiredType.startsWith('Dict'),
isObject,
isRequiredObject,
isObjectList:
!isDiscriminatedObjectList &&
nestedClassName != null &&
Expand Down Expand Up @@ -427,41 +472,108 @@ const hasDiscriminatedLists = (
resourceClass.nestedUnions.length > 0 ||
resourceClass.nestedClasses.some(hasDiscriminatedLists)

interface UnionVariant {
value: string
secondaryValue?: string
description: string
properties: Property[]
isDeprecated: boolean
deprecationMessage: string
}

const buildUnionAliases = (
variants: Array<{ className: string; groupValue: string | undefined }>,
suffix: string,
): Array<{ className: string; variantClassNames: string[] }> => {
const groups = new Map<string, string[]>()
for (const { className, groupValue } of variants) {
if (groupValue == null) continue
const group = groups.get(groupValue)
if (group == null) {
groups.set(groupValue, [className])
} else {
group.push(className)
}
}
return [...groups.entries()].map(([value, variantClassNames]) => ({
className: `${pythonClassName(value)}${suffix}`,
variantClassNames,
}))
}

const buildUnionResource = (
className: string,
discriminator: string,
fromDictName: string,
variants: Array<{
value: string
description: string
properties: Property[]
isDeprecated: boolean
deprecationMessage: string
}>,
variants: UnionVariant[],
isDeprecated: boolean,
deprecationMessage: string,
secondaryDiscriminator?: string,
): ResourceLayoutContext => {
const suffix = className === 'SeamEvent' ? 'Event' : 'ActionAttempt'
const classes = variants.map((variant) => ({
...buildClass(
`${pythonClassName(variant.value)}${suffix}`,
variant.description,
variant.properties,
`${snakeCase(className)}.${variant.value}`,
rootIndentation,
),
isDeprecated: variant.isDeprecated,
deprecationMessage: variant.deprecationMessage,
}))
const classes = variants.map((variant) => {
const secondaryName =
variant.secondaryValue == null
? ''
: pythonClassName(variant.secondaryValue)
const secondaryPath =
variant.secondaryValue == null ? '' : `.${variant.secondaryValue}`
return {
...buildClass(
`${pythonClassName(variant.value)}${secondaryName}${suffix}`,
variant.description,
variant.properties,
`${snakeCase(className)}.${variant.value}${secondaryPath}`,
rootIndentation,
),
isDeprecated: variant.isDeprecated,
deprecationMessage: variant.deprecationMessage,
}
})

const aliases =
secondaryDiscriminator == null
? []
: [
...buildUnionAliases(
classes.map(({ className: name }, index) => ({
className: name,
groupValue: variants[index]?.value,
})),
suffix,
),
...buildUnionAliases(
classes.map(({ className: name }, index) => ({
className: name,
groupValue: variants[index]?.secondaryValue,
})),
suffix,
),
]
const classNames = new Set(classes.map(({ className: name }) => name))
for (const alias of aliases) {
if (classNames.has(alias.className) || alias.className === className) {
throw new Error(
`The union alias ${alias.className} collides with a generated class name.`,
)
}
}

const union = {
className,
discriminator,
...(secondaryDiscriminator == null ? {} : { secondaryDiscriminator }),
fromDictName,
variantsName: `_${snakeCase(className).toUpperCase()}_VARIANTS`,
variants: classes.map((variantClass, index) => ({
className: variantClass.className,
values: [variants[index]?.value ?? ''],
})),
variants: classes.map((variantClass, index) => {
const secondaryValue = variants[index]?.secondaryValue
return {
className: variantClass.className,
values: [variants[index]?.value ?? ''],
...(secondaryValue == null ? {} : { secondaryValue }),
}
}),
aliases,
}

return {
Expand All @@ -474,12 +586,60 @@ const buildUnionResource = (
hasDiscriminatedLists: classes.some(hasDiscriminatedLists),
exports: [
...classes.map(({ className: name }) => name),
...aliases.map(({ className: name }) => name),
className,
fromDictName,
],
}
}

const expandActionAttemptByStatus = (
attempt: Blueprint['actionAttempts'][number],
): UnionVariant[] => {
const statusProperty = attempt.properties.find(
(property): property is EnumProperty =>
property.name === 'status' && property.format === 'enum',
)
if (statusProperty == null || statusProperty.values.length === 0) {
throw new Error(
`The ${attempt.actionAttemptType} action attempt must have a status enum property to expand into per-status variants.`,
)
}

return statusProperty.values.map(({ name: status }) => ({
value: attempt.actionAttemptType,
secondaryValue: status,
description: attempt.description,
isDeprecated: attempt.isDeprecated,
deprecationMessage: attempt.deprecationMessage,
properties: attempt.properties.map((property): Property => {
if (property === statusProperty) {
return {
...statusProperty,
values: statusProperty.values.filter(
(value) => value.name === status,
),
}
}
const { actionAttemptStatuses } = property
if (actionAttemptStatuses == null) return property
if (actionAttemptStatuses.includes(status as ActionAttemptStatus)) {
const presentProperty: StatusAnnotatedProperty = {
...property,
presentForStatus: true,
}
return presentProperty
}
const noneProperty: StatusAnnotatedProperty = {
...property,
isNullable: false,
renderAsNone: true,
}
return noneProperty
}),
}))
}

export const getResourceLayoutContexts = (
blueprint: Blueprint,
): ResourceLayoutContext[] => {
Expand Down Expand Up @@ -568,15 +728,10 @@ export const getResourceLayoutContexts = (
'ActionAttempt',
'action_type',
'action_attempt_from_dict',
blueprint.actionAttempts.map((attempt) => ({
value: attempt.actionAttemptType,
description: attempt.description,
properties: attempt.properties,
isDeprecated: attempt.isDeprecated,
deprecationMessage: attempt.deprecationMessage,
})),
blueprint.actionAttempts.flatMap(expandActionAttemptByStatus),
actionAttemptModel?.isDeprecated ?? false,
actionAttemptModel?.deprecationMessage ?? '',
'status',
),
)

Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
},
"packageManager": "npm@11.19.0",
"devDependencies": {
"@seamapi/blueprint": "^1.9.1",
"@seamapi/blueprint": "^1.10.0",
"@seamapi/fake-seam-connect": "2.0.5",
"@seamapi/smith": "^1.1.0",
"@seamapi/types": "1.1047.0",
Expand Down
Loading
Loading