Skip to content

Commit c5806ba

Browse files
committed
feat!: generate typed nested resource dataclasses
Nested resource objects now hydrate as typed mapping-compatible dataclasses and unknown nested API fields are stripped. Free-form record properties remain mappings. BREAKING CHANGE: Nested properties are typed objects rather than dict subclasses. dict(...) and isinstance(value, dict) no longer work for them; typoed attributes now raise AttributeError instead of returning and inserting an empty mapping; undocumented nested fields are stripped.
1 parent d681d4b commit c5806ba

42 files changed

Lines changed: 6912 additions & 324 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
@dataclass
2-
class {{className}}:
2+
class {{className}}{{#if isNested}}(ResourceMapping){{/if}}:
33
"""{{{indent (pythonDoc description) 4}}}{{#each properties}}
44

55
: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}}
@@ -10,10 +10,10 @@ class {{className}}:
1010
{{pythonIdentifier name}}: {{type}}
1111
{{/each}}
1212

13-
@staticmethod
14-
def from_dict(d: Dict[str, Any]):
15-
return {{className}}(
13+
@classmethod
14+
def from_dict(cls, d: Dict[str, Any]):
15+
return cls(
1616
{{#each properties}}
17-
{{pythonIdentifier name}}={{#if isDictParam}}DeepAttrDict({{/if}}d.get("{{name}}", None){{#if isDictParam}}){{/if}},
17+
{{pythonIdentifier name}}={{#if isObject}}{{type}}.from_dict(d.get("{{name}}")) if d.get("{{name}}") is not None else None{{else}}{{#if isObjectList}}[{{listItemType type}}.from_dict(i) for i in d.get("{{name}}") or []]{{else}}{{#if isDictParam}}DeepAttrDict({{/if}}d.get("{{name}}", None){{#if isDictParam}}){{/if}}{{/if}}{{/if}},
1818
{{/each}}
1919
)

codegen/layouts/resource.hbs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from typing import Any, Dict, List, Optional, Union
22
from dataclasses import dataclass
33
from ..utils.deep_attr_dict import DeepAttrDict
4+
from ..utils.resource_mapping import ResourceMapping
45

5-
6+
{{#each nestedClasses}}
7+
{{> resource-dataclass isNested=true}}
8+
{{/each}}
69
{{> resource-dataclass}}

codegen/lib/layouts/resources.ts

Lines changed: 86 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Each blueprint resource, along with events, action attempts, and pagination,
33
// becomes a dataclass in its own module, re-exported from seam/resources/__init__.py.
44

5-
import type { Blueprint, Property, Resource } from '@seamapi/blueprint'
5+
import type { Blueprint, Property } from '@seamapi/blueprint'
66
import { pascalCase, snakeCase } from 'change-case'
77

88
import { convertCustomResourceName } from '../custom-resource-name-conversions.js'
@@ -14,14 +14,25 @@ export interface ResourceLayoutContext {
1414
description: string
1515
isDeprecated: boolean
1616
deprecationMessage: string
17-
properties: Array<{
18-
name: string
19-
description: string
20-
isDeprecated: boolean
21-
deprecationMessage: string
22-
type: string
23-
isDictParam: boolean
24-
}>
17+
nestedClasses: ResourceClassLayoutContext[]
18+
properties: ResourcePropertyLayoutContext[]
19+
}
20+
21+
interface ResourceClassLayoutContext {
22+
className: string
23+
description: string
24+
properties: ResourcePropertyLayoutContext[]
25+
}
26+
27+
interface ResourcePropertyLayoutContext {
28+
name: string
29+
description: string
30+
isDeprecated: boolean
31+
deprecationMessage: string
32+
type: string
33+
isDictParam: boolean
34+
isObject: boolean
35+
isObjectList: boolean
2536
}
2637

2738
export interface ResourcesIndexLayoutContext {
@@ -31,7 +42,9 @@ export interface ResourcesIndexLayoutContext {
3142
// The action attempt and event variants each generate a single dataclass with
3243
// the union of the variant properties. The first occurrence of a property
3344
// name wins.
34-
const mergeResourceProperties = (resources: Resource[]): Property[] => {
45+
const mergeResourceProperties = (
46+
resources: Array<{ properties: Property[] }>,
47+
): Property[] => {
3548
const merged = new Map<string, Property>()
3649
for (const { properties } of resources) {
3750
for (const property of properties) {
@@ -91,6 +104,67 @@ export const getResourceLayoutContexts = (
91104
const { properties, description, isDeprecated, deprecationMessage } =
92105
model
93106
const className = pascalCase(convertCustomResourceName(name))
107+
const nestedClasses = new Map<string, ResourceClassLayoutContext>()
108+
109+
const buildProperties = (
110+
sourceProperties: Property[],
111+
): ResourcePropertyLayoutContext[] =>
112+
sourceProperties.map((property) => {
113+
let nestedClassName: string | undefined
114+
let nestedProperties: Property[] | undefined
115+
if (property.format === 'object') {
116+
nestedClassName = `${className}${pascalCase(property.name)}`
117+
nestedProperties = property.properties
118+
} else if (
119+
property.format === 'list' &&
120+
property.itemFormat === 'object'
121+
) {
122+
nestedClassName = `${className}${pascalCase(property.name)}`
123+
nestedProperties = property.itemProperties
124+
} else if (
125+
property.format === 'list' &&
126+
property.itemFormat === 'discriminated_object'
127+
) {
128+
nestedClassName = `${className}${pascalCase(property.name)}`
129+
nestedProperties = mergeResourceProperties(property.variants)
130+
}
131+
132+
if (
133+
nestedClassName != null &&
134+
nestedProperties != null &&
135+
!nestedClasses.has(nestedClassName)
136+
) {
137+
// Reserve the name before recursing so colliding/recursive shapes
138+
// cannot register it twice. Reinsert after children for definition
139+
// order: annotations are evaluated when each class is created.
140+
nestedClasses.set(nestedClassName, {
141+
className: nestedClassName,
142+
description: property.description,
143+
properties: [],
144+
})
145+
const childProperties = buildProperties(nestedProperties)
146+
nestedClasses.delete(nestedClassName)
147+
nestedClasses.set(nestedClassName, {
148+
className: nestedClassName,
149+
description: property.description,
150+
properties: childProperties,
151+
})
152+
}
153+
154+
const type = mapPropertyToPythonType(property, nestedClassName)
155+
return {
156+
name: property.name,
157+
description: property.description,
158+
isDeprecated: property.isDeprecated,
159+
deprecationMessage: property.deprecationMessage,
160+
type,
161+
isDictParam: type.startsWith('Dict'),
162+
isObject: nestedClassName != null && property.format === 'object',
163+
isObjectList: nestedClassName != null && property.format === 'list',
164+
}
165+
})
166+
167+
const resourceProperties = buildProperties(properties)
94168
return {
95169
className,
96170
description,
@@ -100,18 +174,8 @@ export const getResourceLayoutContexts = (
100174
// module always matches the dataclass it exports (e.g. the "event"
101175
// resource becomes SeamEvent in seam_event.py).
102176
moduleName: snakeCase(className),
103-
properties: properties.map((property) => {
104-
const type = mapPropertyToPythonType(property)
105-
return {
106-
name: property.name,
107-
description: property.description,
108-
isDeprecated: property.isDeprecated,
109-
deprecationMessage: property.deprecationMessage,
110-
type,
111-
isDictParam:
112-
type.startsWith('Dict') || property.name === 'properties',
113-
}
114-
}),
177+
nestedClasses: [...nestedClasses.values()],
178+
properties: resourceProperties,
115179
}
116180
})
117181
.sort((a, b) => (a.moduleName < b.moduleName ? -1 : 1))

codegen/lib/python-type.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,14 @@ export const mapParameterToPythonType = (parameter: Parameter): string => {
2121
return mapScalarFormatToPythonType(parameter.format)
2222
}
2323

24-
export const mapPropertyToPythonType = (property: Property): string => {
24+
export const mapPropertyToPythonType = (
25+
property: Property,
26+
nestedClassName?: string,
27+
): string => {
2528
if (property.format === 'list') {
26-
return `List[${mapListItemFormatToPythonType(property.itemFormat)}]`
29+
return `List[${
30+
nestedClassName ?? mapListItemFormatToPythonType(property.itemFormat)
31+
}]`
2732
}
2833

2934
if (property.format === 'number') {
@@ -36,6 +41,10 @@ export const mapPropertyToPythonType = (property: Property): string => {
3641
return 'List[Dict[str, Any]]'
3742
}
3843

44+
if (property.format === 'object' && nestedClassName != null) {
45+
return nestedClassName
46+
}
47+
3948
return mapScalarFormatToPythonType(property.format)
4049
}
4150

justfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ default: build
1111
poetry run pylint ./seam ./test
1212
poetry run black --check .
1313
poetry run rstcheck README.rst
14+
poetry run mypy seam/resources --disable-error-code=arg-type --disable-error-code=import-not-found
1415

1516
@test:
1617
poetry run pytest --cov=./seam

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ readme = "README.rst"
88
homepage = "https://github.com/seamapi/python"
99
repository = "https://github.com/seamapi/python"
1010
exclude = ["**/*_test.py"]
11+
include = ["seam/py.typed"]
1112

1213
[tool.poetry.dependencies]
1314
python = "^3.10.0"
@@ -23,6 +24,7 @@ pytest-cov = "^5.0.0"
2324
pytest-runner = "^6.0.0"
2425
pytest-watch = "^4.2.0"
2526
rstcheck = "^6.1.2"
27+
mypy = "^1.17.0"
2628

2729
[build-system]
2830
requires = ["poetry>=1.8"]

seam/py.typed

Whitespace-only changes.

0 commit comments

Comments
 (0)