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
33 changes: 21 additions & 12 deletions codegen/layouts/partials/resource-dataclass.hbs
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
@dataclass
class {{className}}:
"""{{{indent (pythonDoc description) 4}}}{{#each properties}}
{{classIndent}}@dataclass
{{classIndent}}class {{className}}{{#if isNested}}(ResourceMapping){{/if}}:
{{memberIndent}}"""{{{indent (pythonDoc description) docIndent}}}{{#each properties}}

:ivar {{pythonIdentifier name}}: {{#if isDeprecated}}Deprecated{{#if deprecationMessage}}: {{{indent (pythonDoc deprecationMessage) 4}}}{{else}}.{{/if}}{{#if (pythonDoc description)}} {{/if}}{{/if}}{{{indent (pythonDoc description) 4}}}{{/each}}{{#if isDeprecated}}
{{../memberIndent}}:ivar {{pythonIdentifier name}}: {{#if isDeprecated}}Deprecated{{#if deprecationMessage}}: {{{indent (pythonDoc deprecationMessage) ../docIndent}}}{{else}}.{{/if}}{{#if (pythonDoc description)}} {{/if}}{{/if}}{{{indent (pythonDoc description) ../docIndent}}}{{/each}}{{#if isDeprecated}}

.. deprecated::
{{#if (pythonDoc deprecationMessage)}}{{{indent (pythonDoc deprecationMessage) 4}}}{{else}}This resource is deprecated.{{/if}}{{/if}}"""
{{memberIndent}}.. deprecated::
{{memberIndent}} {{#if (pythonDoc deprecationMessage)}}{{{indent (pythonDoc deprecationMessage) docIndent}}}{{else}}This resource is deprecated.{{/if}}{{/if}}"""

{{#each nestedClasses}}
{{> resource-dataclass isNested=true}}

{{/each}}
{{#each properties}}
{{pythonIdentifier name}}: {{type}}
{{../memberIndent}}{{pythonIdentifier name}}: {{type}}
{{/each}}

@staticmethod
def from_dict(d: Dict[str, Any]):
return {{className}}(
{{memberIndent}}@classmethod
{{memberIndent}}def from_dict(cls, d: Dict[str, Any]):
{{#unless properties}}
{{memberIndent}} # This shape documents no properties, so there is nothing to read.
{{memberIndent}} # pylint: disable=unused-argument
{{/unless}}
{{memberIndent}} return cls(
{{#each properties}}
{{pythonIdentifier name}}={{#if isDictParam}}DeepAttrDict({{/if}}d.get("{{name}}", None){{#if isDictParam}}){{/if}},
{{../memberIndent}} {{pythonIdentifier name}}={{#if isObject}}cls.{{nestedClassName}}.from_dict(d.get("{{name}}")) if d.get("{{name}}") is not None else None{{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}},
{{/each}}
)
{{memberIndent}} )
1 change: 1 addition & 0 deletions codegen/layouts/resource.hbs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Any, Dict, List, Optional, Union
from dataclasses import dataclass
from ..utils.deep_attr_dict import DeepAttrDict
from ..utils.resource_mapping import ResourceMapping


{{> resource-dataclass}}
292 changes: 261 additions & 31 deletions codegen/lib/layouts/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,278 @@
// 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, Resource } from '@seamapi/blueprint'
import type { Blueprint, Property } from '@seamapi/blueprint'
import { pascalCase, snakeCase } from 'change-case'

import { convertCustomResourceName } from '../custom-resource-name-conversions.js'
import { mapPropertyToPythonType } from '../python-type.js'

export interface ResourceLayoutContext {
className: string
export interface ResourceLayoutContext extends ResourceClassLayoutContext {
moduleName: string
isDeprecated: boolean
deprecationMessage: string
}

interface ResourceClassLayoutContext {
className: string
description: string
classIndent: string
memberIndent: string
docIndent: number
nestedClasses: ResourceClassLayoutContext[]
properties: ResourcePropertyLayoutContext[]
}

interface ResourcePropertyLayoutContext {
name: string
description: string
isDeprecated: boolean
deprecationMessage: string
properties: Array<{
name: string
description: string
isDeprecated: boolean
deprecationMessage: string
type: string
isDictParam: boolean
}>
type: string
nestedClassName: string
isDictParam: boolean
isObject: boolean
isObjectList: boolean
}

export interface ResourcesIndexLayoutContext {
resources: Array<{ className: string; moduleName: string }>
}

// The action attempt and event variants each generate a single dataclass with
// the union of the variant properties. The first occurrence of a property
// name wins.
const mergeResourceProperties = (resources: Resource[]): Property[] => {
const merged = new Map<string, Property>()
for (const { properties } of resources) {
// the union of the variant properties.
const mergeResourceProperties = (
resources: Array<{ properties: Property[] }>,
): Property[] =>
mergePropertyLists(resources.map(({ properties }) => properties))

const formatKey = (property: Property): string =>
property.format === 'list' ? `list<${property.itemFormat}>` : property.format

const isScalar = (property: Property): boolean =>
property.format !== 'list' && property.format !== 'object'

interface MergedDocs {
description: string
isDeprecated: boolean
deprecationMessage: string
}

// Each variant documents a property for its own case, which is accurate there
// but not for the single dataclass the variants merge into. Some are merely
// narrow ("Previous code configuration" on a shape that also covers names);
// others contradict each other outright ("the error is not a device error"
// against "the error is a device error"). No description beats a wrong one, so
// keep one only when every variant that documents the property agrees.
const mergeDocs = (occurrences: Property[]): MergedDocs => {
const descriptions = [
...new Set(
occurrences
.map((occurrence) => occurrence.description.trim())
.filter((description) => description !== ''),
),
]
const deprecated = occurrences.find(({ isDeprecated }) => isDeprecated)

return {
description: descriptions.length === 1 ? (descriptions[0] ?? '') : '',
// Deprecating in any variant deprecates the merged property, so a warning
// is never dropped just because another variant omits it.
isDeprecated: deprecated != null,
deprecationMessage: deprecated?.deprecationMessage ?? '',
}
}

// The variants of a discriminated union collapse into a single dataclass, so a
// property carried by more than one variant has to end up with every field any
// variant gives it. Keeping only the first occurrence silently drops the rest,
// which loses data once from_dict reads the merged shape field by field.
const mergeOccurrences = (occurrences: Property[], path: string): Property => {
const [first, ...rest] = occurrences
if (first == null) throw new Error(`Nothing to merge at ${path}.`)
if (rest.length === 0) return first

const docs = mergeDocs(occurrences)

const formats = new Set(occurrences.map(formatKey))
if (formats.size > 1) {
// Scalars all map to the same Python type, so any of them stands in.
if (occurrences.every(isScalar)) return { ...first, ...docs }
throw new Error(
`Cannot merge ${path}: variants disagree on its shape (${[...formats].join(', ')}).`,
)
}

if (first.format === 'object') {
return {
...first,
...docs,
properties: mergePropertyLists(
occurrences.map(
(occurrence) => (occurrence as typeof first).properties,
),
path,
),
}
}

if (first.format === 'list' && first.itemFormat === 'object') {
return {
...first,
...docs,
itemProperties: mergePropertyLists(
occurrences.map(
(occurrence) => (occurrence as typeof first).itemProperties,
),
`${path}[]`,
),
}
}

if (first.format === 'list' && first.itemFormat === 'discriminated_object') {
// Keep every variant. Whoever consumes this list merges them in turn.
return {
...first,
...docs,
variants: occurrences.flatMap(
(occurrence) => (occurrence as typeof first).variants,
),
}
}

return { ...first, ...docs }
}

const mergePropertyLists = (
propertyLists: Property[][],
path = '',
): Property[] => {
const occurrences = new Map<string, Property[]>()
for (const properties of propertyLists) {
for (const property of properties) {
if (!merged.has(property.name)) merged.set(property.name, property)
const group = occurrences.get(property.name)
if (group == null) {
occurrences.set(property.name, [property])
} else {
group.push(property)
}
}
}
return [...merged.values()]

return [...occurrences.entries()].map(([name, group]) =>
mergeOccurrences(group, path === '' ? name : `${path}.${name}`),
)
}

// Resource dataclasses are declared at module level.
const rootIndentation = 0

// Nested classes are named after their property, so an unusually deep shape is
// far more likely to be an accidental cycle than a real schema.
const maxNestingDepth = 16

// Names a nested class must not claim, since it would shadow something the
// generated module resolves from an enclosing scope.
const reservedClassNames = new Set([
'Any',
'DeepAttrDict',
'Dict',
'List',
'Optional',
'ResourceMapping',
'Union',
'dataclass',
])

const getNestedProperties = (property: Property): Property[] | undefined => {
if (property.format === 'object') return property.properties
if (property.format === 'list' && property.itemFormat === 'object') {
return property.itemProperties
}
if (
property.format === 'list' &&
property.itemFormat === 'discriminated_object'
) {
return mergeResourceProperties(property.variants)
}
return undefined
}

const buildClass = (
className: string,
description: string,
classProperties: Property[],
path: string,
indentation: number,
): ResourceClassLayoutContext => {
if (indentation > rootIndentation + 4 * maxNestingDepth) {
throw new Error(
`Nested resource classes exceeded a depth of ${maxNestingDepth} at ${path}. This usually means the schema is cyclic.`,
)
}

const nestedClasses: ResourceClassLayoutContext[] = []
const takenClassNames = new Set<string>()

const properties = classProperties.map((property) => {
const nestedProperties = getNestedProperties(property)
const nestedPath = `${path}.${property.name}`
let nestedClassName: string | undefined

if (nestedProperties != null) {
// Each class scopes its own nested classes, so the property name alone
// names them unambiguously.
nestedClassName = pascalCase(property.name)

if (reservedClassNames.has(nestedClassName)) {
throw new Error(
`The ${nestedPath} property would generate a nested class named ${nestedClassName}, which shadows a name the generated module depends on.`,
)
}

if (takenClassNames.has(nestedClassName)) {
throw new Error(
`The ${nestedPath} property would generate a second nested class named ${nestedClassName} inside ${className}.`,
)
}
takenClassNames.add(nestedClassName)

nestedClasses.push(
buildClass(
nestedClassName,
property.description,
nestedProperties,
nestedPath,
indentation + 4,
),
)
}

const type = mapPropertyToPythonType(property, nestedClassName)
return {
name: property.name,
description: property.description,
isDeprecated: property.isDeprecated,
deprecationMessage: property.deprecationMessage,
type,
// Nested classes are attributes of the class that owns them, so
// from_dict reaches them through cls rather than a qualified path.
nestedClassName: nestedClassName ?? '',
isDictParam: type.startsWith('Dict'),
isObject: nestedClassName != null && property.format === 'object',
isObjectList: nestedClassName != null && property.format === 'list',
}
})

return {
className,
description,
classIndent: ' '.repeat(indentation),
memberIndent: ' '.repeat(indentation + 4),
docIndent: indentation + 4,
nestedClasses,
properties,
}
}

export const getResourceLayoutContexts = (
Expand Down Expand Up @@ -91,27 +326,22 @@ export const getResourceLayoutContexts = (
const { properties, description, isDeprecated, deprecationMessage } =
model
const className = pascalCase(convertCustomResourceName(name))
return {
const rootClass = buildClass(
className,
description,
properties,
name,
rootIndentation,
)

return {
...rootClass,
isDeprecated,
deprecationMessage,
// Derived from the class name rather than the resource type so the
// module always matches the dataclass it exports (e.g. the "event"
// resource becomes SeamEvent in seam_event.py).
moduleName: snakeCase(className),
properties: properties.map((property) => {
const type = mapPropertyToPythonType(property)
return {
name: property.name,
description: property.description,
isDeprecated: property.isDeprecated,
deprecationMessage: property.deprecationMessage,
type,
isDictParam:
type.startsWith('Dict') || property.name === 'properties',
}
}),
}
})
.sort((a, b) => (a.moduleName < b.moduleName ? -1 : 1))
Expand Down
Loading
Loading