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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions packages/openapi-python/src/config/output/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { BaseOutput, BaseUserOutput, UserPostProcessor } from '@hey-api/sha

import type { PostProcessorPreset } from './postprocess';

export type PythonVersion = '3.9' | '3.10' | '3.11' | '3.12' | '3.13';

export type UserOutput = BaseUserOutput<'.py'> & {
/**
* Post-processing commands to run on the output folder, executed in order.
Expand All @@ -21,6 +23,12 @@ export type UserOutput = BaseUserOutput<'.py'> & {
* @default false
*/
preferExportAll?: boolean;
/**
* Minimum Python version to target.
*
* @default '3.9'
*/
pythonVersion?: PythonVersion;
};

export type Output = BaseOutput<'.py'> & {
Expand All @@ -29,4 +37,6 @@ export type Output = BaseOutput<'.py'> & {
* instead of named exports.
*/
preferExportAll: boolean;
/** Minimum Python version to target. */
pythonVersion: PythonVersion;
};
6 changes: 3 additions & 3 deletions packages/openapi-python/src/plugins/@hey-api/sdk/v1/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface OperationItem {

export const source = globalThis.Symbol('@hey-api/python-sdk');

function attachComment<T extends ReturnType<typeof $.func>>(args: {
function attachComment<T extends ReturnType<typeof $.method>>(args: {
node: T;
operation: IR.OperationObject;
plugin: HeyApiSdkPlugin['Instance'];
Expand Down Expand Up @@ -62,7 +62,7 @@ function createFnSymbol(
function childToNode(
resource: StructureNode,
plugin: HeyApiSdkPlugin['Instance'],
): ReadonlyArray<ReturnType<typeof $.func>> {
): ReadonlyArray<ReturnType<typeof $.method>> {
const refChild = plugin.referenceSymbol(createShellMeta(resource));
const memberNameStr = toCase(
refChild.name,
Expand Down Expand Up @@ -110,7 +110,7 @@ export function createShell(plugin: HeyApiSdkPlugin['Instance']): StructureShell
};
}

function implementFn<T extends ReturnType<typeof $.func>>(args: {
function implementFn<T extends ReturnType<typeof $.method>>(args: {
node: T;
operation: IR.OperationObject;
plugin: HeyApiSdkPlugin['Instance'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export class PydanticEnumDsl extends Mixed {
cls.extends(plugin.symbols.enum.Enum);

for (const m of this.members) {
cls.do($.var(m.name).assign($.literal(m.value)));
cls.do($.field(m.name).assign($.literal(m.value)));
}

this._dsl = cls;
Expand Down
10 changes: 5 additions & 5 deletions packages/openapi-python/src/plugins/pydantic/dsl/decl/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { VarType } from '../../../../py-dsl';
import { $, PyDsl } from '../../../../py-dsl';
import type { CallCallee } from '../../../../py-dsl/expr/call';
import { OptionalMixin } from '../../../../py-dsl/mixins/optional';
import { safeRuntimeName } from '../../../../py-dsl/utils/name';
import { safeKeywordName } from '../../../../py-dsl/utils/name';
import type { PydanticPlugin } from '../../types';
import { ConstraintsMixin } from '../mixins/constraints';
import { literalize } from '../utils/literal';
Expand All @@ -23,7 +23,7 @@ export class PydanticFieldDsl extends Mixed {
private _default: unknown;
private _defaultFactory?: string;
private _description?: string;
private _dsl?: ReturnType<typeof $.var>;
private _dsl?: ReturnType<typeof $.field>;
private _title?: string;
private _type?: VarType;

Expand Down Expand Up @@ -63,14 +63,14 @@ export class PydanticFieldDsl extends Mixed {
return this;
}

_build(): ReturnType<typeof $.var> {
_build(): ReturnType<typeof $.field> {
if (this._dsl) return this._dsl;

const { plugin } = this;

const name = String(fromRef(this.name));
const snake = toCase(name, 'snake_case');
const safe = safeRuntimeName(snake);
const safe = safeKeywordName(snake);
const runtimeName = safe;
const needsAlias = runtimeName !== name;
const alias = this._alias ?? (needsAlias ? name : undefined);
Expand All @@ -83,7 +83,7 @@ export class PydanticFieldDsl extends Mixed {
type = $(plugin.symbols.typing.Optional).slice(this._type);
}

const stmt = $.var(plugin.symbol(runtimeName)).$if(type, (v, t) => v.type(t));
const stmt = $.field(plugin.symbol(runtimeName)).$if(type, (v, t) => v.type(t));

if (
this._defaultFactory !== undefined ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export class PydanticModelDsl extends Mixed {
.do(...this._fields.map((f) => f._build()))
.$if(this._configKwargs.length, (c) =>
c.do(
$.var(identifiers.model_config).assign(
$.field(identifiers.model_config).assign(
$(plugin.symbols.ConfigDict).call(...this._configKwargs.map(([k, v]) => $.kwarg(k, v))),
),
),
Expand Down
48 changes: 46 additions & 2 deletions packages/openapi-python/src/py-compiler/printer.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,59 @@
import type { PyNode } from './nodes/base';
import { PyNodeKind } from './nodes/kinds';

export type QuoteStyle = 'single' | 'double';
export type QuoteFallback = 'avoid-escape' | 'escape';

export interface PyPrinterOptions {
/**
* Number of spaces per indentation level.
*
* @default 4
*/
indentSize?: number;
/**
* How to handle strings that contain the preferred quote character.
* - `'avoid-escape'`: switch to the alternative quote style to avoid
* escaping, unless the string contains both quote characters
* - `'escape'`: always use the preferred quote style, escaping conflicts
* with a backslash
*
* @default 'avoid-escape'
*/
quoteConflict?: QuoteFallback;
/**
* Preferred string quote character.
*
* @default 'double'
*/
quoteStyle?: QuoteStyle;
}

const DEFAULT_INDENT_SIZE = 4;
const PARAMS_MULTILINE_THRESHOLD = 3;

export function createPrinter(options?: PyPrinterOptions) {
const indentSize = options?.indentSize ?? DEFAULT_INDENT_SIZE;
const quoteStyle = options?.quoteStyle ?? 'double';
const quoteConflict = options?.quoteConflict ?? 'avoid-escape';

function createStringLiteral(value: string): string {
const preferred = quoteStyle === 'double' ? '"' : "'";
const alternative = quoteStyle === 'double' ? "'" : '"';

const hasPreferred = value.includes(preferred);
const hasAlternative = value.includes(alternative);

if (quoteConflict === 'escape' || (hasPreferred && hasAlternative)) {
return `${preferred}${value.replaceAll(preferred, `\\${preferred}`)}${preferred}`;
}

if (hasPreferred && !hasAlternative) {
return `${alternative}${value}${alternative}`;
}

return `${preferred}${value}${preferred}`;
}

let indentLevel = 0;

Expand Down Expand Up @@ -171,7 +215,7 @@ export function createPrinter(options?: PyPrinterOptions) {
const children = node.parts.map((part) =>
typeof part === 'string' ? part : `{${printNode(part)}}`,
);
parts.push(`f"${children.join('')}"`);
parts.push(`f${createStringLiteral(children.join(''))}`);
break;
}

Expand Down Expand Up @@ -301,7 +345,7 @@ export function createPrinter(options?: PyPrinterOptions) {

case PyNodeKind.Literal:
if (typeof node.value === 'string') {
parts.push(`"${node.value}"`);
parts.push(createStringLiteral(node.value));
} else if (typeof node.value === 'boolean') {
parts.push(node.value ? 'True' : 'False');
} else if (node.value === null) {
Expand Down
6 changes: 4 additions & 2 deletions packages/openapi-python/src/py-dsl/decl/class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ import type { AnalysisContext, NodeName, Ref } from '@hey-api/codegen-core';
import { isSymbol, ref } from '@hey-api/codegen-core';

import { py } from '../../py-compiler';
import type { MaybePyDsl } from '../base';
import { PyDsl } from '../base';
import type { DocPyDsl } from '../layout/doc';
import { NewlinePyDsl } from '../layout/newline';
import { DecoratorMixin } from '../mixins/decorator';
import { DocMixin } from '../mixins/doc';
import { LayoutMixin } from '../mixins/layout';
import { ExportMixin } from '../mixins/modifiers';
import { safeRuntimeName } from '../utils/name';
import type { FieldPyDsl } from './field';
import type { MethodPyDsl } from './method';

type Body = Array<MaybePyDsl<py.Statement>>;
type Body = Array<DocPyDsl | FieldPyDsl | MethodPyDsl | NewlinePyDsl>;

const Mixed = DecoratorMixin(DocMixin(ExportMixin(LayoutMixin(PyDsl<py.ClassDeclaration>))));

Expand Down
73 changes: 73 additions & 0 deletions packages/openapi-python/src/py-dsl/decl/field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { AnalysisContext, NodeName, Ref } from '@hey-api/codegen-core';
import { isSymbol, ref } from '@hey-api/codegen-core';

import { py } from '../../py-compiler';
import { PyDsl } from '../base';
import { ValueMixin } from '../mixins/value';
import { safeKeywordName } from '../utils/name';

const Mixed = ValueMixin(PyDsl<py.Assignment>);

export type FieldType = NodeName | PyDsl<py.Expression>;

export class FieldPyDsl extends Mixed {
readonly '~dsl' = 'FieldPyDsl';
override readonly nameSanitizer = safeKeywordName;

protected _type?: Ref<FieldType>;

constructor(name?: NodeName) {
super();
if (name) this.name.set(name);
if (isSymbol(name)) {
name.setKind('var');
}
}

override analyze(ctx: AnalysisContext): void {
super.analyze(ctx);
ctx.analyze(this.name);
ctx.analyze(this._type);
}

/** Returns true when all required builder calls are present. */
get isValid(): boolean {
return !this.missingRequiredCalls().length;
}

/** Sets the type annotation for the field. */
type(type: FieldType): this {
this._type = ref(type);
return this;
}

override toAst() {
this.$validate();
const target = this.$node(this.name)!;
const type = this.$type();
const value = this.$value();

return py.factory.createAssignment(target, type, value);
}

$validate(): asserts this {
const missing = this.missingRequiredCalls();
if (!missing.length) return;
throw new Error(`Field declaration missing ${missing.join(' and ')}`);
}

protected $type(): py.Expression | undefined {
return this.$node(this._type);
}

private missingRequiredCalls(): ReadonlyArray<string> {
const missing: Array<string> = [];
if (!this.$node(this.name)) missing.push('name');
const hasAnnotation = this.$type();
const hasValue = this.$value();
if (!hasAnnotation && !hasValue) {
missing.push('.type() or .assign()');
}
return missing;
}
}
11 changes: 3 additions & 8 deletions packages/openapi-python/src/py-dsl/decl/func.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AnalysisContext, NodeName, NodeNameSanitizer } from '@hey-api/codegen-core';
import type { AnalysisContext, NodeName } from '@hey-api/codegen-core';
import { isSymbol } from '@hey-api/codegen-core';

import { py } from '../../py-compiler';
Expand All @@ -22,15 +22,10 @@ const Mixed = AsyncMixin(

export class FuncPyDsl extends Mixed {
readonly '~dsl' = 'FuncPyDsl';
override readonly nameSanitizer: NodeNameSanitizer;
override readonly nameSanitizer = safeRuntimeName;

constructor(
name: NodeName,
fn?: (f: FuncPyDsl) => void,
options?: { nameSanitizer?: NodeNameSanitizer },
) {
constructor(name: NodeName, fn?: (f: FuncPyDsl) => void) {
super();
this.nameSanitizer = options?.nameSanitizer ?? safeRuntimeName;
this.name.set(name);
if (isSymbol(name)) {
name.setKind('function');
Expand Down
73 changes: 73 additions & 0 deletions packages/openapi-python/src/py-dsl/decl/method.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { AnalysisContext, NodeName } from '@hey-api/codegen-core';
import { isSymbol } from '@hey-api/codegen-core';

import { py } from '../../py-compiler';
import { PyDsl } from '../base';
import { DecoratorMixin } from '../mixins/decorator';
import { DoMixin } from '../mixins/do';
import { DocMixin } from '../mixins/doc';
import { LayoutMixin } from '../mixins/layout';
import { AsyncMixin } from '../mixins/modifiers';
import { ParamMixin } from '../mixins/param';
import { ReturnsMixin } from '../mixins/returns';
import { safeKeywordName } from '../utils/name';

const Mixed = AsyncMixin(
DecoratorMixin(
DocMixin(DoMixin(LayoutMixin(ParamMixin(ReturnsMixin(PyDsl<py.FunctionDeclaration>))))),
),
);

export class MethodPyDsl extends Mixed {
readonly '~dsl' = 'MethodPyDsl';
override readonly nameSanitizer = safeKeywordName;

constructor(name: NodeName, fn?: (f: MethodPyDsl) => void) {
super();
this.name.set(name);
if (isSymbol(name)) {
name.setKind('function');
}
fn?.(this);
}

override analyze(ctx: AnalysisContext): void {
ctx.pushScope();
try {
super.analyze(ctx);
ctx.analyze(this.name);
} finally {
ctx.popScope();
}
}

/** Returns true when all required builder calls are present. */
get isValid(): boolean {
return !this.missingRequiredCalls().length;
}

override toAst() {
this.$validate();
return py.factory.createFunctionDeclaration(
this.name.toString(),
this.$params(),
this.$returns(),
this.$do(),
this.$decorators(),
this.$docs(),
this.modifiers,
);
}

$validate(): asserts this {
const missing = this.missingRequiredCalls();
if (!missing.length) return;
throw new Error(`Method declaration missing ${missing.join(' and ')}`);
}

private missingRequiredCalls(): ReadonlyArray<string> {
const missing: Array<string> = [];
if (!this.name.toString()) missing.push('name');
return missing;
}
}
Loading
Loading