Skip to content

Commit 9281129

Browse files
razor-xseambot
andauthored
feat: Add docstrings to generated Python APIs (#594)
* Add API docstrings to generated Python code * Convert generated doc links to reStructuredText * Rely on Python annotations for docstring types * ci: Generate code * Move more layout logic to hbs * Fix lockfile * ci: Generate code * Add resources to gitattributes --------- Co-authored-by: Seam Bot <seambot@getseam.com>
1 parent 6f40e07 commit 9281129

87 files changed

Lines changed: 6196 additions & 634 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
seam/resources/** linguist-generated
12
seam/routes/** linguist-generated

codegen/layouts/partials/abstract-route-class.hbs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
class {{className}}(abc.ABC):
2+
{{#if isDeprecated}}
3+
""".. deprecated::
4+
This route is deprecated."""
5+
{{/if}}
26
{{#if showPass}}
37
pass
48
{{/if}}
@@ -12,6 +16,7 @@ class {{className}}(abc.ABC):
1216
{{#each methods}}
1317

1418
@abc.abstractmethod
15-
def {{name}}(self,{{#if hasParams}} *,{{/if}} {{signatureParams}}) -> {{returnType}}:
19+
def {{> method-signature}}:
20+
"""{{> method-docstring}}"""
1621
raise NotImplementedError()
1722
{{/each}}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{{{indent (pythonDoc description) 8}}}{{#each params}}
2+
3+
:param {{name}}: {{#if isDeprecated}}Deprecated{{#if deprecationMessage}}: {{{indent (pythonDoc deprecationMessage) 8}}}{{else}}.{{/if}}{{#if (pythonDoc description)}} {{/if}}{{/if}}{{{indent (pythonDoc description) 8}}}{{/each}}{{#if (eq returnType "ActionAttempt")}}
4+
5+
:param wait_for_action_attempt: Whether, and for how long, to wait for the action attempt to finish.{{/if}}{{#unless (eq returnType "None")}}
6+
7+
:returns: {{{indent (pythonDoc responseDescription) 8}}}{{/unless}}{{#if isDeprecated}}
8+
9+
.. deprecated::
10+
{{#if (pythonDoc deprecationMessage)}}{{{indent (pythonDoc deprecationMessage) 8}}}{{else}}This method is deprecated.{{/if}}{{/if}}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{{name}}(self{{#if params}}, *{{else}}{{#if (eq returnType "ActionAttempt")}}, *{{/if}}{{/if}}{{#each params}}, {{name}}: {{#if required}}{{type}}{{else}}Optional[{{type}}] = None{{/if}}{{/each}}{{#if (eq returnType "ActionAttempt")}}, wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = None{{/if}}) -> {{returnType}}
Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
@dataclass
22
class {{className}}:
3+
"""{{{indent (pythonDoc description) 4}}}{{#each properties}}
4+
5+
: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}}
6+
7+
.. deprecated::
8+
{{#if (pythonDoc deprecationMessage)}}{{{indent (pythonDoc deprecationMessage) 4}}}{{else}}This resource is deprecated.{{/if}}{{/if}}"""
39
{{#each properties}}
4-
{{safeName}}: {{type}}
10+
{{pythonIdentifier name}}: {{type}}
511
{{/each}}
612

713
@staticmethod
814
def from_dict(d: Dict[str, Any]):
915
return {{className}}(
1016
{{#each properties}}
11-
{{safeName}}={{#if isDictParam}}DeepAttrDict({{/if}}d.get("{{name}}", None){{#if isDictParam}}){{/if}},
17+
{{pythonIdentifier name}}={{#if isDictParam}}DeepAttrDict({{/if}}d.get("{{name}}", None){{#if isDictParam}}){{/if}},
1218
{{/each}}
1319
)
Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
def {{name}}(self,{{#if hasParams}} *,{{/if}} {{signatureParams}}) -> {{returnType}}:
1+
def {{> method-signature}}:
2+
"""{{> method-docstring}}"""
23
json_payload = {}
34

45
{{#each params}}
56
if {{name}} is not None:
67
json_payload["{{name}}"] = {{name}}
78
{{/each}}
89

9-
{{#unless returnsNone}}res = {{/unless}}self.client.post("{{path}}", json=json_payload)
10-
{{#if pollsActionAttempt}}
10+
{{#unless (eq returnType "None")}}res = {{/unless}}self.client.post("{{path}}", json=json_payload)
11+
{{#if (eq returnType "ActionAttempt")}}
1112

1213
wait_for_action_attempt = (
1314
self.defaults.get("wait_for_action_attempt")
@@ -20,13 +21,13 @@
2021
action_attempt=ActionAttempt.from_dict(res["action_attempt"]),
2122
wait_for_action_attempt=wait_for_action_attempt
2223
)
23-
{{else if returnsNone}}
24+
{{else if (eq returnType "None")}}
2425

2526
return None
26-
{{else if isList}}
27+
{{else if (isListType returnType)}}
2728

28-
return [{{itemType}}.from_dict(item) for item in {{resAccessor}}]
29+
return [{{listItemType returnType}}.from_dict(item) for item in res{{#each returnPath}}["{{this}}"]{{/each}}]
2930
{{else}}
3031

31-
return {{returnType}}.from_dict({{resAccessor}})
32+
return {{returnType}}.from_dict(res{{#each returnPath}}["{{this}}"]{{/each}})
3233
{{/if}}

codegen/layouts/route.hbs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from typing import Optional, Any, List, Dict, Union
22
import abc
33
from ..client import SeamHttpClient
4-
{{#if resourceImportList}}
5-
from ..resources import ({{resourceImportList}})
4+
{{#if resourceClasses}}
5+
from ..resources import ({{#each resourceClasses}}{{this}}{{#unless @last}},{{/unless}}{{/each}})
66
{{/if}}
77
{{#each childClasses}}
88
from .{{module}} import {{abstractClassName}}, {{className}}
@@ -16,6 +16,10 @@ from ..modules.action_attempts import resolve_action_attempt
1616

1717

1818
class {{className}}({{abstractClassName}}):
19+
{{#if isDeprecated}}
20+
""".. deprecated::
21+
This route is deprecated."""
22+
{{/if}}
1923
def __init__(self, client: SeamHttpClient, defaults: Dict[str, Any]):
2024
self.client = client
2125
self.defaults = defaults

codegen/lib/class-model.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
// Holds the class and method data assembled by the routes plugin; all string
2-
// serialization lives in the Handlebars layouts and their context builders.
2+
// serialization lives in the Handlebars layouts and helpers.
33

44
export interface ClassMethodParameter {
55
name: string
66
type: string
7+
description: string
8+
isDeprecated: boolean
9+
deprecationMessage: string
710
position?: number | undefined
811
required?: boolean | undefined
912
}
1013

1114
export interface ClassMethod {
1215
methodName: string
1316
path: string
17+
description: string
18+
responseDescription: string
19+
isDeprecated: boolean
20+
deprecationMessage: string
1421
parameters: ClassMethodParameter[]
1522
returnPath: string[]
1623
returnResource: string
@@ -24,6 +31,7 @@ export interface ChildClassIdentifier {
2431
export interface ClassModel {
2532
name: string
2633
namespace: string
34+
isDeprecated: boolean
2735
methods: ClassMethod[]
2836
childClassIdentifiers: ChildClassIdentifier[]
2937
}

codegen/lib/handlebars-helpers.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,58 @@
1+
const PYTHON_KEYWORDS = new Set([
2+
'False',
3+
'None',
4+
'True',
5+
'and',
6+
'as',
7+
'assert',
8+
'async',
9+
'await',
10+
'break',
11+
'class',
12+
'continue',
13+
'def',
14+
'del',
15+
'elif',
16+
'else',
17+
'except',
18+
'finally',
19+
'for',
20+
'from',
21+
'global',
22+
'if',
23+
'import',
24+
'in',
25+
'is',
26+
'lambda',
27+
'nonlocal',
28+
'not',
29+
'or',
30+
'pass',
31+
'raise',
32+
'return',
33+
'try',
34+
'while',
35+
'with',
36+
'yield',
37+
])
38+
139
export const identity = (x: unknown): unknown => x
40+
41+
// Blueprint descriptions use Markdown, while Python docstrings use
42+
// Sphinx/reStructuredText fields.
43+
export const pythonDoc = (value: string): string =>
44+
value
45+
.trim()
46+
.replaceAll('"""', '\\"\\"\\"')
47+
.replaceAll(/(?<!`)`([^`\n]+)`(?!`)/g, '``$1``')
48+
.replaceAll(/\[([^\]]+)]\(([^)]+)\)/g, '`$1 <$2>`_')
49+
50+
export const indent = (value: string, spaces: number): string =>
51+
value.replaceAll('\n', `\n${' '.repeat(spaces)}`)
52+
53+
export const pythonIdentifier = (name: string): string =>
54+
PYTHON_KEYWORDS.has(name) ? `${name}_` : name
55+
56+
export const isListType = (type: string): boolean => type.startsWith('List[')
57+
58+
export const listItemType = (type: string): string => type.slice(5, -1)

codegen/lib/layouts/resources.ts

Lines changed: 47 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -8,57 +8,17 @@ import { pascalCase, snakeCase } from 'change-case'
88
import { convertCustomResourceName } from '../custom-resource-name-conversions.js'
99
import { mapPropertyToPythonType } from '../python-type.js'
1010

11-
// Python hard keywords cannot be used as identifiers. When a property name
12-
// collides with one (e.g. "from"), the dataclass field and keyword argument
13-
// are suffixed with an underscore while the original name is preserved as the
14-
// dict key.
15-
const PYTHON_KEYWORDS = new Set([
16-
'False',
17-
'None',
18-
'True',
19-
'and',
20-
'as',
21-
'assert',
22-
'async',
23-
'await',
24-
'break',
25-
'class',
26-
'continue',
27-
'def',
28-
'del',
29-
'elif',
30-
'else',
31-
'except',
32-
'finally',
33-
'for',
34-
'from',
35-
'global',
36-
'if',
37-
'import',
38-
'in',
39-
'is',
40-
'lambda',
41-
'nonlocal',
42-
'not',
43-
'or',
44-
'pass',
45-
'raise',
46-
'return',
47-
'try',
48-
'while',
49-
'with',
50-
'yield',
51-
])
52-
53-
const toSafeIdentifier = (name: string): string =>
54-
PYTHON_KEYWORDS.has(name) ? `${name}_` : name
55-
5611
export interface ResourceLayoutContext {
5712
className: string
5813
moduleName: string
14+
description: string
15+
isDeprecated: boolean
16+
deprecationMessage: string
5917
properties: Array<{
6018
name: string
61-
safeName: string
19+
description: string
20+
isDeprecated: boolean
21+
deprecationMessage: string
6222
type: string
6323
isDictParam: boolean
6424
}>
@@ -84,29 +44,58 @@ const mergeResourceProperties = (resources: Resource[]): Property[] => {
8444
export const getResourceLayoutContexts = (
8545
blueprint: Blueprint,
8646
): ResourceLayoutContext[] => {
87-
const models = new Map<string, Property[]>()
47+
const models = new Map<
48+
string,
49+
{
50+
properties: Property[]
51+
description: string
52+
isDeprecated: boolean
53+
deprecationMessage: string
54+
}
55+
>()
8856

8957
for (const resource of blueprint.resources) {
90-
models.set(resource.resourceType, resource.properties)
58+
models.set(resource.resourceType, resource)
9159
}
9260

9361
// The event and action attempt variants merge into a single dataclass with
9462
// the union of the variant properties, overriding the base resource schema.
95-
models.set(
96-
'action_attempt',
97-
mergeResourceProperties(blueprint.actionAttempts),
98-
)
99-
models.set('event', mergeResourceProperties(blueprint.events))
63+
const actionAttemptModel = models.get('action_attempt')
64+
models.set('action_attempt', {
65+
properties: mergeResourceProperties(blueprint.actionAttempts),
66+
description:
67+
actionAttemptModel?.description ??
68+
'An attempt to perform an action in the Seam API.',
69+
isDeprecated: actionAttemptModel?.isDeprecated ?? false,
70+
deprecationMessage: actionAttemptModel?.deprecationMessage ?? '',
71+
})
72+
const eventModel = models.get('event')
73+
models.set('event', {
74+
properties: mergeResourceProperties(blueprint.events),
75+
description: eventModel?.description ?? 'An event emitted by the Seam API.',
76+
isDeprecated: eventModel?.isDeprecated ?? false,
77+
deprecationMessage: eventModel?.deprecationMessage ?? '',
78+
})
10079

10180
if (blueprint.pagination != null) {
102-
models.set('pagination', blueprint.pagination.properties)
81+
models.set('pagination', {
82+
properties: blueprint.pagination.properties,
83+
description: blueprint.pagination.description,
84+
isDeprecated: false,
85+
deprecationMessage: '',
86+
})
10387
}
10488

10589
return [...models.entries()]
106-
.map(([name, properties]) => {
90+
.map(([name, model]) => {
91+
const { properties, description, isDeprecated, deprecationMessage } =
92+
model
10793
const className = pascalCase(convertCustomResourceName(name))
10894
return {
10995
className,
96+
description,
97+
isDeprecated,
98+
deprecationMessage,
11099
// Derived from the class name rather than the resource type so the
111100
// module always matches the dataclass it exports (e.g. the "event"
112101
// resource becomes SeamEvent in seam_event.py).
@@ -115,7 +104,9 @@ export const getResourceLayoutContexts = (
115104
const type = mapPropertyToPythonType(property)
116105
return {
117106
name: property.name,
118-
safeName: toSafeIdentifier(property.name),
107+
description: property.description,
108+
isDeprecated: property.isDeprecated,
109+
deprecationMessage: property.deprecationMessage,
119110
type,
120111
isDictParam:
121112
type.startsWith('Dict') || property.name === 'properties',

0 commit comments

Comments
 (0)