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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
# For more details, visit the project page:
# https://github.com/github/gitignore

# Codegen
CODEGEN.md

# Temporary development files
tmp

Expand Down Expand Up @@ -325,3 +328,6 @@ dist
# yalc
.yalc/
yalc.lock

# Codegen exceptions
!codegen/lib/
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Prettier exceptions

*.hbs
CODEGEN.md
1 change: 1 addition & 0 deletions codegen/content/CODEGEN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Codegen
1 change: 1 addition & 0 deletions codegen/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default null
1 change: 1 addition & 0 deletions codegen/layouts/default.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{contents}}
17 changes: 17 additions & 0 deletions codegen/layouts/models.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from typing import Any, Dict, List, Optional, Union
from typing_extensions import Self
import abc
from dataclasses import dataclass
from ..utils.deep_attr_dict import DeepAttrDict

{{#each resources}}

{{> model-dataclass}}
{{/each}}
{{#each abstractClasses}}

{{> abstract-route-class}}
{{/each}}


{{> abstract-routes}}
17 changes: 17 additions & 0 deletions codegen/layouts/partials/abstract-route-class.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class {{className}}(abc.ABC):
{{#if showPass}}
pass
{{/if}}
{{#each childProperties}}

@property
@abc.abstractmethod
def {{namespace}}(self) -> {{abstractClassName}}:
raise NotImplementedError()
{{/each}}
{{#each methods}}

@abc.abstractmethod
def {{name}}(self,{{#if hasParams}} *,{{/if}} {{signatureParams}}) -> {{returnType}}:
raise NotImplementedError()
{{/each}}
5 changes: 5 additions & 0 deletions codegen/layouts/partials/abstract-routes.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@dataclass
class AbstractRoutes(abc.ABC):
{{#each routesNamespaces}}
{{namespace}}: {{abstractClassName}}
{{/each}}
13 changes: 13 additions & 0 deletions codegen/layouts/partials/model-dataclass.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@dataclass
class {{className}}:
{{#each properties}}
{{name}}: {{type}}
{{/each}}

@staticmethod
def from_dict(d: Dict[str, Any]):
return {{className}}(
{{#each properties}}
{{name}}={{#if isDictParam}}DeepAttrDict({{/if}}d.get("{{name}}", None){{#if isDictParam}}){{/if}},
{{/each}}
)
32 changes: 32 additions & 0 deletions codegen/layouts/partials/route-method.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
def {{name}}(self,{{#if hasParams}} *,{{/if}} {{signatureParams}}) -> {{returnType}}:
json_payload = {}

{{#each params}}
if {{name}} is not None:
json_payload["{{name}}"] = {{name}}
{{/each}}

{{#unless returnsNone}}res = {{/unless}}self.client.post("{{path}}", json=json_payload)
{{#if pollsActionAttempt}}

wait_for_action_attempt = (
self.defaults.get("wait_for_action_attempt")
if wait_for_action_attempt is None
else wait_for_action_attempt
)

return resolve_action_attempt(
client=self.client,
action_attempt=ActionAttempt.from_dict(res["action_attempt"]),
wait_for_action_attempt=wait_for_action_attempt
)
{{else if returnsNone}}

return None
{{else if isList}}

return [{{itemType}}.from_dict(item) for item in {{resAccessor}}]
{{else}}

return {{returnType}}.from_dict({{resAccessor}})
{{/if}}
30 changes: 30 additions & 0 deletions codegen/layouts/route.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from typing import Optional, Any, List, Dict, Union
from ..client import SeamHttpClient
from .models import ({{modelImportList}})
{{#each childClasses}}
from .{{module}} import {{className}}
{{else}}

{{/each}}
{{#if importResolveActionAttempt}}
from ..modules.action_attempts import resolve_action_attempt
{{/if}}


class {{className}}({{abstractClassName}}):
def __init__(self, client: SeamHttpClient, defaults: Dict[str, Any]):
self.client = client
self.defaults = defaults
{{#each childClasses}}
self._{{namespace}} = {{className}}(client=client, defaults=defaults)
{{/each}}
{{#each childClasses}}

@property
def {{namespace}}(self) -> {{className}}:
return self._{{namespace}}
{{/each}}
{{#each methods}}

{{> route-method}}
{{/each}}
13 changes: 13 additions & 0 deletions codegen/layouts/routes-index.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from typing import Any, Dict
from ..client import SeamHttpClient
from .models import AbstractRoutes
{{#each namespaces}}
from .{{namespace}} import {{className}}
{{/each}}


class Routes(AbstractRoutes):
def __init__(self, client: SeamHttpClient, defaults: Dict[str, Any]):
{{#each namespaces}}
self.{{namespace}} = {{className}}(client=client, defaults=defaults)
{{/each}}
49 changes: 49 additions & 0 deletions codegen/lib/class-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Ported from @seamapi/nextlove-sdk-generator lib/generate-python-sdk/class-file.ts.
// Holds the data previously carried by the nextlove ClassFile; all string
// serialization moved to the Handlebars layouts and their context builders.

export interface ClassMethodParameter {
name: string
type: string
position?: number | undefined
required?: boolean | undefined
}

export interface ClassMethod {
methodName: string
path: string
parameters: ClassMethodParameter[]
returnPath: Array<string | undefined>
returnResource: string
}

export interface ChildClassIdentifier {
className: string
namespace: string
}

export interface ClassModel {
name: string
namespace: string
methods: ClassMethod[]
childClassIdentifiers: ChildClassIdentifier[]
}

// Verbatim port of the nextlove parameter comparator. The original
// expression `(a.position ?? a.required ? 1000 : 9999)` parses as
// `(a.position ?? a.required) ? 1000 : 9999`, so a parameter with
// position 0 is falsy and lands in the 9999 tier together with the
// optional parameters. Combined with a stable sort this yields: required
// parameters first (in schema order), then everything else (in schema
// order).
// TODO: Fix the operator precedence so position sorts a parameter first
// as originally intended, once generated output is allowed to change.
// Until then, do not "fix" it: the generated output must stay identical.
export const sortClassMethodParameters = (
parameters: ClassMethodParameter[],
): ClassMethodParameter[] =>
[...parameters].sort(
(a, b) =>
((a.position ?? a.required) ? 1000 : 9999) -
((b.position ?? b.required) ? 1000 : 9999),
)
5 changes: 5 additions & 0 deletions codegen/lib/custom-resource-name-conversions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Ported from @seamapi/nextlove-sdk-generator lib/custom-resource-name-conversions.ts.

export function convertCustomResourceName(responseType: string): string {
return responseType === 'event' ? 'seam_event' : responseType
}
23 changes: 23 additions & 0 deletions codegen/lib/endpoint-rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// TEMPORARY: Verbatim port of @seamapi/nextlove-sdk-generator
// lib/endpoint-rules.ts. These lists only preserve legacy generated output:
// the ignored paths reproduce the previous endpoint filtering, and the
// deprecated action attempt list keeps those endpoints returning None.
// TODO: Delete this file once generated output is allowed to change; filter
// on blueprint undocumented flags instead and let the deprecated endpoints
// return their real response types.

export const endpointsReturningDeprecatedActionAttempt = [
'/access_codes/delete',
'/access_codes/unmanaged/delete',
'/access_codes/update',
'/noise_sensors/noise_thresholds/delete',
'/noise_sensors/noise_thresholds/update',
'/thermostats/climate_setting_schedules/update',
]

export const ignoredEndpointPaths = [
'/health',
'/health/get_health',
'/health/get_service_health',
'/health/service/[service_name]',
]
1 change: 1 addition & 0 deletions codegen/lib/handlebars-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const identity = (x: unknown): unknown => x
7 changes: 7 additions & 0 deletions codegen/lib/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { handlebarsHelpers } from '@seamapi/smith'

import * as customHelpers from './handlebars-helpers.js'

export const helpers = { ...handlebarsHelpers, ...customHelpers }

export * from './routes.js'
94 changes: 94 additions & 0 deletions codegen/lib/layouts/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Builds the template context for seam/routes/models.py.
// Mirrors the models.py assembly in the nextlove generate-python-sdk.ts plus
// ClassFile#serializeToAbstractClassWithoutImports.

import { pascalCase } from 'change-case'

import type { ClassModel } from '../class-model.js'
import { convertCustomResourceName } from '../custom-resource-name-conversions.js'
import { mapPythonType } from '../map-python-type.js'
import { flattenObjSchema } from '../openapi/flatten-obj-schema.js'
import type { ObjSchema, OpenapiSchema } from '../openapi/types.js'
import { getMethodLayoutContext } from './route.js'

export interface ModelsLayoutContext {
resources: Array<{
className: string
properties: Array<{ name: string; type: string; isDictParam: boolean }>
}>
abstractClasses: Array<{
className: string
showPass: boolean
childProperties: Array<{ namespace: string; abstractClassName: string }>
methods: Array<{
name: string
hasParams: boolean
signatureParams: string
returnType: string
}>
}>
routesNamespaces: Array<{ namespace: string; abstractClassName: string }>
}

export const setModelsLayoutContext = (
openapi: OpenapiSchema,
classMap: Map<string, ClassModel>,
topLevelNamespaces: string[],
): ModelsLayoutContext => {
// TODO: Use blueprint.resources, blueprint.events, and
// blueprint.actionAttempts once generated output is allowed to change.
// Blueprint currently omits some schemas (e.g. pagination and
// phone_registration), reorders others, and collapses integer to number,
// so the raw OpenAPI schemas are used to keep the output identical.
const resources = Object.entries(openapi.components.schemas)
.map(
([schemaName, schema]) =>
[schemaName, flattenObjSchema(schema as ObjSchema)] as [
string,
ObjSchema,
],
)
.map(([schemaName, schema]) => ({
className: pascalCase(convertCustomResourceName(schemaName)),
properties: Object.entries(schema.properties).map(
([name, propertySchema]) => {
const type = mapPythonType(propertySchema)
return {
name,
type,
isDictParam: type.startsWith('Dict') || name === 'properties',
}
},
),
}))

const abstractClasses = [...classMap.values()]
// Define classes without children first for parent-child referencing.
// Array#sort is stable, so ties keep class-map insertion order.
.sort(
(a, b) => a.childClassIdentifiers.length - b.childClassIdentifiers.length,
)
.map((cls) => ({
className: `Abstract${cls.name}`,
showPass:
cls.methods.length === 0 && cls.childClassIdentifiers.length === 0,
childProperties: cls.childClassIdentifiers.map((i) => ({
namespace: i.namespace,
abstractClassName: `Abstract${i.className}`,
})),
methods: cls.methods.map((method) => {
const { name, hasParams, signatureParams, returnType } =
getMethodLayoutContext(method)
return { name, hasParams, signatureParams, returnType }
}),
}))

return {
resources,
abstractClasses,
routesNamespaces: topLevelNamespaces.map((ns) => ({
namespace: ns,
abstractClassName: `Abstract${pascalCase(ns)}`,
})),
}
}
Loading
Loading