diff --git a/.changeset/blue-loops-fix.md b/.changeset/blue-loops-fix.md new file mode 100644 index 000000000..ff0ef2ad6 --- /dev/null +++ b/.changeset/blue-loops-fix.md @@ -0,0 +1,5 @@ +--- +"@hey-api/openapi-ts": patch +--- + +**config**: rename `exportFromIndex` option to `includeInEntry` diff --git a/.changeset/cold-ties-return.md b/.changeset/cold-ties-return.md new file mode 100644 index 000000000..aedc371e6 --- /dev/null +++ b/.changeset/cold-ties-return.md @@ -0,0 +1,9 @@ +--- +"@hey-api/openapi-ts": minor +--- + +**BREAKING:** **symbol**: replace `exportFrom` array with `getExportFromFilePath()` function + +### Updated Symbol interface + +The `exportFrom` property has been replaced with the `getExportFromFilePath()` function. This allows you to dynamically determine export paths based on symbol properties. This is a low-level feature, so you're most likely unaffected. diff --git a/.changeset/deep-buttons-repeat.md b/.changeset/deep-buttons-repeat.md new file mode 100644 index 000000000..deae64eb4 --- /dev/null +++ b/.changeset/deep-buttons-repeat.md @@ -0,0 +1,5 @@ +--- +"@hey-api/openapi-ts": patch +--- + +**config**: `includeInEntry` accepts function in addition to primitive value diff --git a/.changeset/forty-shoes-fetch.md b/.changeset/forty-shoes-fetch.md new file mode 100644 index 000000000..342942e5b --- /dev/null +++ b/.changeset/forty-shoes-fetch.md @@ -0,0 +1,5 @@ +--- +"@hey-api/openapi-ts": patch +--- + +**parser**: add `getExportFromFilePath()` hook diff --git a/.changeset/fresh-peaches-thank.md b/.changeset/fresh-peaches-thank.md new file mode 100644 index 000000000..50333f9a5 --- /dev/null +++ b/.changeset/fresh-peaches-thank.md @@ -0,0 +1,5 @@ +--- +"@hey-api/shared": patch +--- + +**parser**: add `getExportFromFilePath()` hook diff --git a/.changeset/heavy-waves-kneel.md b/.changeset/heavy-waves-kneel.md new file mode 100644 index 000000000..8a02f6495 --- /dev/null +++ b/.changeset/heavy-waves-kneel.md @@ -0,0 +1,5 @@ +--- +"@hey-api/shared": patch +--- + +**config**: `includeInEntry` accepts function in addition to primitive value diff --git a/.changeset/itchy-beds-start.md b/.changeset/itchy-beds-start.md new file mode 100644 index 000000000..87dc3cb48 --- /dev/null +++ b/.changeset/itchy-beds-start.md @@ -0,0 +1,9 @@ +--- +"@hey-api/codegen-core": minor +--- + +**BREAKING:** **symbol**: replace `exportFrom` array with `getExportFromFilePath()` function + +### Updated Symbol interface + +The `exportFrom` property has been replaced with the `getExportFromFilePath()` function. This allows you to dynamically determine export paths based on symbol properties. This is a low-level feature, so you're most likely unaffected. diff --git a/.changeset/slick-olives-doubt.md b/.changeset/slick-olives-doubt.md new file mode 100644 index 000000000..bdb6ccba6 --- /dev/null +++ b/.changeset/slick-olives-doubt.md @@ -0,0 +1,5 @@ +--- +"@hey-api/openapi-ts": patch +--- + +**types**: improve `defineConfig()` types diff --git a/.changeset/swift-rooms-see.md b/.changeset/swift-rooms-see.md new file mode 100644 index 000000000..0b18375a2 --- /dev/null +++ b/.changeset/swift-rooms-see.md @@ -0,0 +1,5 @@ +--- +"@hey-api/openapi-ts": patch +--- + +**config**: rename `output.indexFile` to `output.entryFile` diff --git a/.gitignore b/.gitignore index 6dd67d8dd..73cb9894a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .cache +.mypy_cache .idea .tmp junit.xml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..4b9b1c655 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md + +OpenAPI TypeScript is a CLI tool and library for generating TypeScript clients, SDKs, validators, and schemas from OpenAPI specifications. This is a monorepo built with pnpm workspaces, Turbo build orchestration, and TypeScript. + +## Quick Reference + +```bash +pnpm install # Install dependencies +pnpm build --filter="@hey-api/**" # Build packages only +pnpm build # Build everything (packages + examples + docs) +pnpm test # Run all tests +pnpm typecheck # Type check all packages +pnpm lint # Check formatting (oxfmt) + linting (eslint) +pnpm lint:fix # Auto-fix formatting and linting +pnpm format # Format with oxfmt +``` + +### Shortcuts + +```bash +pnpm tt -- @hey-api/openapi-ts # Test specific package +pnpm tw -- @hey-api/openapi-ts # Test watch specific package +pnpm tu -- @hey-api/openapi-ts # Update test snapshots +pnpm tb -- @hey-api/openapi-ts # Build specific package +pnpm ty -- @hey-api/openapi-ts # Typecheck specific package +``` + +### Development + +```bash +pnpm dev:ts # Watch mode for openapi-ts (runs from dev/) +pnpm dev:py # Watch mode for openapi-python (runs from dev/) +``` + +## Build Timing + +**Do not cancel build commands** - they take significant time: + +- `pnpm install`: ~1m 20s +- `pnpm build --filter="@hey-api/**"`: ~2m 15s +- `pnpm build` (full): ~5+ minutes +- `pnpm test`: ~1m 5s +- `pnpm typecheck`: ~1m 20s +- `pnpm lint`: ~35s + +Set timeouts accordingly (180s+ for builds, 120s+ for tests/typecheck). + +## Repository Structure + +``` +packages/ + openapi-ts/ # Main CLI tool and library + openapi-python/ # Python DSL generation + codegen-core/ # Core code generation utilities + shared/ # Cross-package utilities (migrating out) + types/ # Shared type definitions + custom-client/ # Custom HTTP client implementations + nuxt/ # Nuxt.js integration + vite-plugin/ # Vite plugin + config-vite-base/ # Shared Vite base configuration + openapi-ts-tests/ # Test utilities and snapshots +examples/ # 16+ framework-specific examples +docs/ # VitePress documentation site +dev/ # Development environment (CLI testing configs) +specs/ # OpenAPI test specifications +scripts/ # Build and test scripts +``` + +## Tooling + +- **Package manager**: pnpm 10.28.2 (strict engine, exact versions) +- **Node**: >=20.19.0 (see .nvmrc for exact version) +- **Build**: Turbo 2.8.0 + tsdown + Rollup +- **Language**: TypeScript 5.9.3, ESM only +- **Formatter**: oxfmt 0.27.0 (single quotes via .oxfmtrc.json) +- **Linter**: ESLint 9 flat config with typescript-eslint, simple-import-sort, sort-destructure-keys, typescript-sort-keys +- **Tests**: Vitest 3.2.4 +- **Pre-commit**: Husky + lint-staged (runs `pnpm format` + `pnpm lint:fix`) +- **Python** (for openapi-python): Python >=3.10, mypy, ruff, line length 120 +- **Releases**: Changesets + +## Code Conventions + +- ESM modules only (`.mts`/`.mjs` extensions in builds) +- UTF-8, LF line endings, 2-space indentation +- Single quotes (enforced by oxfmt) +- Imports sorted by eslint-plugin-simple-import-sort +- Object/interface keys sorted alphabetically +- Destructured keys sorted alphabetically + +## Pre-commit Checklist + +Run before committing (Husky runs format + lint automatically, but also verify): + +```bash +pnpm lint:fix # Auto-fix formatting and linting +pnpm typecheck # Type check +pnpm test # Run tests +``` + +Some linting warnings in `.gen/snapshots/` directories are expected for generated code. + +## Git Conventions + +- **Branch naming**: `feat/`, `fix/`, `chore/`, `refactor/`, `docs/` prefixes +- **Commit messages**: Conventional Commits (`feat:`, `fix:`, `chore:`, `refactor:`, `ci:`, `docs:`) +- **Releases**: Changesets-based, auto-publish on merge to main + +## Known Issues + +- Docs build may fail due to pnpm version mismatch in VitePress - use `--filter="@hey-api/**"` to skip +- Some tests may fail in sandboxed environments due to network restrictions (OpenAPI spec downloads) +- Generated test files in `packages/openapi-ts-tests/` may have expected linting warnings diff --git a/dev/python/presets.ts b/dev/python/presets.ts index 72790b14b..9e720f1b4 100644 --- a/dev/python/presets.ts +++ b/dev/python/presets.ts @@ -3,7 +3,16 @@ import { sdk } from './plugins'; export const presets = { sdk: () => [ /** SDK */ - sdk(), + sdk({ + '~hooks': { + symbols: { + // getExportFromFilePath(symbol) { + // console.log(symbol.toString()); + // return undefined; + // }, + }, + }, + }), ], } as const; diff --git a/dev/typescript/presets.ts b/dev/typescript/presets.ts index d13a208be..d1555e606 100644 --- a/dev/typescript/presets.ts +++ b/dev/typescript/presets.ts @@ -16,7 +16,12 @@ export const presets = { sdk: () => [ /** SDK with types */ typescript(), - sdk(), + sdk({ + operations: { + containerName: 'OpenCode', + strategy: 'single', + }, + }), ], tanstack: () => [ /** SDK + TanStack Query */ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2600554d8..d40529049 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,4 +1,4 @@ -# @docs/openapi-ts +# docs ## 0.10.4 diff --git a/docs/openapi-ts/migrating.md b/docs/openapi-ts/migrating.md index aa3ac3ba1..e12c4cfe4 100644 --- a/docs/openapi-ts/migrating.md +++ b/docs/openapi-ts/migrating.md @@ -7,6 +7,12 @@ description: Migrating to @hey-api/openapi-ts. While we try to avoid breaking changes, sometimes it's unavoidable in order to offer you the latest features. This page lists changes that require updates to your code. If you run into a problem with migration, please [open an issue](https://github.com/hey-api/openapi-ts/issues). +## v0.92.0 + +### Updated Symbol interface + +The `exportFrom` property has been replaced with the `getExportFromFilePath()` function. This allows you to dynamically determine export paths based on symbol properties. This is a low-level feature, so you're most likely unaffected. + ## v0.91.0 ### Removed CommonJS (CJS) support diff --git a/docs/openapi-ts/output.md b/docs/openapi-ts/output.md index cffd611a6..23db04913 100644 --- a/docs/openapi-ts/output.md +++ b/docs/openapi-ts/output.md @@ -58,7 +58,7 @@ You can learn more on the [TypeScript](/openapi-ts/plugins/typescript) page. You can learn more on the [SDK](/openapi-ts/plugins/sdk) page. -## Barrel File +## Entry File `index.ts` is not generated by any specific plugin. It's meant for convenience and by default, it re-exports every artifact generated by default plugins (TypeScript and SDK). @@ -71,7 +71,7 @@ export * from './types.gen'; ::: -### Disable index file +### Disable entry file We recommend importing artifacts from their respective files to avoid ambiguity, but we leave this choice up to you. @@ -81,35 +81,73 @@ import type { Pet } from './client'; import type { Pet } from './client/types.gen'; ``` -If you're not importing artifacts from the index file, you can skip generating it altogether by setting the `output.indexFile` option to `false`. +If you're not importing artifacts from the entry file, you can skip generating it altogether by setting the `output.entryFile` option to `false`. ```js export default { input: 'hey-api/backend', // sign up at app.heyapi.dev output: { - indexFile: false, // [!code ++] + entryFile: false, // [!code ++] path: 'src/client', }, }; ``` -### Re-export more files +### Re-export artifacts -You can choose which files should be re-exported by setting the `exportFromIndex` option to `true` on any plugin. For example, here's how you would re-export [Zod](/openapi-ts/plugins/zod) plugin exports. +You can choose which artifacts should be re-exported from the entry file using the `includeInEntry` option on any plugin. For example, we can re-export all [Zod](/openapi-ts/plugins/zod) plugin artifacts by setting `includeInEntry` to `true`: -```js +::: code-group + +```ts [example] +export { + zAddPetData, + zAddPetResponse, + zApiResponse, + zCategory, + // ...more exports +} from './zod.gen'; +``` + +```js [config] +export default { + input: 'hey-api/backend', // sign up at app.heyapi.dev + output: 'src/client', + plugins: [ + // ...other plugins + { + includeInEntry: true, // [!code ++] + name: 'zod', + }, + ], +}; +``` + +::: + +Or we can re-export only specific artifacts by providing a predicate function: + +::: code-group + +```ts [example] +export { zTag } from './zod.gen'; +``` + +```js [config] export default { input: 'hey-api/backend', // sign up at app.heyapi.dev output: 'src/client', plugins: [ // ...other plugins { - exportFromIndex: true, // [!code ++] + includeInEntry: (symbol) => symbol.name === 'zTag', // [!code ++] name: 'zod', }, ], }; ``` +::: + diff --git a/docs/openapi-ts/plugins/custom.md b/docs/openapi-ts/plugins/custom.md index 5e3dc5f4c..19e2f7de0 100644 --- a/docs/openapi-ts/plugins/custom.md +++ b/docs/openapi-ts/plugins/custom.md @@ -92,7 +92,7 @@ export const defineConfig = definePluginConfig(defaultConfig); In the file above, we define a `my-plugin` plugin which will generate a `my-plugin.gen.ts` file. We also demonstrate declaring `@hey-api/typescript` as a dependency for our plugin, so we can safely import artifacts from `types.gen.ts`. -By default, your plugin output won't be re-exported from the `index.ts` file. To enable this feature, add `exportFromIndex: true` to your `config.ts` file. +By default, your plugin output won't be re-exported from the `index.ts` file. To enable this feature, add `includeInEntry: true` to your `config.ts` file. ::: warning Re-exporting your plugin from index file may result in broken output due to naming conflicts. Ensure your exported identifiers are unique. diff --git a/docs/package.json b/docs/package.json index 212692978..239cce296 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,5 +1,5 @@ { - "name": "@docs/openapi-ts", + "name": "docs", "version": "0.10.4", "private": true, "description": "Documentation for OpenAPI TypeScript.", diff --git a/examples/openapi-ts-angular-common/openapi-ts.config.ts b/examples/openapi-ts-angular-common/openapi-ts.config.ts index 67ab76aff..d146f5d2d 100644 --- a/examples/openapi-ts-angular-common/openapi-ts.config.ts +++ b/examples/openapi-ts-angular-common/openapi-ts.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', + logs: { + path: './logs', + }, output: { path: './src/client', postProcess: ['oxfmt', 'eslint'], @@ -13,12 +16,12 @@ export default defineConfig({ throwOnError: true, }, { - exportFromIndex: true, httpRequests: true, httpResources: { containerName: '{{name}}ServiceResources', strategy: 'byTags', }, + includeInEntry: true, name: '@angular/common', }, '@hey-api/schemas', diff --git a/examples/openapi-ts-angular/openapi-ts.config.ts b/examples/openapi-ts-angular/openapi-ts.config.ts index 862074a8a..7bbd56313 100644 --- a/examples/openapi-ts-angular/openapi-ts.config.ts +++ b/examples/openapi-ts-angular/openapi-ts.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', + logs: { + path: './logs', + }, output: { path: './src/client', postProcess: ['oxfmt', 'eslint'], diff --git a/examples/openapi-ts-axios/openapi-ts.config.ts b/examples/openapi-ts-axios/openapi-ts.config.ts index 5e262c070..76edc0e08 100644 --- a/examples/openapi-ts-axios/openapi-ts.config.ts +++ b/examples/openapi-ts-axios/openapi-ts.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', + logs: { + path: './logs', + }, output: { path: './src/client', postProcess: ['oxfmt', 'eslint'], diff --git a/examples/openapi-ts-fastify/openapi-ts.config.ts b/examples/openapi-ts-fastify/openapi-ts.config.ts index b47ce6c9f..34c6987cb 100644 --- a/examples/openapi-ts-fastify/openapi-ts.config.ts +++ b/examples/openapi-ts-fastify/openapi-ts.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: 'https://gist.githubusercontent.com/seriousme/55bd4c8ba2e598e416bb5543dcd362dc/raw/cf0b86ba37bb54bf1a6bf047c0ecf2a0ce4c62e0/petstore-v3.1.json', + logs: { + path: './logs', + }, output: { path: './src/client', postProcess: ['oxfmt', 'eslint'], diff --git a/examples/openapi-ts-fetch/openapi-ts.config.ts b/examples/openapi-ts-fetch/openapi-ts.config.ts index 64e596e39..acb2c6549 100644 --- a/examples/openapi-ts-fetch/openapi-ts.config.ts +++ b/examples/openapi-ts-fetch/openapi-ts.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', + logs: { + path: './logs', + }, output: { path: './src/client', postProcess: ['oxfmt', 'eslint'], diff --git a/examples/openapi-ts-ky/openapi-ts.config.ts b/examples/openapi-ts-ky/openapi-ts.config.ts index b45726a98..1e18d36ea 100644 --- a/examples/openapi-ts-ky/openapi-ts.config.ts +++ b/examples/openapi-ts-ky/openapi-ts.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', + logs: { + path: './logs', + }, output: { path: './src/client', postProcess: ['oxfmt', 'eslint'], diff --git a/examples/openapi-ts-next/openapi-ts.config.ts b/examples/openapi-ts-next/openapi-ts.config.ts index 734f5197d..b4514b9fc 100644 --- a/examples/openapi-ts-next/openapi-ts.config.ts +++ b/examples/openapi-ts-next/openapi-ts.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', + logs: { + path: './logs', + }, output: { path: './src/client', postProcess: ['oxfmt', 'eslint'], diff --git a/examples/openapi-ts-ofetch/openapi-ts.config.ts b/examples/openapi-ts-ofetch/openapi-ts.config.ts index e94cd0f25..b1ac7ed69 100644 --- a/examples/openapi-ts-ofetch/openapi-ts.config.ts +++ b/examples/openapi-ts-ofetch/openapi-ts.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', + logs: { + path: './logs', + }, output: { path: './src/client', postProcess: ['oxfmt', 'eslint'], diff --git a/examples/openapi-ts-openai/openapi-ts.config.ts b/examples/openapi-ts-openai/openapi-ts.config.ts index 14beca046..5075c5efe 100644 --- a/examples/openapi-ts-openai/openapi-ts.config.ts +++ b/examples/openapi-ts-openai/openapi-ts.config.ts @@ -4,6 +4,9 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: path.resolve('..', '..', 'specs', '3.1.x', 'openai.yaml'), + logs: { + path: './logs', + }, output: { path: './src/client', postProcess: ['oxfmt'], diff --git a/examples/openapi-ts-pinia-colada/openapi-ts.config.ts b/examples/openapi-ts-pinia-colada/openapi-ts.config.ts index 7e16a6309..2686ace7c 100644 --- a/examples/openapi-ts-pinia-colada/openapi-ts.config.ts +++ b/examples/openapi-ts-pinia-colada/openapi-ts.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', + logs: { + path: './logs', + }, output: { path: './src/client', postProcess: ['oxfmt', 'eslint'], @@ -16,7 +19,7 @@ export default defineConfig({ name: '@hey-api/typescript', }, { - exportFromIndex: true, + includeInEntry: true, name: '@pinia/colada', queryKeys: false, }, diff --git a/examples/openapi-ts-tanstack-angular-query-experimental/openapi-ts.config.ts b/examples/openapi-ts-tanstack-angular-query-experimental/openapi-ts.config.ts index 1e53201ef..3743db4c0 100644 --- a/examples/openapi-ts-tanstack-angular-query-experimental/openapi-ts.config.ts +++ b/examples/openapi-ts-tanstack-angular-query-experimental/openapi-ts.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', + logs: { + path: './logs', + }, output: { path: './src/client', postProcess: ['oxfmt', 'eslint'], diff --git a/examples/openapi-ts-tanstack-react-query/openapi-ts.config.ts b/examples/openapi-ts-tanstack-react-query/openapi-ts.config.ts index f256e9cdb..ff1aa6526 100644 --- a/examples/openapi-ts-tanstack-react-query/openapi-ts.config.ts +++ b/examples/openapi-ts-tanstack-react-query/openapi-ts.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', + logs: { + path: './logs', + }, output: { path: './src/client', postProcess: ['oxfmt', 'eslint'], diff --git a/examples/openapi-ts-tanstack-svelte-query/openapi-ts.config.ts b/examples/openapi-ts-tanstack-svelte-query/openapi-ts.config.ts index a05c77f87..1b07d4944 100644 --- a/examples/openapi-ts-tanstack-svelte-query/openapi-ts.config.ts +++ b/examples/openapi-ts-tanstack-svelte-query/openapi-ts.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', + logs: { + path: './logs', + }, output: { path: './src/client', postProcess: ['oxfmt', 'eslint'], diff --git a/examples/openapi-ts-tanstack-vue-query/openapi-ts.config.ts b/examples/openapi-ts-tanstack-vue-query/openapi-ts.config.ts index 0a8ac79cc..af75fc002 100644 --- a/examples/openapi-ts-tanstack-vue-query/openapi-ts.config.ts +++ b/examples/openapi-ts-tanstack-vue-query/openapi-ts.config.ts @@ -3,6 +3,9 @@ import { defineConfig } from '@hey-api/openapi-ts'; export default defineConfig({ input: 'https://raw.githubusercontent.com/swagger-api/swagger-petstore/master/src/main/resources/openapi.yaml', + logs: { + path: './logs', + }, output: { path: './src/client', postProcess: ['oxfmt', 'eslint'], diff --git a/package.json b/package.json index ac248b478..47e91c671 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "test:watch": "turbo run test:watch", "test": "turbo run test", "typecheck": "turbo run typecheck", + "td": "turbo run dev --filter", "tt": "turbo run test --filter", "tw": "turbo run test:watch --filter", "tu": "turbo run test:update --filter", diff --git a/packages/codegen-core/src/__tests__/exports.test.ts b/packages/codegen-core/src/__tests__/exports.test.ts index 0faeb2366..dddd4fc08 100644 --- a/packages/codegen-core/src/__tests__/exports.test.ts +++ b/packages/codegen-core/src/__tests__/exports.test.ts @@ -2,6 +2,7 @@ import * as index from '../index'; const constExports = [ 'defaultExtensions', + 'defaultModuleEntryNames', 'defaultNameConflictResolvers', 'File', 'fromRef', @@ -41,6 +42,7 @@ export type _TypeExports = [ index.ImportModule, index.IProject, index.Language, + index.ModuleEntryNames, index.NameConflictResolver, index.NameConflictResolvers, index.Node, diff --git a/packages/codegen-core/src/index.ts b/packages/codegen-core/src/index.ts index 1501df9da..332f31173 100644 --- a/packages/codegen-core/src/index.ts +++ b/packages/codegen-core/src/index.ts @@ -11,8 +11,14 @@ export { File } from './files/file'; export type { IFileIn as FileIn } from './files/types'; export { isNode, isNodeRef, isSymbol, isSymbolRef } from './guards'; export { defaultExtensions } from './languages/extensions'; +export { defaultModuleEntryNames } from './languages/modules'; export { defaultNameConflictResolvers } from './languages/resolvers'; -export type { Extensions, Language, NameConflictResolvers } from './languages/types'; +export type { + Extensions, + Language, + ModuleEntryNames, + NameConflictResolvers, +} from './languages/types'; export { log } from './log'; export { Logger } from './logger'; export type { diff --git a/packages/codegen-core/src/languages/modules.ts b/packages/codegen-core/src/languages/modules.ts new file mode 100644 index 000000000..2180a53ac --- /dev/null +++ b/packages/codegen-core/src/languages/modules.ts @@ -0,0 +1,7 @@ +import type { ModuleEntryNames } from './types'; + +export const defaultModuleEntryNames: ModuleEntryNames = { + javascript: 'index', + python: '__init__', + typescript: 'index', +}; diff --git a/packages/codegen-core/src/languages/types.ts b/packages/codegen-core/src/languages/types.ts index 8e221c479..86c73e713 100644 --- a/packages/codegen-core/src/languages/types.ts +++ b/packages/codegen-core/src/languages/types.ts @@ -2,6 +2,17 @@ import type { AnyString } from '@hey-api/types'; import type { NameConflictResolver } from '../planner/types'; +/** + * Map of extensions for each language. + * + * @example + * ```ts + * const exts: Extensions = { + * typescript: ['.ts', '.tsx'], + * python: ['.py'], + * }; + * ``` + */ export type Extensions = Partial>>; export type Language = @@ -34,4 +45,17 @@ export type Language = | 'yaml' | AnyString; // other/custom language +/** + * Map of module entry names for each language. + * + * @example + * ```ts + * const entries: ModuleEntryNames = { + * typescript: 'index', + * python: '__init__', + * }; + * ``` + */ +export type ModuleEntryNames = Partial>; + export type NameConflictResolvers = Partial>; diff --git a/packages/codegen-core/src/planner/planner.ts b/packages/codegen-core/src/planner/planner.ts index b0a511268..183360abf 100644 --- a/packages/codegen-core/src/planner/planner.ts +++ b/packages/codegen-core/src/planner/planner.ts @@ -54,11 +54,11 @@ export class Planner { }); file.addNode(node); symbol.setFile(file); - for (const exportFrom of symbol.exportFrom) { + for (const logicalFilePath of symbol.getExportFromFilePath?.(symbol) ?? []) { this.project.files.register({ external: false, language: file.language, - logicalFilePath: exportFrom, + logicalFilePath, }); } ctx.walkScopes((dependency) => { @@ -151,11 +151,11 @@ export class Planner { const file = node.file; if (!file) return; - for (const exportFrom of symbol.exportFrom) { + for (const logicalFilePath of symbol.getExportFromFilePath?.(symbol) ?? []) { const target = this.project.files.register({ external: false, language: node.language, - logicalFilePath: exportFrom, + logicalFilePath, }); if (target.id === file.id) continue; diff --git a/packages/codegen-core/src/project/project.ts b/packages/codegen-core/src/project/project.ts index 7b4c9d014..1ff95558e 100644 --- a/packages/codegen-core/src/project/project.ts +++ b/packages/codegen-core/src/project/project.ts @@ -3,8 +3,9 @@ import path from 'node:path'; import type { IProjectRenderMeta } from '../extensions'; import { FileRegistry } from '../files/registry'; import { defaultExtensions } from '../languages/extensions'; +import { defaultModuleEntryNames } from '../languages/modules'; import { defaultNameConflictResolvers } from '../languages/resolvers'; -import type { Extensions, NameConflictResolvers } from '../languages/types'; +import type { Extensions, ModuleEntryNames, NameConflictResolvers } from '../languages/types'; import { NodeRegistry } from '../nodes/registry'; import type { IOutput } from '../output'; import { Planner } from '../planner/planner'; @@ -25,6 +26,7 @@ export class Project implements IProject { readonly defaultNameConflictResolver: NameConflictResolver; readonly extensions: Extensions; readonly fileName?: (name: string) => string; + readonly moduleEntryNames: ModuleEntryNames; readonly nameConflictResolvers: NameConflictResolvers; readonly renderers: ReadonlyArray; readonly root: string; @@ -36,6 +38,7 @@ export class Project implements IProject { | 'defaultNameConflictResolver' | 'extensions' | 'fileName' + | 'moduleEntryNames' | 'nameConflictResolvers' | 'renderers' > & @@ -51,6 +54,10 @@ export class Project implements IProject { }; this.fileName = typeof fileName === 'string' ? () => fileName : fileName; this.files = new FileRegistry(this); + this.moduleEntryNames = { + ...defaultModuleEntryNames, + ...args.moduleEntryNames, + }; this.nameConflictResolvers = { ...defaultNameConflictResolvers, ...args.nameConflictResolvers, diff --git a/packages/codegen-core/src/project/types.ts b/packages/codegen-core/src/project/types.ts index 748a00f2a..e3a1863af 100644 --- a/packages/codegen-core/src/project/types.ts +++ b/packages/codegen-core/src/project/types.ts @@ -1,6 +1,6 @@ import type { IProjectRenderMeta } from '../extensions'; import type { IFileRegistry } from '../files/types'; -import type { Extensions, NameConflictResolvers } from '../languages/types'; +import type { Extensions, ModuleEntryNames, NameConflictResolvers } from '../languages/types'; import type { INodeRegistry } from '../nodes/types'; import type { IOutput } from '../output'; import type { NameConflictResolver } from '../planner/types'; @@ -21,7 +21,17 @@ export interface IProject { readonly defaultFileName: string; /** Default name conflict resolver used when a file has no specific resolver. */ readonly defaultNameConflictResolver: NameConflictResolver; - /** Maps language to array of extensions. First element is used by default. */ + /** + * Maps language to array of extensions. First element is used by default. + * + * @example + * ```ts + * const exts: Extensions = { + * typescript: ['.ts', '.tsx'], + * python: ['.py'], + * }; + * ``` + */ readonly extensions: Extensions; /** * Function to transform file names before they are used. @@ -32,7 +42,29 @@ export interface IProject { readonly fileName?: (name: string) => string; /** Centralized file registry for the project. */ readonly files: IFileRegistry; - /** Map of language-specific name conflict resolvers for files in the project. */ + /** + * Map of module entry names for each language. + * + * @example + * ```ts + * const entries: ModuleEntryNames = { + * typescript: 'index', + * python: '__init__', + * }; + * ``` + */ + readonly moduleEntryNames: ModuleEntryNames; + /** + * Map of language-specific name conflict resolvers for files in the project. + * + * @example + * ```ts + * const resolvers: NameConflictResolvers = { + * typescript: myTypeScriptResolver, + * python: myPythonResolver, + * }; + * ``` + */ readonly nameConflictResolvers: NameConflictResolvers; /** Centralized node registry for the project. */ readonly nodes: INodeRegistry; diff --git a/packages/codegen-core/src/symbols/symbol.ts b/packages/codegen-core/src/symbols/symbol.ts index 70cbff3f7..a7779a155 100644 --- a/packages/codegen-core/src/symbols/symbol.ts +++ b/packages/codegen-core/src/symbols/symbol.ts @@ -20,12 +20,6 @@ export class Symbol { * @default false */ private _exported: boolean; - /** - * Names of files (without extension) from which this symbol is re-exported. - * - * @default [] - */ - private _exportFrom: ReadonlyArray; /** * External module name if this symbol is imported from a module not managed * by the project (e.g. "zod", "lodash"). @@ -43,6 +37,12 @@ export class Symbol { * The alias-resolved, conflict-free emitted name. */ private _finalName?: string; + /** + * Custom strategy to determine from which file path(s) this symbol is re-exported. + * + * @returns The file path(s) that re-export this symbol, or undefined if none. + */ + private _getExportFromFilePath?: (symbol: Symbol) => ReadonlyArray | undefined; /** * Custom strategy to determine file output path. * @@ -85,8 +85,8 @@ export class Symbol { constructor(input: ISymbolIn, id: number) { this._exported = input.exported ?? false; - this._exportFrom = input.exportFrom ?? []; this._external = input.external; + this._getExportFromFilePath = input.getExportFromFilePath; this._getFilePath = input.getFilePath; this.id = id; this._importKind = input.importKind ?? 'named'; @@ -113,13 +113,6 @@ export class Symbol { return this.canonical._exported; } - /** - * Names of files (without extension) that re-export this symbol. - */ - get exportFrom(): ReadonlyArray { - return this.canonical._exportFrom; - } - /** * External module from which this symbol originates, if any. */ @@ -148,6 +141,13 @@ export class Symbol { return this.canonical._finalName; } + /** + * Custom re-export file path resolver, if provided. + */ + get getExportFromFilePath(): ((symbol: Symbol) => ReadonlyArray | undefined) | undefined { + return this.canonical._getExportFromFilePath; + } + /** * Custom file path resolver, if provided. */ @@ -219,16 +219,6 @@ export class Symbol { this._exported = exported; } - /** - * Records file names that re‑export this symbol. - * - * @param list — Source files re‑exporting this symbol. - */ - setExportFrom(list: ReadonlyArray): void { - this.assertCanonical(); - this._exportFrom = list; - } - /** * Assigns the output file this symbol will be emitted into. * diff --git a/packages/codegen-core/src/symbols/types.ts b/packages/codegen-core/src/symbols/types.ts index f5af83222..8df213efa 100644 --- a/packages/codegen-core/src/symbols/types.ts +++ b/packages/codegen-core/src/symbols/types.ts @@ -8,12 +8,6 @@ export type ISymbolIdentifier = number | ISymbolMeta; export type SymbolKind = 'class' | 'enum' | 'function' | 'interface' | 'namespace' | 'type' | 'var'; export type ISymbolIn = { - /** - * Array of file names (without extensions) from which this symbol is re-exported. - * - * @default undefined - */ - exportFrom?: ReadonlyArray; /** * Whether this symbol is exported from its own file. * @@ -27,6 +21,12 @@ export type ISymbolIn = { * @default undefined */ external?: string; + /** + * Optional output strategy to override default behavior. + * + * @returns The file path(s) that re-export this symbol, or undefined if none. + */ + getExportFromFilePath?: Symbol['getExportFromFilePath']; /** * Optional output strategy to override default behavior. * diff --git a/packages/openapi-python/src/config/output/config.ts b/packages/openapi-python/src/config/output/config.ts index 3f5382026..87a3a2457 100644 --- a/packages/openapi-python/src/config/output/config.ts +++ b/packages/openapi-python/src/config/output/config.ts @@ -18,13 +18,13 @@ export function getOutput(userConfig: { output: MaybeArray const output = valueToObject({ defaultValue: { clean: true, + entryFile: true, fileName: { case: 'preserve', name: '{{name}}', - suffix: '.gen', + suffix: '_gen', }, - header: '// This file is auto-generated by @hey-api/openapi-python', - indexFile: true, + header: '# This file is auto-generated by @hey-api/openapi-python', path: '', postProcess: [], preferExportAll: false, diff --git a/packages/openapi-python/src/config/utils.ts b/packages/openapi-python/src/config/utils.ts new file mode 100644 index 000000000..d0acd100c --- /dev/null +++ b/packages/openapi-python/src/config/utils.ts @@ -0,0 +1,12 @@ +import type { Context, PluginInstance } from '@hey-api/shared'; + +import type { Config } from './types'; + +export function getTypedConfig( + plugin: Pick | Pick, +): Config { + if ('context' in plugin) { + return plugin.context.config as Config; + } + return plugin.config as Config; +} diff --git a/packages/openapi-python/src/createClient.ts b/packages/openapi-python/src/createClient.ts index d0809eacf..1c0f2fd6b 100644 --- a/packages/openapi-python/src/createClient.ts +++ b/packages/openapi-python/src/createClient.ts @@ -21,7 +21,7 @@ import colors from 'ansi-colors'; import { postProcessors } from './config/output/postprocess'; import type { Config } from './config/types'; import { generateOutput } from './generate/output'; -// import { TypeScriptRenderer } from './py-dsl'; +import { PythonRenderer } from './py-dsl'; export async function createClient({ config, @@ -106,14 +106,14 @@ export async function createClient({ const eventParser = logger.timeEvent('parser'); // TODO: allow overriding via config const project = new Project({ - defaultFileName: 'index', + defaultFileName: '__init__', fileName: (base) => { const name = applyNaming(base, config.output.fileName); const { suffix } = config.output.fileName; if (!suffix) { return name; } - return name === 'index' || name.endsWith(suffix) ? name : `${name}${suffix}`; + return name === '__init__' || name.endsWith(suffix) ? name : `${name}${suffix}`; }, nameConflictResolvers: config.output.nameConflictResolver ? { @@ -121,13 +121,12 @@ export async function createClient({ } : undefined, renderers: [ - // TODO: attach renderer - // new TypeScriptRenderer({ - // header: config.output.header, - // preferExportAll: config.output.preferExportAll, - // preferFileExtension: config.output.importFileExtension || undefined, - // resolveModuleName: config.output.resolveModuleName, - // }), + new PythonRenderer({ + header: config.output.header, + preferExportAll: config.output.preferExportAll, + preferFileExtension: config.output.importFileExtension || undefined, + resolveModuleName: config.output.resolveModuleName, + }), ], root: config.output.path, }); diff --git a/packages/openapi-python/src/generate/__tests__/client.test.ts b/packages/openapi-python/src/generate/__tests__/client.test.ts new file mode 100644 index 000000000..df86a61bc --- /dev/null +++ b/packages/openapi-python/src/generate/__tests__/client.test.ts @@ -0,0 +1,131 @@ +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +describe('isDevMode logic', () => { + const scenarios: ReadonlyArray<{ + description: string; + expected: boolean; + mockPath: string; + }> = [ + { + description: 'returns true in dev mode (src/generate)', + expected: true, + mockPath: ['', 'home', 'user', 'packages', 'openapi-python', 'src', 'generate'].join( + path.sep, + ), + }, + { + description: 'returns false in prod mode (dist/generate)', + expected: false, + mockPath: ['', 'home', 'user', 'packages', 'openapi-python', 'dist', 'generate'].join( + path.sep, + ), + }, + { + description: 'returns false when path contains /src/ but not in correct position', + expected: false, + mockPath: [ + '', + 'home', + 'user', + 'src', + 'project', + 'node_modules', + '@hey-api', + 'openapi-python', + 'dist', + 'generate', + ].join(path.sep), + }, + { + description: 'returns false when src is in project path (pnpm case from issue)', + expected: false, + mockPath: [ + '', + 'home', + 'user', + 'src', + 'thcdb', + 'worktree', + 'schema-gen', + 'web', + 'node_modules', + '.pnpm', + '@hey-api+openapi-python@0.91.1', + 'node_modules', + '@hey-api', + 'openapi-python', + 'dist', + 'generate', + ].join(path.sep), + }, + { + description: + 'returns false when src is in project path with plugins path (exact error from issue)', + expected: false, + mockPath: [ + '', + 'home', + 'user', + 'src', + 'thcdb', + 'worktree', + 'schema-gen', + 'web', + 'node_modules', + '.pnpm', + '@hey-api+openapi-python@0.91.1_magicast@0.5.1_typescript@5.9.3', + 'node_modules', + '@hey-api', + 'openapi-python', + 'plugins', + '@hey-api', + 'client-core', + 'bundle', + ].join(path.sep), + }, + { + description: 'returns true only when ending with src/generate', + expected: true, + mockPath: [ + '', + 'home', + 'user', + 'src', + 'backup', + 'packages', + 'openapi-python', + 'src', + 'generate', + ].join(path.sep), + }, + { + description: 'returns false when not ending with generate', + expected: false, + mockPath: ['', 'home', 'user', 'packages', 'openapi-python', 'src', 'plugins'].join(path.sep), + }, + { + description: 'returns false when src exists but dist is later', + expected: false, + mockPath: ['', 'home', 'user', 'src', 'project', 'openapi-python', 'dist', 'generate'].join( + path.sep, + ), + }, + ]; + + it.each(scenarios)('$description', ({ expected, mockPath }) => { + // Test the isDevMode logic + const normalized = mockPath.split(path.sep); + const srcIndex = normalized.lastIndexOf('src'); + const distIndex = normalized.lastIndexOf('dist'); + + const result = + srcIndex !== -1 && + srcIndex > distIndex && + srcIndex === normalized.length - 2 && + normalized[srcIndex + 1] === 'generate'; + + expect(result).toBe(expected); + }); +}); diff --git a/packages/openapi-python/src/generate/client.ts b/packages/openapi-python/src/generate/client.ts new file mode 100644 index 000000000..ed288d6ef --- /dev/null +++ b/packages/openapi-python/src/generate/client.ts @@ -0,0 +1,259 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { IProject, ProjectRenderMeta } from '@hey-api/codegen-core'; +import type { DefinePlugin } from '@hey-api/shared'; +import { ensureDirSync } from '@hey-api/shared'; + +import type { Config } from '../config/types'; +import type { Client } from '../plugins/@hey-api/client-core/types'; +import { getClientPlugin } from '../plugins/@hey-api/client-core/utils'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Dev mode: 'src' appears after 'dist' (or dist doesn't exist), and 'generate' follows 'src' + */ +function isDevMode(): boolean { + const normalized = __dirname.split(path.sep); + const srcIndex = normalized.lastIndexOf('src'); + const distIndex = normalized.lastIndexOf('dist'); + return ( + srcIndex !== -1 && + srcIndex > distIndex && + srcIndex === normalized.length - 2 && + normalized[srcIndex + 1] === 'generate' + ); +} + +/** + * Returns paths to client bundle files based on execution context + */ +function getClientBundlePaths(pluginName: string): { + clientPath: string; + corePath: string; +} { + const clientName = pluginName.slice('@hey-api/client-'.length); + + if (isDevMode()) { + // Dev: source bundle folders at src/plugins/@hey-api/{client}/bundle + const pluginsDir = path.resolve(__dirname, '..', 'plugins', '@hey-api'); + return { + clientPath: path.resolve(pluginsDir, `client-${clientName}`, 'bundle'), + corePath: path.resolve(pluginsDir, 'client-core', 'bundle'), + }; + } + + // Prod: copied to dist/clients/{clientName} + return { + clientPath: path.resolve(__dirname, 'clients', clientName), + corePath: path.resolve(__dirname, 'clients', 'core'), + }; +} + +/** + * Returns absolute path to the client folder. This is hard-coded for now. + */ +export function clientFolderAbsolutePath(config: Config): string { + const client = getClientPlugin(config); + + if ('bundle' in client.config && client.config.bundle) { + // not proud of this one + const renamed: Map | undefined = + // @ts-expect-error + config._FRAGILE_CLIENT_BUNDLE_RENAMED; + return path.resolve(config.output.path, 'client', `${renamed?.get('index') ?? 'index'}.py`); + } + + return client.name; +} + +/** + * Recursively copies files and directories. + * This is a PnP-compatible alternative to fs.cpSync that works with Yarn PnP's + * virtualized filesystem. + */ +function copyRecursivePnP(src: string, dest: string): void { + const stat = fs.statSync(src); + + if (stat.isDirectory()) { + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + + const files = fs.readdirSync(src); + for (const file of files) { + copyRecursivePnP(path.join(src, file), path.join(dest, file)); + } + } else { + const content = fs.readFileSync(src); + fs.writeFileSync(dest, content); + } +} + +function renameFile({ + filePath, + project, + renamed, +}: { + filePath: string; + project: IProject; + renamed: Map; +}): void { + const extension = path.extname(filePath); + const name = path.basename(filePath, extension); + const renamedName = project.fileName?.(name) || name; + if (renamedName !== name) { + const outputPath = path.dirname(filePath); + fs.renameSync(filePath, path.resolve(outputPath, `${renamedName}${extension}`)); + renamed.set(name, renamedName); + } +} + +function replaceImports({ + filePath, + isDevMode, + meta, + renamed, +}: { + filePath: string; + isDevMode?: boolean; + meta: ProjectRenderMeta; + renamed: Map; +}): void { + let content = fs.readFileSync(filePath, 'utf8'); + + // Dev mode: rewrite source bundle imports to match output structure + if (isDevMode) { + // ../../client-core/bundle/foo -> ../core/foo + content = content.replace(/from\s+['"]\.\.\/\.\.\/client-core\/bundle\//g, "from '../core/"); + // ../../client-core/bundle' (index import) + content = content.replace(/from\s+['"]\.\.\/\.\.\/client-core\/bundle['"]/g, "from '../core'"); + } + + content = content.replace(/from\s+['"](\.\.?\/[^'"]*?)['"]/g, (match, importPath) => { + const importIndex = match.indexOf(importPath); + const extension = path.extname(importPath); + const fileName = path.basename(importPath, extension); + const importDir = path.dirname(importPath); + const replacedName = + (renamed.get(fileName) ?? fileName) + + (meta.importFileExtension ? meta.importFileExtension : extension); + const replacedMatch = + match.slice(0, importIndex) + + [importDir, replacedName].filter(Boolean).join('/') + + match.slice(importIndex + importPath.length); + return replacedMatch; + }); + + const header = '# This file is auto-generated by @hey-api/openapi-python\n\n'; + + content = `${header}${content}`; + + fs.writeFileSync(filePath, content, 'utf8'); +} + +/** + * Creates a `client` folder containing the same modules as the client package. + */ +export function generateClientBundle({ + meta, + outputPath, + plugin, + project, +}: { + meta: ProjectRenderMeta; + outputPath: string; + plugin: DefinePlugin['Config']; + project?: IProject; +}): Map | undefined { + const renamed = new Map(); + const devMode = isDevMode(); + + // copy Hey API clients to output + const isHeyApiClientPlugin = plugin.name.startsWith('@hey-api/client-'); + if (isHeyApiClientPlugin) { + const { clientPath } = getClientBundlePaths(plugin.name); + // const { clientPath, corePath } = getClientBundlePaths(plugin.name); + + // copy client core + // const coreOutputPath = path.resolve(outputPath, 'core'); + // ensureDirSync(coreOutputPath); + // copyRecursivePnP(corePath, coreOutputPath); + + // copy client bundle + const clientOutputPath = path.resolve(outputPath, 'client'); + ensureDirSync(clientOutputPath); + copyRecursivePnP(clientPath, clientOutputPath); + + if (project) { + // const copiedCoreFiles = fs.readdirSync(coreOutputPath); + // for (const file of copiedCoreFiles) { + // renameFile({ + // filePath: path.resolve(coreOutputPath, file), + // project, + // renamed, + // }); + // } + + const copiedClientFiles = fs.readdirSync(clientOutputPath); + for (const file of copiedClientFiles) { + renameFile({ + filePath: path.resolve(clientOutputPath, file), + project, + renamed, + }); + } + } + + // const coreFiles = fs.readdirSync(coreOutputPath); + // for (const file of coreFiles) { + // replaceImports({ + // filePath: path.resolve(coreOutputPath, file), + // isDevMode: devMode, + // meta, + // renamed, + // }); + // } + + const clientFiles = fs.readdirSync(clientOutputPath); + for (const file of clientFiles) { + replaceImports({ + filePath: path.resolve(clientOutputPath, file), + isDevMode: devMode, + meta, + renamed, + }); + } + return renamed; + } + + const clientSrcPath = path.isAbsolute(plugin.name) ? path.dirname(plugin.name) : undefined; + + // copy custom local client to output + if (clientSrcPath) { + const dirPath = path.resolve(outputPath, 'client'); + ensureDirSync(dirPath); + copyRecursivePnP(clientSrcPath, dirPath); + return; + } + + // copy third-party client to output + const clientModulePath = path.normalize(require.resolve(plugin.name)); + const clientModulePathComponents = clientModulePath.split(path.sep); + const clientDistPath = clientModulePathComponents + .slice(0, clientModulePathComponents.indexOf('dist') + 1) + .join(path.sep); + + const indexJsFile = clientModulePathComponents[clientModulePathComponents.length - 1]; + const distFiles = [indexJsFile!, 'index.d.mts', 'index.d.cts']; + const dirPath = path.resolve(outputPath, 'client'); + ensureDirSync(dirPath); + for (const file of distFiles) { + fs.copyFileSync(path.resolve(clientDistPath, file), path.resolve(dirPath, file)); + } + + return; +} diff --git a/packages/openapi-python/src/generate/output.ts b/packages/openapi-python/src/generate/output.ts index 9d699d58a..03ce2da10 100644 --- a/packages/openapi-python/src/generate/output.ts +++ b/packages/openapi-python/src/generate/output.ts @@ -4,9 +4,9 @@ import path from 'node:path'; import type { Context } from '@hey-api/shared'; import { IntentContext } from '@hey-api/shared'; -// import { getTypedConfig } from '../config/utils'; -// import { getClientPlugin } from '../plugins/@hey-api/client-core/utils'; -// import { generateClientBundle } from './client'; +import { getTypedConfig } from '../config/utils'; +import { getClientPlugin } from '../plugins/@hey-api/client-core/utils'; +import { generateClientBundle } from './client'; export async function generateOutput(context: Context): Promise { const outputPath = path.resolve(context.config.output.path); @@ -17,22 +17,22 @@ export async function generateOutput(context: Context): Promise { } } - // const config = getTypedConfig(context); + const config = getTypedConfig(context); - // const client = getClientPlugin(config); - // if ('bundle' in client.config && client.config.bundle && !config.dryRun) { - // not proud of this one - // // @ts-expect-error - // config._FRAGILE_CLIENT_BUNDLE_RENAMED = generateClientBundle({ - // meta: { - // importFileExtension: config.output.importFileExtension, - // }, - // outputPath, - // // @ts-expect-error - // plugin: client, - // project: context.gen, - // }); - // } + const client = getClientPlugin(config); + if ('bundle' in client.config && client.config.bundle && !config.dryRun) { + // not proud of this one + // @ts-expect-error + config._FRAGILE_CLIENT_BUNDLE_RENAMED = generateClientBundle({ + meta: { + importFileExtension: config.output.importFileExtension, + }, + outputPath, + // @ts-expect-error + plugin: client, + project: context.gen, + }); + } for (const plugin of context.registerPlugins()) { await plugin.run(); diff --git a/packages/openapi-python/src/index.ts b/packages/openapi-python/src/index.ts index 0c2ce25b7..6345f686f 100644 --- a/packages/openapi-python/src/index.ts +++ b/packages/openapi-python/src/index.ts @@ -57,6 +57,7 @@ declare module '@hey-api/codegen-core' { declare module '@hey-api/shared' { interface PluginConfigMap { + '@hey-api/client-httpx': HeyApiClientHttpxPlugin['Types']; '@hey-api/python-sdk': HeyApiSdkPlugin['Types']; } } @@ -68,6 +69,7 @@ import colors from 'ansi-colors'; import colorSupport from 'color-support'; import type { UserConfig } from './config/types'; +import type { HeyApiClientHttpxPlugin } from './plugins/@hey-api/client-httpx'; import type { HeyApiSdkPlugin } from './plugins/@hey-api/sdk'; colors.enabled = colorSupport().hasBasic; @@ -77,6 +79,10 @@ export { createClient } from './generate'; /** * Type helper for configuration object, returns {@link MaybeArray} object(s) */ +export function defineConfig( + config: LazyOrAsync>, +): Promise>; +export function defineConfig(config: LazyOrAsync): Promise; export async function defineConfig>( config: LazyOrAsync, ): Promise { diff --git a/packages/openapi-python/src/plugins/@hey-api/client-core/config.ts b/packages/openapi-python/src/plugins/@hey-api/client-core/config.ts new file mode 100644 index 000000000..f00e3469f --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/client-core/config.ts @@ -0,0 +1,9 @@ +export const clientDefaultConfig = { + baseUrl: true, + bundle: true, + includeInEntry: false, +} as const; + +export const clientDefaultMeta = { + tags: ['client'], +} as const; diff --git a/packages/openapi-python/src/plugins/@hey-api/client-core/types.ts b/packages/openapi-python/src/plugins/@hey-api/client-core/types.ts new file mode 100644 index 000000000..382bf377a --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/client-core/types.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import type { Plugin } from '@hey-api/shared'; + +import type { HeyApiClientHttpxPlugin } from '../../../plugins/@hey-api/client-httpx'; + +export interface PluginHandler { + (...args: Parameters): void; +} + +/** + * Public Client API. + */ +export namespace Client { + export type Config = Plugin.Hooks & + Plugin.UserExports & { + /** + * Set a default base URL when creating the client? You can set `baseUrl` + * to a string which will be used as the base URL. If your input defines + * server(s), you can set `baseUrl` to a number to pick a specific server + * to use as the base URL. You can disable setting the base URL by setting + * `baseUrl` to `false`. By default, `baseUrl` is `true` and it will try to + * use the first defined server value. If there's none, we won't set a + * base URL. + * + * If the matched URL contains template literals, it will be ignored. + * + * @default true + */ + baseUrl?: string | number | boolean; + /** + * Bundle the client module? When `true`, the client module will be copied + * from the client plugin and bundled with the generated output. + * + * @default true + */ + bundle?: boolean; + /** + * Relative path to the runtime configuration file. This file must export + * a `createClientConfig()` function. The `createClientConfig()` function + * will be called on client initialization and the returned object will + * become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ + runtimeConfigPath?: string; + /** + * Should the type helper for base URL allow only values matching the + * server(s) defined in the input? By default, `strictBaseUrl` is `false` + * which will provide type hints and allow you to pass any string. + * + * Note that setting `strictBaseUrl` to `true` can produce an invalid + * build if you specify `baseUrl` which doesn't conform to the type helper. + * + * @default false + */ + strictBaseUrl?: boolean; + }; +} diff --git a/packages/openapi-python/src/plugins/@hey-api/client-core/utils.ts b/packages/openapi-python/src/plugins/@hey-api/client-core/utils.ts new file mode 100644 index 000000000..e90721a10 --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/client-core/utils.ts @@ -0,0 +1,24 @@ +import type { Config } from '../../../config/types'; +import type { PluginClientNames } from '../../../plugins/types'; + +export function getClientPlugin( + config: Config, +): Config['plugins'][PluginClientNames] & { name: PluginClientNames } { + for (const name of config.pluginOrder) { + const plugin = config.plugins[name]; + if (plugin?.tags?.includes('client')) { + return plugin as Config['plugins'][PluginClientNames] & { + name: PluginClientNames; + }; + } + } + + return { + config: { + // @ts-expect-error + name: '', + }, + // @ts-expect-error + name: '', + }; +} diff --git a/packages/openapi-python/src/plugins/@hey-api/client-httpx/bundle/__init__.py b/packages/openapi-python/src/plugins/@hey-api/client-httpx/bundle/__init__.py new file mode 100644 index 000000000..e20c2299e --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/client-httpx/bundle/__init__.py @@ -0,0 +1,3 @@ +from .client import Client, create_client + +__all__ = ["Client", "create_client"] diff --git a/packages/openapi-python/src/plugins/@hey-api/client-httpx/bundle/client.py b/packages/openapi-python/src/plugins/@hey-api/client-httpx/bundle/client.py new file mode 100644 index 000000000..9dfbfcd0e --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/client-httpx/bundle/client.py @@ -0,0 +1,62 @@ +from typing import Optional +import httpx + + +class BaseClient: + """Base HTTP client using httpx that SDK classes extend.""" + + def __init__(self, client: Optional[httpx.Client] = None, base_url: Optional[str] = None, **kwargs): + if client is not None: + self._client = client + else: + self._client = httpx.Client(base_url=base_url or "", **kwargs) + + @property + def client(self) -> httpx.Client: + """Get the httpx client instance.""" + return self._client + + def request(self, method: str, url: str, **kwargs) -> httpx.Response: + """Make an HTTP request.""" + return self._client.request(method, url, **kwargs) + + def get(self, url: str, **kwargs) -> httpx.Response: + """Make a GET request.""" + return self._client.get(url, **kwargs) + + def post(self, url: str, **kwargs) -> httpx.Response: + """Make a POST request.""" + return self._client.post(url, **kwargs) + + def put(self, url: str, **kwargs) -> httpx.Response: + """Make a PUT request.""" + return self._client.put(url, **kwargs) + + def patch(self, url: str, **kwargs) -> httpx.Response: + """Make a PATCH request.""" + return self._client.patch(url, **kwargs) + + def delete(self, url: str, **kwargs) -> httpx.Response: + """Make a DELETE request.""" + return self._client.delete(url, **kwargs) + + def close(self): + """Close the client.""" + self._client.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + +class Client(BaseClient): + """HTTP client using httpx (alias for BaseClient).""" + + pass + + +def create_client(base_url: Optional[str] = None, **kwargs) -> Client: + """Create a new HTTP client instance.""" + return Client(base_url=base_url, **kwargs) diff --git a/packages/openapi-python/src/plugins/@hey-api/client-httpx/config.ts b/packages/openapi-python/src/plugins/@hey-api/client-httpx/config.ts new file mode 100644 index 000000000..d7e4f7ebc --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/client-httpx/config.ts @@ -0,0 +1,18 @@ +import { definePluginConfig } from '@hey-api/shared'; + +import { clientDefaultConfig, clientDefaultMeta } from '../client-core/config'; +import type { HeyApiClientHttpxPlugin } from './types'; + +export const defaultConfig: HeyApiClientHttpxPlugin['Config'] = { + ...clientDefaultMeta, + config: clientDefaultConfig, + handler() { + // TODO: handler + }, + name: '@hey-api/client-httpx', +}; + +/** + * Type helper for `@hey-api/client-httpx` plugin, returns {@link Plugin.Config} object + */ +export const defineConfig = definePluginConfig(defaultConfig); diff --git a/packages/openapi-python/src/plugins/@hey-api/client-httpx/index.ts b/packages/openapi-python/src/plugins/@hey-api/client-httpx/index.ts new file mode 100644 index 000000000..438cfa332 --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/client-httpx/index.ts @@ -0,0 +1,2 @@ +export { defaultConfig, defineConfig } from './config'; +export type { HeyApiClientHttpxPlugin } from './types'; diff --git a/packages/openapi-python/src/plugins/@hey-api/client-httpx/types.ts b/packages/openapi-python/src/plugins/@hey-api/client-httpx/types.ts new file mode 100644 index 000000000..d1b4b25b2 --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/client-httpx/types.ts @@ -0,0 +1,24 @@ +import type { DefinePlugin, Plugin } from '@hey-api/shared'; + +export type UserConfig = Plugin.Name<'@hey-api/client-httpx'> & { + /** + * Set a default base URL when creating the client? You can set `baseUrl` + * to a string which will be used as the base URL. If your input defines + * server(s), you can set `baseUrl` to a number to pick a specific server + * to use as the base URL. You can disable setting the base URL by setting + * `baseUrl` to `false`. By default, `baseUrl` is `true` and it will try to + * use the first defined server value. If there's none, we won't set a + * base URL. + * + * If the matched URL contains template literals, it will be ignored. + * + * @default true + */ + baseUrl?: string | number | boolean; +}; + +export type Config = Plugin.Name<'@hey-api/client-httpx'> & { + baseUrl: string | number | boolean; +}; + +export type HeyApiClientHttpxPlugin = DefinePlugin; diff --git a/packages/openapi-python/src/plugins/@hey-api/sdk/config.ts b/packages/openapi-python/src/plugins/@hey-api/sdk/config.ts index 6e3617ae7..a88c14669 100644 --- a/packages/openapi-python/src/plugins/@hey-api/sdk/config.ts +++ b/packages/openapi-python/src/plugins/@hey-api/sdk/config.ts @@ -1,38 +1,32 @@ import { definePluginConfig } from '@hey-api/shared'; -// import { resolveExamples } from './examples'; -// import { resolveOperations } from './operations'; +import { resolveOperations } from './operations'; import { handler } from './plugin'; import type { HeyApiSdkPlugin } from './types'; export const defaultConfig: HeyApiSdkPlugin['Config'] = { config: { - auth: true, + // auth: true, client: true, - exportFromIndex: true, + includeInEntry: true, paramsStructure: 'grouped', - responseStyle: 'fields', - transformer: false, - validator: false, - - // Deprecated - kept for backward compatibility - // eslint-disable-next-line sort-keys-fix/sort-keys-fix - response: 'body', + // responseStyle: 'fields', + // transformer: false, + // validator: false, }, handler, name: '@hey-api/python-sdk', - // resolveConfig: (plugin, context) => { - resolveConfig: () => { - // if (plugin.config.client) { - // if (typeof plugin.config.client === 'boolean') { - // plugin.config.client = context.pluginByTag('client', { - // defaultPlugin: '@hey-api/client-httpx', - // }); - // } - // plugin.dependencies.add(plugin.config.client!); - // } else { - // plugin.config.client = false; - // } + resolveConfig: (plugin, context) => { + if (plugin.config.client) { + if (typeof plugin.config.client === 'boolean') { + plugin.config.client = context.pluginByTag('client', { + defaultPlugin: '@hey-api/client-httpx', + }); + } + plugin.dependencies.add(plugin.config.client!); + } else { + plugin.config.client = false; + } // if (plugin.config.transformer) { // if (typeof plugin.config.transformer === 'boolean') { // plugin.config.transformer = context.pluginByTag('transformer'); @@ -64,7 +58,7 @@ export const defaultConfig: HeyApiSdkPlugin['Config'] = { // plugin.config.validator.response = false; // } // plugin.config.examples = resolveExamples(plugin.config, context); - // plugin.config.operations = resolveOperations(plugin.config, context); + plugin.config.operations = resolveOperations(plugin.config, context); }, }; diff --git a/packages/openapi-python/src/plugins/@hey-api/sdk/examples/config.ts b/packages/openapi-python/src/plugins/@hey-api/sdk/examples/config.ts new file mode 100644 index 000000000..9adf93b51 --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/sdk/examples/config.ts @@ -0,0 +1,19 @@ +import type { PluginContext } from '@hey-api/shared'; + +import type { UserConfig } from '../types'; +import type { ExamplesConfig } from './types'; + +type Config = Omit; + +export function resolveExamples(config: Config, context: PluginContext): ExamplesConfig { + return context.valueToObject({ + defaultValue: { + enabled: Boolean(config.examples), + language: 'Python', + }, + mappers: { + boolean: (enabled) => ({ enabled }), + }, + value: config.examples, + }) as ExamplesConfig; +} diff --git a/packages/openapi-python/src/plugins/@hey-api/sdk/examples/index.ts b/packages/openapi-python/src/plugins/@hey-api/sdk/examples/index.ts new file mode 100644 index 000000000..6b6954921 --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/sdk/examples/index.ts @@ -0,0 +1,2 @@ +export { resolveExamples } from './config'; +export type { ExamplesConfig, UserExamplesConfig } from './types'; diff --git a/packages/openapi-python/src/plugins/@hey-api/sdk/examples/types.ts b/packages/openapi-python/src/plugins/@hey-api/sdk/examples/types.ts new file mode 100644 index 000000000..1b3b4c52e --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/sdk/examples/types.ts @@ -0,0 +1,61 @@ +import type { FeatureToggle, IR, LinguistLanguages } from '@hey-api/shared'; +import type { MaybeFunc } from '@hey-api/types'; + +import type { CallArgs, DollarPyDsl, ExampleOptions } from '../../../../py-dsl'; + +export type UserExamplesConfig = Omit & { + /** + * Whether this feature is enabled. + * + * @default true + */ + enabled?: boolean; + /** + * The programming language for the generated examples. + * + * This is used to display the language label in code blocks in + * documentation UIs. + * + * @default 'Python' + */ + language?: LinguistLanguages; + /** + * Example request payload. + */ + payload?: MaybeFunc< + (operation: IR.OperationObject, ctx: DollarPyDsl) => CallArgs | CallArgs[number] + >; + /** + * Transform the generated example string. + * + * @param example The generated example string. + * @param operation The operation the example was generated for. + * @returns The final example string. + */ + transform?: (example: string, operation: IR.OperationObject) => string; +}; + +export type ExamplesConfig = Omit & + FeatureToggle & { + /** + * The programming language for the generated examples. + * + * This is used to display the language label in code blocks in + * documentation UIs. + */ + language: LinguistLanguages; + /** + * Example request payload. + */ + payload?: MaybeFunc< + (operation: IR.OperationObject, ctx: DollarPyDsl) => CallArgs | CallArgs[number] + >; + /** + * Transform the generated example string. + * + * @param example The generated example string. + * @param operation The operation the example was generated for. + * @returns The final example string. + */ + transform?: (example: string, operation: IR.OperationObject) => string; + }; diff --git a/packages/openapi-python/src/plugins/@hey-api/sdk/operations/config.ts b/packages/openapi-python/src/plugins/@hey-api/sdk/operations/config.ts new file mode 100644 index 000000000..5355119cc --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/sdk/operations/config.ts @@ -0,0 +1,67 @@ +import type { OperationsStrategy, PluginContext } from '@hey-api/shared'; + +import type { UserConfig } from '../types'; +import type { OperationsConfig, UserOperationsConfig } from './types'; + +type Config = Omit; + +export function resolveOperations(config: Config, context: PluginContext): OperationsConfig { + return normalizeConfig(config.operations, context); +} + +function normalizeConfig( + input: Exclude | UserOperationsConfig | undefined, + context: PluginContext, +): OperationsConfig { + if (!input || typeof input === 'string' || typeof input === 'function') { + input = { strategy: input }; + } + + const strategy = input.strategy ?? 'single'; + + return context.valueToObject({ + defaultValue: { + container: 'class', + methods: 'instance', + nesting: 'operationId', + nestingDelimiters: /[./]/, + strategy, + strategyDefaultTag: 'default', + }, + mappers: { + object(value) { + value.containerName = context.valueToObject({ + defaultValue: + strategy === 'single' + ? { casing: 'PascalCase', name: 'Sdk' } + : { casing: 'PascalCase' }, + mappers: { + function: (name) => ({ name }), + string: (name) => ({ name }), + }, + value: value.containerName, + }); + value.methodName = context.valueToObject({ + defaultValue: { casing: 'snake_case' }, + mappers: { + function: (name) => ({ name }), + string: (name) => ({ name }), + }, + value: value.methodName, + }); + value.segmentName = context.valueToObject({ + defaultValue: { casing: 'PascalCase' }, + mappers: { + function: (name) => ({ name }), + string: (name) => ({ name }), + }, + value: value.segmentName, + }); + return value; + }, + }, + value: { + ...input, + } as UserOperationsConfig, + }) as OperationsConfig; +} diff --git a/packages/openapi-python/src/plugins/@hey-api/sdk/operations/index.ts b/packages/openapi-python/src/plugins/@hey-api/sdk/operations/index.ts new file mode 100644 index 000000000..a472cd1ec --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/sdk/operations/index.ts @@ -0,0 +1,3 @@ +export { resolveOperations } from './config'; +export { resolveStrategy } from './resolve'; +export type { OperationsConfig, UserOperationsConfig } from './types'; diff --git a/packages/openapi-python/src/plugins/@hey-api/sdk/operations/resolve.ts b/packages/openapi-python/src/plugins/@hey-api/sdk/operations/resolve.ts new file mode 100644 index 000000000..3cedab33c --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/sdk/operations/resolve.ts @@ -0,0 +1,44 @@ +import type { OperationPathStrategy, OperationStructureStrategy } from '@hey-api/shared'; +import { OperationPath, OperationStrategy } from '@hey-api/shared'; + +import type { HeyApiSdkPlugin } from '../types'; + +function resolvePath(plugin: HeyApiSdkPlugin['Instance']): OperationPathStrategy { + if (plugin.config.operations.nesting === 'id') { + return OperationPath.id(); + } + + if (plugin.config.operations.nesting === 'operationId') { + return OperationPath.fromOperationId({ + delimiters: plugin.config.operations.nestingDelimiters, + fallback: OperationPath.id(), + }); + } + + return plugin.config.operations.nesting; +} + +export function resolveStrategy(plugin: HeyApiSdkPlugin['Instance']): OperationStructureStrategy { + // if (plugin.config.operations.strategy === 'flat') { + // return OperationStrategy.flat({ + // path: (operation) => [resolvePath(plugin)(operation).join('.')], + // }); + // } + + if (plugin.config.operations.strategy === 'single') { + const root = plugin.config.operations.containerName; + return OperationStrategy.single({ + path: resolvePath(plugin), + root: typeof root.name === 'string' ? root.name : (root.name?.('') ?? ''), + }); + } + + if (plugin.config.operations.strategy === 'byTags') { + return OperationStrategy.byTags({ + fallback: plugin.config.operations.strategyDefaultTag, + path: resolvePath(plugin), + }); + } + + return plugin.config.operations.strategy; +} diff --git a/packages/openapi-python/src/plugins/@hey-api/sdk/operations/types.ts b/packages/openapi-python/src/plugins/@hey-api/sdk/operations/types.ts new file mode 100644 index 000000000..18d1ea5b8 --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/sdk/operations/types.ts @@ -0,0 +1,178 @@ +import type { + NamingConfig, + NamingRule, + OperationPathStrategy, + OperationsStrategy, +} from '@hey-api/shared'; + +export interface UserOperationsConfig { + /** + * Type of container for grouped operations. + * + * Ignored when `strategy` is `'flat'`. + * + * - `'class'` - Class with methods + * + * @default 'class' + */ + container?: 'class'; + /** + * Customize container names. + * + * For `'single'` strategy, this sets the root container name. + * For `'byTags'` strategy, this transforms tag names. + * + * @default 'Sdk' for `'single'` strategy + * + * @example + * // Set root name for single strategy + * containerName: 'MyApi' + * + * @example + * // Transform tag names with suffix + * containerName: '{{name}}Service' + * + * @example + * // With casing + * containerName: { name: '{{name}}Service', casing: 'PascalCase' } + */ + containerName?: NamingRule; + /** + * Customize method/function names. + * + * Applied to the final segment of the path (the method name). + */ + methodName?: NamingRule; + /** + * How methods are attached to class containers. + * + * Only applies when `container` is `'class'`. + * + * - `'instance'` - Instance methods, requires `new ClassName(config)` + * + * @default 'instance' + */ + methods?: 'instance'; + /** + * How to derive nesting structure from operations. + * + * - `'operationId'` - Split operationId by delimiters (e.g., `users.list` → `Users.list()`) + * - `'id'` - Use operation id as-is, no nesting + * - Custom function for full control + * + * @default 'operationId' + */ + nesting?: 'operationId' | 'id' | OperationPathStrategy; + /** + * Delimiters for splitting operationId. + * + * Only applies when `nesting` is `'operationId'`. + * + * @default /[./]/ + */ + nestingDelimiters?: RegExp; + /** + * Customize nesting segment names. + * + * Applied to intermediate path segments (not the method name). + */ + segmentName?: NamingRule; + /** + * Grouping strategy. + * + * - `'byTags'` - One container per operation tag + * - `'single'` - All operations in one container + * - Custom function for full control + * + * @default 'single' + */ + strategy?: Exclude; + /** + * Default container name for operations without tags. + * + * Only applies when `strategy` is `'byTags'`. + * + * @default 'default' + */ + strategyDefaultTag?: string; +} + +export interface OperationsConfig { + /** + * Type of container for grouped operations. + * + * Ignored when `strategy` is `'flat'`. + * + * - `'class'` - Class with methods + */ + container: 'class'; + /** + * Customize container names. + * + * For `'single'` strategy, this sets the root container name. + * For `'byTags'` strategy, this transforms tag names. + * + * @default 'Sdk' for `'single'` strategy + * + * @example + * // Set root name for single strategy + * containerName: 'MyApi' + * + * @example + * // Transform tag names with suffix + * containerName: '{{name}}Service' + * + * @example + * // With casing + * containerName: { name: '{{name}}Service', case: 'PascalCase' } + */ + containerName: NamingConfig; + /** + * Customize method/function names. + * + * Applied to the final segment of the path (the method name). + */ + methodName: NamingConfig; + /** + * How methods are attached to class containers. + * + * Only applies when `container` is `'class'`. + * + * - `'instance'` - Instance methods, requires `new ClassName(config)` + */ + methods: 'instance'; + /** + * How to derive nesting structure from operations. + * + * - `'operationId'` - Split operationId by delimiters (e.g., `users.list` → `Users.list()`) + * - `'id'` - Use operation id as-is, no nesting + * - Custom function for full control + */ + nesting: 'operationId' | 'id' | OperationPathStrategy; + /** + * Delimiters for splitting operationId. + * + * Only applies when `nesting` is `'operationId'`. + */ + nestingDelimiters: RegExp; + /** + * Customize nesting segment names. + * + * Applied to intermediate path segments (not the method name). + */ + segmentName: NamingConfig; + /** + * Grouping strategy. + * + * - `'byTags'` - One container per operation tag + * - `'single'` - All operations in one container + * - Custom function for full control + */ + strategy: Exclude; + /** + * Default container name for operations without tags. + * + * Only applies when `strategy` is `'byTags'`. + */ + strategyDefaultTag: string; +} diff --git a/packages/openapi-python/src/plugins/@hey-api/sdk/shared/class.ts b/packages/openapi-python/src/plugins/@hey-api/sdk/shared/class.ts new file mode 100644 index 000000000..6def31fcf --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/sdk/shared/class.ts @@ -0,0 +1,2 @@ +export const createRegistryClass = () => {}; +export const createClientClass = () => {}; diff --git a/packages/openapi-python/src/plugins/@hey-api/sdk/types.ts b/packages/openapi-python/src/plugins/@hey-api/sdk/types.ts index d351c828a..4eb9b25f3 100644 --- a/packages/openapi-python/src/plugins/@hey-api/sdk/types.ts +++ b/packages/openapi-python/src/plugins/@hey-api/sdk/types.ts @@ -1,14 +1,13 @@ -import type { IndexExportOption, NameTransformer } from '@hey-api/shared'; -import type { DefinePlugin, Plugin } from '@hey-api/shared'; +import type { DefinePlugin, OperationsStrategy, Plugin } from '@hey-api/shared'; -// import type { OperationsStrategy } from '@hey-api/shared'; -import type { PluginClientNames, PluginValidatorNames } from '../../../plugins/types'; - -// import type { ExamplesConfig, UserExamplesConfig } from './examples'; -// import type { OperationsConfig, UserOperationsConfig } from './operations'; +import type { PluginClientNames } from '../../../plugins/types'; +// import type { PluginClientNames, PluginValidatorNames } from '../../../plugins/types'; +import type { ExamplesConfig, UserExamplesConfig } from './examples'; +import type { OperationsConfig, UserOperationsConfig } from './operations'; export type UserConfig = Plugin.Name<'@hey-api/python-sdk'> & - Plugin.Hooks & { + Plugin.Hooks & + Plugin.UserExports & { /** * Should the generated functions contain auth mechanisms? You may want to * disable this option if you're handling auth yourself or defining it @@ -16,7 +15,7 @@ export type UserConfig = Plugin.Name<'@hey-api/python-sdk'> & * * @default true */ - auth?: boolean; + // auth?: boolean; /** * Use an internal client instance to send HTTP requests? This is useful if * you don't want to manually pass the client to each SDK function. @@ -24,7 +23,7 @@ export type UserConfig = Plugin.Name<'@hey-api/python-sdk'> & * You can customize the selected client output through its plugin. You can * also set `client` to `true` to automatically choose the client from your * defined plugins. If we can't detect a client plugin when using `true`, we - * will default to `@hey-api/client-fetch`. + * will default to `@hey-api/client-httpx`. * * @default true */ @@ -38,27 +37,20 @@ export type UserConfig = Plugin.Name<'@hey-api/python-sdk'> & * * @default false */ - // examples?: boolean | UserExamplesConfig; - /** - * Whether exports should be re-exported in the index file. - * - * @default true - */ - exportFromIndex?: boolean; + examples?: boolean | UserExamplesConfig; /** * Define the structure of generated SDK operations. * * String shorthand: * - `'byTags'` – one container per operation tag - * - `'flat'` – standalone functions, no container * - `'single'` – all operations in a single container * - custom function for full control * * Use the object form for advanced configuration. * - * @default 'flat' + * @default 'single' */ - // operations?: OperationsStrategy | UserOperationsConfig; + operations?: Exclude | UserOperationsConfig; /** * Define how request parameters are structured in generated SDK methods. * @@ -77,7 +69,7 @@ export type UserConfig = Plugin.Name<'@hey-api/python-sdk'> & * * @default 'fields' */ - responseStyle?: 'data' | 'fields'; + // responseStyle?: 'data' | 'fields'; /** * Transform response data before returning. This is useful if you want to * convert for example ISO strings into Date objects. However, transformation @@ -89,7 +81,7 @@ export type UserConfig = Plugin.Name<'@hey-api/python-sdk'> & * * @default false */ - transformer?: '@hey-api/transformers' | boolean; + // transformer?: PluginTransformerNames | boolean; /** * Validate request and/or response data against schema before returning. * This is useful if you want to ensure the request and/or response conforms @@ -108,99 +100,34 @@ export type UserConfig = Plugin.Name<'@hey-api/python-sdk'> & * * @default false */ - validator?: - | PluginValidatorNames - | boolean - | { - /** - * Validate request data against schema before sending. - * - * Can be a validator plugin name or boolean (true to auto-select, false - * to disable). - * - * @default false - */ - request?: PluginValidatorNames | boolean; - /** - * Validate response data against schema before returning. - * - * Can be a validator plugin name or boolean (true to auto-select, false - * to disable). - * - * @default false - */ - response?: PluginValidatorNames | boolean; - }; - - // DEPRECATED OPTIONS BELOW - - /** - * Group operation methods into classes? When enabled, you can select which - * classes to export with `sdk.include` and/or transform their names with - * `sdk.classNameBuilder`. - * - * Note that by enabling this option, your SDKs will **NOT** - * support {@link https://developer.mozilla.org/docs/Glossary/Tree_shaking tree-shaking}. - * For this reason, it is disabled by default. - * - * @deprecated Use `operations: { strategy: "byTags" }` or `operations: { strategy: "single" }` instead. - * @default false - */ - // eslint-disable-next-line typescript-sort-keys/interface - asClass?: boolean; - /** - * Customize the generated class names. The name variable is obtained from - * your OpenAPI specification tags or `instance` value. - * - * This option has no effect if `sdk.asClass` is `false`. - * - * @deprecated Use `operations: { containerName: "..." }` instead. - */ - classNameBuilder?: NameTransformer; - /** - * How should we structure your SDK? By default, we try to infer the ideal - * structure using `operationId` keywords. If you prefer a flatter structure, - * you can set `classStructure` to `off` to disable this behavior. - * - * @deprecated Use `operations: { nesting: "operationId" }` or `operations: { nesting: "id" }` instead. - * @default 'auto' - */ - classStructure?: 'auto' | 'off'; - /** - * Set `instance` to create an instantiable SDK. Using `true` will use the - * default instance name; in practice, you want to define your own by passing - * a string value. - * - * @deprecated Use `operations: { strategy: "single", containerName: "Name", methods: "instance" }` instead. - * @default false - */ - instance?: string | boolean; - /** - * Customise the name of methods within the service. By default, - * `operation.id` is used. - * - * @deprecated Use `operations: { methodName: "..." }` instead. - */ - methodNameBuilder?: NameTransformer; - /** - * Use operation ID to generate operation names? - * - * @deprecated Use `operations: { nesting: "operationId" }` or `operations: { nesting: "id" }` instead. - * @default true - */ - operationId?: boolean; - /** - * Define shape of returned value from service calls - * - * @deprecated - * @default 'body' - */ - response?: 'body' | 'response'; + // validator?: + // | PluginValidatorNames + // | boolean + // | { + // /** + // * Validate request data against schema before sending. + // * + // * Can be a validator plugin name or boolean (true to auto-select, false + // * to disable). + // * + // * @default false + // */ + // request?: PluginValidatorNames | boolean; + // /** + // * Validate response data against schema before returning. + // * + // * Can be a validator plugin name or boolean (true to auto-select, false + // * to disable). + // * + // * @default false + // */ + // response?: PluginValidatorNames | boolean; + // }; }; export type Config = Plugin.Name<'@hey-api/python-sdk'> & Plugin.Hooks & - IndexExportOption & { + Plugin.Exports & { /** * Should the generated functions contain auth mechanisms? You may want to * disable this option if you're handling auth yourself or defining it @@ -208,7 +135,7 @@ export type Config = Plugin.Name<'@hey-api/python-sdk'> & * * @default true */ - auth: boolean; + // auth: boolean; /** * Use an internal client instance to send HTTP requests? This is useful if * you don't want to manually pass the client to each SDK function. @@ -216,7 +143,7 @@ export type Config = Plugin.Name<'@hey-api/python-sdk'> & * You can customize the selected client output through its plugin. You can * also set `client` to `true` to automatically choose the client from your * defined plugins. If we can't detect a client plugin when using `true`, we - * will default to `@hey-api/client-fetch`. + * will default to `@hey-api/client-httpx`. * * @default true */ @@ -224,11 +151,11 @@ export type Config = Plugin.Name<'@hey-api/python-sdk'> & /** * Configuration for generating SDK code examples. */ - // examples: ExamplesConfig; + examples: ExamplesConfig; /** * Define the structure of generated SDK operations. */ - // operations: OperationsConfig; + operations: OperationsConfig; /** * Define how request parameters are structured in generated SDK methods. * @@ -247,7 +174,7 @@ export type Config = Plugin.Name<'@hey-api/python-sdk'> & * * @default 'fields' */ - responseStyle: 'data' | 'fields'; + // responseStyle: 'data' | 'fields'; /** * Transform response data before returning. This is useful if you want to * convert for example ISO strings into Date objects. However, transformation @@ -259,38 +186,27 @@ export type Config = Plugin.Name<'@hey-api/python-sdk'> & * * @default false */ - transformer: '@hey-api/transformers' | false; + // transformer: PluginTransformerNames | false; /** * Validate request and/or response data against schema before returning. * This is useful if you want to ensure the request and/or response conforms * to a desired shape. However, validation adds runtime overhead, so it's * not recommended to use unless absolutely necessary. */ - validator: { - /** - * The validator plugin to use for request validation, or false to disable. - * - * @default false - */ - request: PluginValidatorNames | false; - /** - * The validator plugin to use for response validation, or false to disable. - * - * @default false - */ - response: PluginValidatorNames | false; - }; - - // DEPRECATED OPTIONS BELOW - - /** - * Define shape of returned value from service calls - * - * @deprecated - * @default 'body' - */ - // eslint-disable-next-line typescript-sort-keys/interface - response: 'body' | 'response'; + // validator: { + // /** + // * The validator plugin to use for request validation, or false to disable. + // * + // * @default false + // */ + // request: PluginValidatorNames | false; + // /** + // * The validator plugin to use for response validation, or false to disable. + // * + // * @default false + // */ + // response: PluginValidatorNames | false; + // }; }; export type HeyApiSdkPlugin = DefinePlugin; diff --git a/packages/openapi-python/src/plugins/@hey-api/sdk/v1/node.ts b/packages/openapi-python/src/plugins/@hey-api/sdk/v1/node.ts new file mode 100644 index 000000000..904f5b9ca --- /dev/null +++ b/packages/openapi-python/src/plugins/@hey-api/sdk/v1/node.ts @@ -0,0 +1,203 @@ +import type { + StructureItem, + StructureNode, + StructureShell, + Symbol, + SymbolMeta, +} from '@hey-api/codegen-core'; +import type { IR } from '@hey-api/shared'; +import { applyNaming, toCase } from '@hey-api/shared'; + +import { $ } from '../../../../py-dsl'; +import { createOperationComment } from '../../../shared/utils/operation'; +import type { HeyApiSdkPlugin } from '../types'; + +export interface OperationItem { + operation: IR.OperationObject; + path: ReadonlyArray; + tags: ReadonlyArray | undefined; +} + +export const source = globalThis.Symbol('@hey-api/python-sdk'); + +function attachComment>(args: { + node: T; + operation: IR.OperationObject; +}): T { + const { node, operation } = args; + return node.$if(createOperationComment(operation), (n, v) => n.doc(v)) as T; +} + +function createShellMeta(node: StructureNode): SymbolMeta { + return { + category: 'utility', + resource: 'class', + resourceId: node.getPath().join('.'), + tool: 'sdk', + }; +} + +function createFnSymbol( + plugin: HeyApiSdkPlugin['Instance'], + item: StructureItem & { data: OperationItem }, +): Symbol { + const { operation, path, tags } = item.data; + const name = item.location[item.location.length - 1]!; + return plugin.symbol(applyNaming(name, plugin.config.operations.methodName), { + meta: { + category: 'sdk', + path, + resource: 'operation', + resourceId: operation.id, + tags, + tool: 'sdk', + }, + }); +} + +function childToNode( + resource: StructureNode, + plugin: HeyApiSdkPlugin['Instance'], +): ReadonlyArray> { + const refChild = plugin.referenceSymbol(createShellMeta(resource)); + const memberNameStr = toCase( + refChild.name, + plugin.config.operations.methodName.casing ?? 'camelCase', + ); + const memberName = plugin.symbol(memberNameStr); + return [ + $.func( + memberName, + (f) => f.returns('None'), + // f.returns(refChild).do( + // $('this') + // .attr(privateName) + // .nullishAssign( + // $.new(refChild).args($.object().prop('client', $('this').attr('client'))), + // ) + // .return(), + // ), + ), + ]; +} + +export function createShell(plugin: HeyApiSdkPlugin['Instance']): StructureShell { + return { + define: (node) => { + const symbol = plugin.symbol( + applyNaming( + node.name, + node.isRoot + ? plugin.config.operations.containerName + : plugin.config.operations.segmentName, + ), + { + meta: createShellMeta(node), + }, + ); + + const symbolClient = plugin.external('client.Client'); + + const c = $.class(symbol).extends(symbolClient); + + const dependencies: Array> = []; + + return { dependencies, node: c }; + }, + }; +} + +function implementFn>(args: { + node: T; + operation: IR.OperationObject; + plugin: HeyApiSdkPlugin['Instance']; +}): T { + const { node, operation } = args; + + // Build the URL path + const path = operation.path; + + // Get the HTTP method (default to GET) + const method = operation.method?.toLowerCase() || 'get'; + + // Create the client call expression: self.client.get(path) + // self is the Python equivalent of 'this' for instance methods + const selfExpr = $.id('self'); + const clientExpr = $.attr(selfExpr, 'client'); + const methodExpr = $.attr(clientExpr, method); + const clientCall = $.call(methodExpr, $.literal(path)); + + // Return the client call + node.do($.return(clientCall)); + + return node; +} + +export function toNode( + model: StructureNode, + plugin: HeyApiSdkPlugin['Instance'], +): { + dependencies?: Array>; + nodes: ReadonlyArray>; +} { + if (model.virtual) { + const nodes: Array> = []; + for (const item of model.itemsFrom(source)) { + const fnName = applyNaming( + String(item.location[item.location.length - 1]), + plugin.config.operations.methodName, + ); + const node = $.func(fnName); + node.do($.return($.id('None'))); + nodes.push(node); + } + return { nodes }; + } + + if (!model.shell) { + return { nodes: [] }; + } + + const nodes: Array> = []; + const shell = model.shell.define(model); + const node = shell.node as ReturnType; + + let index = 0; + for (const item of model.itemsFrom(source)) { + const { operation } = item.data; + if (node['~dsl'] === 'FuncPyDsl') { + // TODO: function? + } else { + if (index > 0 || node.hasBody) node.newline(); + const method = implementFn({ + node: $.func(createFnSymbol(plugin, item), (m) => + attachComment({ + node: m, + operation, + }), + ), + operation, + plugin, + }); + node.do(method); + // exampleIntent(method, operation, plugin); + } + index += 1; + } + + for (const child of model.children.values()) { + if (node['~dsl'] === 'FuncPyDsl') { + // TODO: function? + } else { + if (node.hasBody) node.newline(); + node.do(...childToNode(child, plugin)); + } + } + + nodes.push(node); + + return { + dependencies: shell.dependencies as Array>, + nodes, + }; +} diff --git a/packages/openapi-python/src/plugins/@hey-api/sdk/v1/plugin.ts b/packages/openapi-python/src/plugins/@hey-api/sdk/v1/plugin.ts index 99202b9b6..c941d5662 100644 --- a/packages/openapi-python/src/plugins/@hey-api/sdk/v1/plugin.ts +++ b/packages/openapi-python/src/plugins/@hey-api/sdk/v1/plugin.ts @@ -1,6 +1,59 @@ +import { StructureModel } from '@hey-api/codegen-core'; + +import { getTypedConfig } from '../../../../config/utils'; +import { clientFolderAbsolutePath } from '../../../../generate/client'; +import type { $ } from '../../../../py-dsl'; +import { getClientPlugin } from '../../client-core/utils'; +import { resolveStrategy } from '../operations'; import type { HeyApiSdkPlugin } from '../types'; +import { createShell, type OperationItem, source, toNode } from './node'; export const handlerV1: HeyApiSdkPlugin['Handler'] = ({ plugin }) => { - console.log(plugin); - // plugin.node() + const clientModule = clientFolderAbsolutePath(getTypedConfig(plugin)); + const client = getClientPlugin(getTypedConfig(plugin)); + plugin.symbol('Client', { + external: clientModule, + meta: { + category: 'external', + resource: 'client.Client', + tool: client.name, + }, + }); + + const structure = new StructureModel(); + const shell = createShell(plugin); + const strategy = resolveStrategy(plugin); + + plugin.forEach( + 'operation', + (event) => { + structure.insert({ + data: { + operation: event.operation, + path: event._path, + tags: event.tags, + } satisfies OperationItem, + locations: strategy(event.operation).map((path) => ({ path, shell })), + source, + }); + }, + { order: 'declarations' }, + ); + + const allDependencies: Array> = []; + const allNodes: Array> = []; + + for (const node of structure.walk()) { + const { dependencies, nodes } = toNode(node, plugin); + allDependencies.push(...(dependencies ?? [])); + allNodes.push(...nodes); + } + + for (const dep of allDependencies) { + plugin.node(dep); + } + + for (const node of allNodes) { + plugin.node(node); + } }; diff --git a/packages/openapi-python/src/plugins/config.ts b/packages/openapi-python/src/plugins/config.ts index 0747cc04f..9910b6266 100644 --- a/packages/openapi-python/src/plugins/config.ts +++ b/packages/openapi-python/src/plugins/config.ts @@ -1,9 +1,11 @@ import type { Plugin, PluginConfigMap, PluginNames } from '@hey-api/shared'; +import { defaultConfig as heyApiClientHttpx } from '../plugins/@hey-api/client-httpx'; import { defaultConfig as heyApiSdk } from '../plugins/@hey-api/sdk'; export const defaultPluginConfigs: { [K in PluginNames]: Plugin.Config; } = { + '@hey-api/client-httpx': heyApiClientHttpx, '@hey-api/python-sdk': heyApiSdk, }; diff --git a/packages/openapi-python/src/plugins/shared/utils/operation.ts b/packages/openapi-python/src/plugins/shared/utils/operation.ts new file mode 100644 index 000000000..9cb5a2e81 --- /dev/null +++ b/packages/openapi-python/src/plugins/shared/utils/operation.ts @@ -0,0 +1,31 @@ +import type { IR } from '@hey-api/shared'; +import { escapeComment } from '@hey-api/shared'; + +export function createOperationComment( + operation: IR.OperationObject, +): ReadonlyArray | undefined { + const comments: Array = []; + + if (operation.summary) { + comments.push(escapeComment(operation.summary)); + } + + if (operation.description) { + if (comments.length) { + comments.push(''); // Add an empty line between summary and description + } + + comments.push(escapeComment(operation.description)); + } + + if (operation.deprecated) { + if (comments.length) { + comments.push(''); // Add an empty line before deprecated + } + + // TODO: smarter deprecation message + comments.push('Deprecated.'); + } + + return comments.length ? comments : undefined; +} diff --git a/packages/openapi-python/src/plugins/types.ts b/packages/openapi-python/src/plugins/types.ts index 5cfcf3d7d..1e18293c7 100644 --- a/packages/openapi-python/src/plugins/types.ts +++ b/packages/openapi-python/src/plugins/types.ts @@ -6,4 +6,6 @@ export type PluginClientNames = export type PluginMockNames = 'factory_boy' | 'faker' | 'mimesis'; +export type PluginTransformerNames = never; + export type PluginValidatorNames = 'attrs' | 'dataclasses' | 'marshmallow' | 'pydantic'; diff --git a/packages/openapi-python/src/py-dsl/base.ts b/packages/openapi-python/src/py-dsl/base.ts new file mode 100644 index 000000000..58e92362e --- /dev/null +++ b/packages/openapi-python/src/py-dsl/base.ts @@ -0,0 +1,270 @@ +// TODO: symbol should be protected, but needs to be public to satisfy types +import type { + AnalysisContext, + File, + FromRef, + Language, + Node, + NodeName, + NodeNameSanitizer, + NodeRelationship, + NodeScope, + Ref, + Symbol, +} from '@hey-api/codegen-core'; +import { fromRef, isNode, isRef, isSymbol, nodeBrand, ref } from '@hey-api/codegen-core'; +import type { AnyString } from '@hey-api/types'; + +import { py } from '../ts-python'; +import type { AccessOptions } from './utils/context'; + +export abstract class PyDsl implements Node { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + analyze(_: AnalysisContext): void {} + clone(): this { + const cloned = Object.create(Object.getPrototypeOf(this)); + Object.assign(cloned, this); + return cloned; + } + exported?: boolean; + file?: File; + get name(): Node['name'] { + return { + ...this._name, + set: (value) => { + this._name = ref(value); + if (isSymbol(value)) { + value.setNode(this); + } + }, + toString: () => (this._name ? this.$name(this._name) : ''), + } as Node['name']; + } + readonly nameSanitizer?: NodeNameSanitizer; + language: Language = 'python'; + parent?: Node; + root: boolean = false; + scope?: NodeScope = 'value'; + structuralChildren?: Map; + structuralParents?: Map; + symbol?: Symbol; + toAst(): T { + return undefined as unknown as T; + } + readonly '~brand' = nodeBrand; + + /** Branding property to identify the DSL class at runtime. */ + abstract readonly '~dsl': AnyString; + + /** Conditionally applies a callback to this builder. */ + $if( + this: T, + value: V, + ifTrue: (self: T, v: Exclude) => R | void, + ifFalse?: (self: T, v: Extract) => R | void, + ): R | T; + $if( + this: T, + value: V, + ifTrue: (v: Exclude) => R | void, + ifFalse?: (v: Extract) => R | void, + ): R | T; + $if( + this: T, + value: V, + ifTrue: () => R | void, + ifFalse?: () => R | void, + ): R | T; + $if( + this: T, + value: V, + ifTrue: any, + ifFalse?: any, + ): R | T { + if (value) { + // Try calling with (self, value), then (value), then () + let result: R | void | undefined; + try { + result = ifTrue?.(this, value as Exclude); + } catch { + // ignore and try other signatures + } + if (result === undefined) { + try { + result = ifTrue?.(value as Exclude); + } catch { + // ignore and try zero-arg + } + } + if (result === undefined) { + try { + result = ifTrue?.(); + } catch { + // swallow + } + } + return (result ?? this) as R | T; + } + if (ifFalse) { + let result: R | void | undefined; + try { + result = ifFalse?.(this, value as Extract); + } catch { + // ignore + } + if (result === undefined) { + try { + result = ifFalse?.(value as Extract); + } catch { + // ignore + } + } + if (result === undefined) { + try { + result = ifFalse?.(); + } catch { + // ignore + } + } + return (result ?? this) as R | T; + } + return this; + } + + /** Access patterns for this node. */ + toAccessNode?( + node: this, + options: AccessOptions, + ctx: { + /** The full chain. */ + chain: ReadonlyArray; + /** Position in the chain (0 = root). */ + index: number; + /** Is this the leaf node? */ + isLeaf: boolean; + /** Is this the root node? */ + isRoot: boolean; + /** Total length of the chain. */ + length: number; + }, + ): PyDsl | undefined; + + protected $maybeId( + expr: T, + ): T extends string ? py.Identifier : T { + return (typeof expr === 'string' ? py.factory.createIdentifier(expr) : expr) as T extends string + ? py.Identifier + : T; + } + + protected $name(name: Ref): string { + const value = fromRef(name); + if (isSymbol(value)) { + try { + return value.finalName; + } catch { + return value.name; + } + } + return String(value); + } + + protected $node(value: I): NodeOfMaybe { + if (value === undefined) { + return undefined as NodeOfMaybe; + } + // @ts-expect-error + if (isRef(value)) value = fromRef(value); + if (isSymbol(value)) { + return this.$maybeId(value.finalName) as NodeOfMaybe; + } + if (typeof value === 'string') { + return this.$maybeId(value) as NodeOfMaybe; + } + if (value instanceof Array) { + return value.map((item) => { + if (isRef(item)) item = fromRef(item); + return this.unwrap(item); + }) as NodeOfMaybe; + } + return this.unwrap(value as any) as NodeOfMaybe; + } + + // protected $type(value: I, args?: ReadonlyArray): TypeOfMaybe { + // if (value === undefined) { + // return undefined as TypeOfMaybe; + // } + // // @ts-expect-error + // if (isRef(value)) value = fromRef(value); + // if (isSymbol(value)) { + // return ts.factory.createTypeReferenceNode(value.finalName, args) as TypeOfMaybe; + // } + // if (typeof value === 'string') { + // return ts.factory.createTypeReferenceNode(value, args) as TypeOfMaybe; + // } + // if (typeof value === 'boolean') { + // const literal = value ? ts.factory.createTrue() : ts.factory.createFalse(); + // return ts.factory.createLiteralTypeNode(literal) as TypeOfMaybe; + // } + // if (typeof value === 'number') { + // return ts.factory.createLiteralTypeNode( + // ts.factory.createNumericLiteral(value), + // ) as TypeOfMaybe; + // } + // if (value instanceof Array) { + // return value.map((item) => this.$type(item, args)) as TypeOfMaybe; + // } + // return this.unwrap(value as any) as TypeOfMaybe; + // } + + private _name?: Ref; + + /** Unwraps nested nodes into raw Python AST. */ + private unwrap(value: I): I extends PyDsl ? N : I { + return (isNode(value) ? value.toAst() : value) as I extends PyDsl ? N : I; + } +} + +type NodeOfMaybe = undefined extends I + ? NodeOf>> | undefined + : NodeOf>; + +type NodeOf = + I extends ReadonlyArray + ? ReadonlyArray ? N : U> + : I extends string + ? py.Expression + : I extends PyDsl + ? N + : I extends py.Node + ? I + : never; + +export type MaybePyDsl = + T extends PyDsl ? U | PyDsl : T extends py.Node ? T | PyDsl : never; + +// export abstract class TypePyDsl< +// T extends +// | ts.LiteralTypeNode +// | ts.QualifiedName +// | ts.TypeElement +// | ts.TypeNode +// | ts.TypeParameterDeclaration = ts.TypeNode, +// > extends PyDsl {} + +// type TypeOfMaybe = undefined extends I +// ? TypeOf>> | undefined +// : TypeOf>; + +// type TypeOf = +// I extends ReadonlyArray +// ? ReadonlyArray> +// : I extends string +// ? ts.TypeNode +// : I extends boolean +// ? ts.LiteralTypeNode +// : I extends PyDsl +// ? N +// : I extends ts.TypeNode +// ? I +// : never; diff --git a/packages/openapi-python/src/py-dsl/decl/class.ts b/packages/openapi-python/src/py-dsl/decl/class.ts new file mode 100644 index 000000000..fefe46d65 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/decl/class.ts @@ -0,0 +1,118 @@ +import type { AnalysisContext, NodeName } from '@hey-api/codegen-core'; +import { isSymbol } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import { type MaybePyDsl, PyDsl } from '../base'; +import { NewlinePyDsl } from '../layout/newline'; +import { DecoratorMixin } from '../mixins/decorator'; +import { DocMixin } from '../mixins/doc'; +import { LayoutMixin } from '../mixins/layout'; +import { safeRuntimeName } from '../utils/name'; + +type Body = Array>; + +const Mixed = DecoratorMixin(DocMixin(LayoutMixin(PyDsl))); + +export class ClassPyDsl extends Mixed { + readonly '~dsl' = 'ClassPyDsl'; + override readonly nameSanitizer = safeRuntimeName; + + protected baseClasses: Array = []; + protected body: Body = []; + + constructor(name: NodeName) { + super(); + this.name.set(name); + if (isSymbol(name)) { + name.setKind('class'); + } + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + for (const baseClass of this.baseClasses) { + ctx.analyze(baseClass); + } + ctx.analyze(this.name); + ctx.pushScope(); + try { + for (const item of this.body) { + ctx.analyze(item); + } + } finally { + ctx.popScope(); + } + } + + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + + /** Returns true if the class has any members. */ + get hasBody(): boolean { + return this.body.length > 0; + } + + /** Adds one or more class members (fields, methods, etc.). */ + do(...items: Body): this { + this.body.push(...items); + return this; + } + + /** Records base classes to extend from. */ + extends(...baseClass: ReadonlyArray): this { + this.baseClasses.push(...baseClass); + return this; + } + + /** Inserts an empty line between members for formatting. */ + newline(): this { + this.body.push(new NewlinePyDsl()); + return this; + } + + override toAst(): py.ClassDeclaration { + this.$validate(); + // const uniqueClasses: Array = []; + + // for (const base of baseClass) { + // let expr: py.Expression; + // if (typeof base === 'string' || base instanceof PyDsl) { + // expr = this.$node(base) as py.Expression; + // } else if (isSymbol(base)) { + // expr = py.factory.createIdentifier(base.finalName); + // } + + // // Avoid duplicates by checking if already added + // const exists = uniqueClasses.some((existing) => { + // const existingExpr = this.$node(existing) as py.Expression; + // return existingExpr === expr; + // }); + + // if (!exists) { + // uniqueClasses.push(expr); + // } + // } + + return py.factory.createClassDeclaration( + this.name.toString(), + this.$node(this.body) as ReadonlyArray, + this.$decorators(), + this.baseClasses.map((c) => this.$node(c)), + this.$docs(), + ); + } + + $validate(): asserts this { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Class declaration missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this.name.toString()) missing.push('name'); + return missing; + } +} diff --git a/packages/openapi-python/src/py-dsl/decl/func.ts b/packages/openapi-python/src/py-dsl/decl/func.ts new file mode 100644 index 000000000..9ae05ad93 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/decl/func.ts @@ -0,0 +1,89 @@ +import type { AnalysisContext, NodeName } from '@hey-api/codegen-core'; +import { isSymbol } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +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, ModifiersMixin } from '../mixins/modifiers'; +import { safeRuntimeName } from '../utils/name'; + +const Mixed = DecoratorMixin( + DocMixin(DoMixin(LayoutMixin(AsyncMixin(ModifiersMixin(PyDsl))))), +); + +export class FuncPyDsl extends Mixed { + readonly '~dsl' = 'FuncPyDsl'; + override readonly nameSanitizer = safeRuntimeName; + + protected _parameters: Array = []; + protected _returnType?: py.Expression; + + constructor(name: NodeName, fn?: (f: FuncPyDsl) => 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(); + } + for (const param of this._parameters) { + ctx.analyze(param); + } + ctx.analyze(this._returnType); + } + + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + + param(name: string, configure?: (p: py.FunctionParameter) => void): this { + const param = py.factory.createFunctionParameter(name, undefined, undefined, undefined); + if (configure) configure(param); + this._parameters.push(param); + return this; + } + + returns(returnType: string | py.Expression): this { + this._returnType = + typeof returnType === 'string' ? py.factory.createIdentifier(returnType) : returnType; + return this; + } + + override toAst(): py.FunctionDeclaration { + this.$validate(); + return py.factory.createFunctionDeclaration( + this.name.toString(), + this._parameters, + this._returnType ? (this.$node(this._returnType) as py.Expression) : undefined, + this.$do(), + this.$decorators(), + this.$docs(), + this.modifiers, + ); + } + + $validate(): asserts this { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Function declaration missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this.name.toString()) missing.push('name'); + return missing; + } +} diff --git a/packages/openapi-python/src/py-dsl/expr/binary.ts b/packages/openapi-python/src/py-dsl/expr/binary.ts new file mode 100644 index 000000000..8454f5a0a --- /dev/null +++ b/packages/openapi-python/src/py-dsl/expr/binary.ts @@ -0,0 +1,168 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; + +export type PyBinaryOperator = + | '+' + | '-' + | '*' + | '/' + | '//' + | '%' + | '**' + | '==' + | '!=' + | '>' + | '>=' + | '<' + | '<=' + | 'is' + | 'is not' + | 'in' + | 'not in' + | 'and' + | 'or'; + +const Mixed = PyDsl; + +export class BinaryPyDsl extends Mixed { + readonly '~dsl' = 'BinaryPyDsl'; + + protected _left?: MaybePyDsl; + protected _op?: PyBinaryOperator; + protected _right?: MaybePyDsl; + + constructor( + left: MaybePyDsl, + op: PyBinaryOperator, + right: MaybePyDsl, + ) { + super(); + this._left = left; + this._op = op; + this._right = right; + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + ctx.analyze(this._left); + ctx.analyze(this._right); + } + + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + + and(right: MaybePyDsl): this { + return this.opAndExpr('and', right); + } + + div(right: MaybePyDsl): this { + return this.opAndExpr('/', right); + } + + eq(right: MaybePyDsl): this { + return this.opAndExpr('==', right); + } + + floordiv(right: MaybePyDsl): this { + return this.opAndExpr('//', right); + } + + gt(right: MaybePyDsl): this { + return this.opAndExpr('>', right); + } + + gte(right: MaybePyDsl): this { + return this.opAndExpr('>=', right); + } + + in_(right: MaybePyDsl): this { + return this.opAndExpr('in', right); + } + + is(right: MaybePyDsl): this { + return this.opAndExpr('is', right); + } + + isNot(right: MaybePyDsl): this { + return this.opAndExpr('is not', right); + } + + lt(right: MaybePyDsl): this { + return this.opAndExpr('<', right); + } + + lte(right: MaybePyDsl): this { + return this.opAndExpr('<=', right); + } + + minus(right: MaybePyDsl): this { + return this.opAndExpr('-', right); + } + + mod(right: MaybePyDsl): this { + return this.opAndExpr('%', right); + } + + neq(right: MaybePyDsl): this { + return this.opAndExpr('!=', right); + } + + notIn(right: MaybePyDsl): this { + return this.opAndExpr('not in', right); + } + + or(right: MaybePyDsl): this { + return this.opAndExpr('or', right); + } + + plus(right: MaybePyDsl): this { + return this.opAndExpr('+', right); + } + + pow(right: MaybePyDsl): this { + return this.opAndExpr('**', right); + } + + times(right: MaybePyDsl): this { + return this.opAndExpr('*', right); + } + + override toAst(): py.BinaryExpression { + this.$validate(); + + return py.factory.createBinaryExpression( + this.$node(this._left!) as py.Expression, + this._op!, + this.$node(this._right!) as py.Expression, + ); + } + + $validate(): asserts this is this & { + _left: MaybePyDsl; + _op: PyBinaryOperator; + _right: MaybePyDsl; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Binary expression missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._left) missing.push('left operand'); + if (!this._op) missing.push('operator'); + if (!this._right) missing.push('right operand'); + return missing; + } + + private opAndExpr(op: PyBinaryOperator, right: MaybePyDsl): this { + this._right = right; + this._op = op; + return this; + } +} diff --git a/packages/openapi-python/src/py-dsl/expr/call.ts b/packages/openapi-python/src/py-dsl/expr/call.ts new file mode 100644 index 000000000..12127918f --- /dev/null +++ b/packages/openapi-python/src/py-dsl/expr/call.ts @@ -0,0 +1,69 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; + +export type CallArgs = ReadonlyArray | undefined>; + +const Mixed = PyDsl; + +export class CallPyDsl extends Mixed { + readonly '~dsl' = 'CallPyDsl'; + + protected _args: Array | undefined> = []; + protected _callee?: MaybePyDsl; + + constructor(callee: MaybePyDsl, ...args: CallArgs) { + super(); + this._callee = callee; + this._args = [...args]; + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + ctx.analyze(this._callee); + for (const arg of this._args) { + if (arg) ctx.analyze(arg); + } + } + + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + + arg(arg: MaybePyDsl): this { + this._args.push(arg); + return this; + } + + args(...args: CallArgs): this { + this._args.push(...args); + return this; + } + + override toAst(): py.CallExpression { + this.$validate(); + + const astArgs = this._args + .filter((a): a is MaybePyDsl => a !== undefined) + .map((arg) => this.$node(arg) as py.Expression); + + return py.factory.createCallExpression(this.$node(this._callee!) as py.Expression, astArgs); + } + + $validate(): asserts this is this & { + _callee: MaybePyDsl; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Call expression missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._callee) missing.push('callee'); + return missing; + } +} diff --git a/packages/openapi-python/src/py-dsl/expr/dict.ts b/packages/openapi-python/src/py-dsl/expr/dict.ts new file mode 100644 index 000000000..7e669c1e6 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/expr/dict.ts @@ -0,0 +1,52 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; +import { LayoutMixin } from '../mixins/layout'; + +const Mixed = LayoutMixin(PyDsl); + +export class DictPyDsl extends Mixed { + readonly '~dsl' = 'DictPyDsl'; + + protected _entries: Array<{ + key: MaybePyDsl; + value: MaybePyDsl; + }> = []; + + constructor( + ...entries: Array<{ key: MaybePyDsl; value: MaybePyDsl }> + ) { + super(); + this._entries = entries; + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + for (const entry of this._entries) { + ctx.analyze(entry.key); + ctx.analyze(entry.value); + } + } + + entry(key: MaybePyDsl, value: MaybePyDsl): this { + this._entries.push({ key, value }); + return this; + } + + entries( + ...entries: ReadonlyArray<{ key: MaybePyDsl; value: MaybePyDsl }> + ): this { + this._entries.push(...entries); + return this; + } + + override toAst(): py.DictExpression { + const astEntries = this._entries.map((entry) => ({ + key: this.$node(entry.key) as py.Expression, + value: this.$node(entry.value) as py.Expression, + })); + return py.factory.createDictExpression(astEntries); + } +} diff --git a/packages/openapi-python/src/py-dsl/expr/expr.ts b/packages/openapi-python/src/py-dsl/expr/expr.ts new file mode 100644 index 000000000..541daf664 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/expr/expr.ts @@ -0,0 +1,40 @@ +import type { AnalysisContext, NodeName, Ref } from '@hey-api/codegen-core'; +import { isNode, isSymbol, ref } from '@hey-api/codegen-core'; + +import type { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; +// import { AsMixin } from '../mixins/as'; +// import { ExprMixin } from '../mixins/expr'; +// import { OperatorMixin } from '../mixins/operator'; +// import { TypeExprMixin } from '../mixins/type-expr'; + +type Id = NodeName | MaybePyDsl; + +const Mixed = PyDsl; +// const Mixed = AsMixin(ExprMixin(OperatorMixin(TypeExprMixin(PyDsl)))); + +export class ExprPyDsl extends Mixed { + readonly '~dsl' = 'ExprPyDsl'; + + protected _exprInput: Ref; + + constructor(id: Id) { + super(); + this._exprInput = ref(id); + if (typeof id === 'string' || isSymbol(id)) { + this.name.set(id); + } else if (isNode(id)) { + this.name.set(id.name); + } + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + ctx.analyze(this._exprInput); + } + + override toAst() { + return this.$node(this._exprInput); + } +} diff --git a/packages/openapi-python/src/py-dsl/expr/identifier.ts b/packages/openapi-python/src/py-dsl/expr/identifier.ts new file mode 100644 index 000000000..2650210f5 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/expr/identifier.ts @@ -0,0 +1,24 @@ +import type { AnalysisContext, NodeName } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import { PyDsl } from '../base'; + +const Mixed = PyDsl; + +export class IdPyDsl extends Mixed { + readonly '~dsl' = 'IdPyDsl'; + + constructor(name: NodeName) { + super(); + this.name.set(name); + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + ctx.analyze(this.name); + } + + override toAst(): py.Identifier { + return py.factory.createIdentifier(this.name.toString()); + } +} diff --git a/packages/openapi-python/src/py-dsl/expr/list.ts b/packages/openapi-python/src/py-dsl/expr/list.ts new file mode 100644 index 000000000..ca486b7a5 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/expr/list.ts @@ -0,0 +1,41 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; +import { LayoutMixin } from '../mixins/layout'; + +const Mixed = LayoutMixin(PyDsl); + +export class ListPyDsl extends Mixed { + readonly '~dsl' = 'ListPyDsl'; + + protected _elements: Array> = []; + + constructor(...elements: Array>) { + super(); + this._elements = elements; + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + for (const el of this._elements) { + ctx.analyze(el); + } + } + + element(expr: MaybePyDsl): this { + this._elements.push(expr); + return this; + } + + elements(...exprs: ReadonlyArray>): this { + this._elements.push(...exprs); + return this; + } + + override toAst(): py.ListExpression { + const astElements = this._elements.map((el) => this.$node(el) as py.Expression); + return py.factory.createListExpression(astElements); + } +} diff --git a/packages/openapi-python/src/py-dsl/expr/literal.ts b/packages/openapi-python/src/py-dsl/expr/literal.ts new file mode 100644 index 000000000..903abae26 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/expr/literal.ts @@ -0,0 +1,27 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import { PyDsl } from '../base'; + +export type LiteralValue = string | number | boolean | null; + +const Mixed = PyDsl; + +export class LiteralPyDsl extends Mixed { + readonly '~dsl' = 'LiteralPyDsl'; + + protected value: LiteralValue; + + constructor(value: LiteralValue) { + super(); + this.value = value; + } + + override analyze(_ctx: AnalysisContext): void { + super.analyze(_ctx); + } + + override toAst(): py.Literal { + return py.factory.createLiteral(this.value); + } +} diff --git a/packages/openapi-python/src/py-dsl/expr/member.ts b/packages/openapi-python/src/py-dsl/expr/member.ts new file mode 100644 index 000000000..87be1397e --- /dev/null +++ b/packages/openapi-python/src/py-dsl/expr/member.ts @@ -0,0 +1,59 @@ +import type { AnalysisContext, NodeName } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; + +const Mixed = PyDsl; + +export class AttrPyDsl extends Mixed { + readonly '~dsl' = 'AttrPyDsl'; + + protected object?: MaybePyDsl; + protected prop?: NodeName; + + constructor(object: MaybePyDsl, prop: NodeName) { + super(); + this.object = object; + this.name.set(prop); + } + + static attr(object: MaybePyDsl, prop: NodeName): AttrPyDsl { + return new AttrPyDsl(object, prop); + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + ctx.analyze(this.object); + ctx.analyze(this.name); + } + + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + + override toAst(): py.MemberExpression { + this.$validate(); + + return py.factory.createMemberExpression( + this.$node(this.object!) as py.Expression, + py.factory.createIdentifier(this.name.toString()), + ); + } + + $validate(): asserts this is this & { + object: MaybePyDsl; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Attribute access missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this.object) missing.push('object'); + if (!this.name.toString()) missing.push('property name'); + return missing; + } +} diff --git a/packages/openapi-python/src/py-dsl/expr/set.ts b/packages/openapi-python/src/py-dsl/expr/set.ts new file mode 100644 index 000000000..980bb7e52 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/expr/set.ts @@ -0,0 +1,41 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; +import { LayoutMixin } from '../mixins/layout'; + +const Mixed = LayoutMixin(PyDsl); + +export class SetPyDsl extends Mixed { + readonly '~dsl' = 'SetPyDsl'; + + protected _elements: Array> = []; + + constructor(...elements: Array>) { + super(); + this._elements = elements; + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + for (const el of this._elements) { + ctx.analyze(el); + } + } + + element(expr: MaybePyDsl): this { + this._elements.push(expr); + return this; + } + + elements(...exprs: ReadonlyArray>): this { + this._elements.push(...exprs); + return this; + } + + override toAst(): py.SetExpression { + const astElements = this._elements.map((el) => this.$node(el) as py.Expression); + return py.factory.createSetExpression(astElements); + } +} diff --git a/packages/openapi-python/src/py-dsl/expr/tuple.ts b/packages/openapi-python/src/py-dsl/expr/tuple.ts new file mode 100644 index 000000000..7f23080dd --- /dev/null +++ b/packages/openapi-python/src/py-dsl/expr/tuple.ts @@ -0,0 +1,41 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; +import { LayoutMixin } from '../mixins/layout'; + +const Mixed = LayoutMixin(PyDsl); + +export class TuplePyDsl extends Mixed { + readonly '~dsl' = 'TuplePyDsl'; + + protected _elements: Array> = []; + + constructor(...elements: Array>) { + super(); + this._elements = elements; + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + for (const el of this._elements) { + ctx.analyze(el); + } + } + + element(expr: MaybePyDsl): this { + this._elements.push(expr); + return this; + } + + elements(...exprs: ReadonlyArray>): this { + this._elements.push(...exprs); + return this; + } + + override toAst(): py.TupleExpression { + const astElements = this._elements.map((el) => this.$node(el) as py.Expression); + return py.factory.createTupleExpression(astElements); + } +} diff --git a/packages/openapi-python/src/py-dsl/index.ts b/packages/openapi-python/src/py-dsl/index.ts index e69de29bb..8abb6c94c 100644 --- a/packages/openapi-python/src/py-dsl/index.ts +++ b/packages/openapi-python/src/py-dsl/index.ts @@ -0,0 +1,320 @@ +import type { NodeName } from '@hey-api/codegen-core'; + +import type { py } from '../ts-python'; +import { ClassPyDsl } from './decl/class'; +// import { DecoratorPyDsl } from './decl/decorator'; +// import { EnumPyDsl } from './decl/enum'; +// import { FieldPyDsl } from './decl/field'; +import { FuncPyDsl } from './decl/func'; +// import { GetterPyDsl } from './decl/getter'; +// import { InitPyDsl } from './decl/init'; +// import { EnumMemberPyDsl } from './decl/member'; +// import { MethodPyDsl } from './decl/method'; +// import { ParamPyDsl } from './decl/param'; +// import { PatternPyDsl } from './decl/pattern'; +// import { SetterPyDsl } from './decl/setter'; +// import { ArrayPyDsl } from './expr/array'; +// import { AsPyDsl } from './expr/as'; +// import { AwaitPyDsl } from './expr/await'; +import { BinaryPyDsl } from './expr/binary'; +import { CallPyDsl } from './expr/call'; +import { DictPyDsl } from './expr/dict'; +import { ExprPyDsl } from './expr/expr'; +// import { fromValue as exprValue } from './expr/fromValue'; +import { IdPyDsl } from './expr/identifier'; +import { ListPyDsl } from './expr/list'; +import { LiteralPyDsl } from './expr/literal'; +import { AttrPyDsl } from './expr/member'; +// import { NewPyDsl } from './expr/new'; +// import { ObjectPyDsl } from './expr/object'; +// import { PrefixPyDsl } from './expr/prefix'; +// import { ObjectPropPyDsl } from './expr/prop'; +// import { RegExpPyDsl } from './expr/regexp'; +import { SetPyDsl } from './expr/set'; +// import { TemplatePyDsl } from './expr/template'; +// import { TernaryPyDsl } from './expr/ternary'; +import { TuplePyDsl } from './expr/tuple'; +// import { TypeOfExprPyDsl } from './expr/typeof'; +import { DocPyDsl } from './layout/doc'; +import { HintPyDsl } from './layout/hint'; +import { NewlinePyDsl } from './layout/newline'; +import { BlockPyDsl } from './stmt/block'; +import { BreakPyDsl } from './stmt/break'; +import { ContinuePyDsl } from './stmt/continue'; +import { ForPyDsl } from './stmt/for'; +import { IfPyDsl } from './stmt/if'; +import { ImportPyDsl } from './stmt/import'; +import { RaisePyDsl } from './stmt/raise'; +import { ReturnPyDsl } from './stmt/return'; +import { StmtPyDsl } from './stmt/stmt'; +import { TryPyDsl } from './stmt/try'; +import { VarPyDsl } from './stmt/var'; +import { WhilePyDsl } from './stmt/while'; +import { WithPyDsl } from './stmt/with'; +// import { TokenPyDsl } from './token'; +// import { TypeAliasPyDsl } from './type/alias'; +// import { TypeAndPyDsl } from './type/and'; +// import { TypeAttrPyDsl } from './type/attr'; +// import { TypeExprPyDsl } from './type/expr'; +// import { fromValue as typeValue } from './type/fromValue'; +// import { TypeFuncPyDsl } from './type/func'; +// import { TypeIdxPyDsl } from './type/idx'; +// import { TypeLiteralPyDsl } from './type/literal'; +// import { TypeMappedPyDsl } from './type/mapped'; +// import { TypeObjectPyDsl } from './type/object'; +// import { TypeOperatorPyDsl } from './type/operator'; +// import { TypeOrPyDsl } from './type/or'; +// import { TypeParamPyDsl } from './type/param'; +// import { TypeQueryPyDsl } from './type/query'; +// import { TypeTemplatePyDsl } from './type/template'; +// import { TypeTuplePyDsl } from './type/tuple'; +import { LazyPyDsl } from './utils/lazy'; + +const pyDsl = { + /** Creates an array literal expression (e.g. `[1, 2, 3]`). */ + // array: (...args: ConstructorParameters) => new ArrayTsDsl(...args), + /** Creates an `as` type assertion expression (e.g. `value as Type`). */ + // as: (...args: ConstructorParameters) => new AsTsDsl(...args), + + /** Creates a property access expression (e.g. `obj.foo`). */ + attr: (...args: ConstructorParameters) => new AttrPyDsl(...args), + + /** Creates an await expression (e.g. `await promise`). */ + // await: (...args: ConstructorParameters) => new AwaitTsDsl(...args), + + /** Creates a binary expression (e.g. `a + b`). */ + binary: (...args: ConstructorParameters) => new BinaryPyDsl(...args), + + /** Creates a statement block. */ + block: (...args: ConstructorParameters) => new BlockPyDsl(...args), + + /** Creates a break statement. */ + break: (...args: ConstructorParameters) => new BreakPyDsl(...args), + + /** Creates a function or method call expression (e.g. `fn(arg)`). */ + call: (...args: ConstructorParameters) => new CallPyDsl(...args), + + /** Creates a class declaration or expression. */ + class: (...args: ConstructorParameters) => new ClassPyDsl(...args), + + /** Creates a continue statement. */ + continue: (...args: ConstructorParameters) => new ContinuePyDsl(...args), + + /** Creates a decorator expression (e.g. `@decorator`). */ + // decorator: (...args: ConstructorParameters) => new DecoratorTsDsl(...args), + + /** Creates a dictionary expression (e.g. `{ 'a': 1 }`). */ + dict: (...args: ConstructorParameters) => new DictPyDsl(...args), + + /** Creates a Python docstring (`"""..."""`). */ + doc: (...args: ConstructorParameters) => new DocPyDsl(...args), + + /** Creates an enum declaration. */ + // enum: (...args: ConstructorParameters) => new EnumTsDsl(...args), + + /** Creates a general expression node. */ + expr: (...args: ConstructorParameters) => new ExprPyDsl(...args), + + /** Creates a field declaration in a class or object. */ + // field: (...args: ConstructorParameters) => new FieldTsDsl(...args), + + /** Creates a for statement (e.g. `for x in items:`). */ + for: (...args: ConstructorParameters) => new ForPyDsl(...args), + + /** Converts a runtime value into a corresponding expression node. */ + // fromValue: (...args: Parameters) => exprValue(...args), + + /** Creates a function declaration. */ + func: ((name: NodeName, fn?: (f: FuncPyDsl) => void) => new FuncPyDsl(name, fn)) as { + (name: NodeName): FuncPyDsl; + (name: NodeName, fn: (f: FuncPyDsl) => void): FuncPyDsl; + }, + + /** Creates a getter method declaration. */ + // getter: (...args: ConstructorParameters) => new GetterTsDsl(...args), + + /** Creates a Python comment (`# ...`). */ + hint: (...args: ConstructorParameters) => new HintPyDsl(...args), + + /** Creates an identifier (e.g. `foo`). */ + id: (...args: ConstructorParameters) => new IdPyDsl(...args), + + /** Creates an if statement. */ + if: (...args: ConstructorParameters) => new IfPyDsl(...args), + + /** Creates an import statement. */ + import: (...args: ConstructorParameters) => new ImportPyDsl(...args), + + /** Creates an initialization block or statement. */ + // init: (...args: ConstructorParameters) => new InitTsDsl(...args), + + /** Creates a lazy, context-aware node with deferred evaluation. */ + lazy: (...args: ConstructorParameters>) => + new LazyPyDsl(...args), + + /** Creates a list expression (e.g. `[1, 2, 3]`). */ + list: (...args: ConstructorParameters) => new ListPyDsl(...args), + + /** Creates a literal value (e.g. string, number, boolean). */ + literal: (...args: ConstructorParameters) => new LiteralPyDsl(...args), + + /** Creates an enum member declaration. */ + // member: (...args: ConstructorParameters) => new EnumMemberTsDsl(...args), + + /** Creates a method declaration inside a class or object. */ + // method: (...args: ConstructorParameters) => new MethodTsDsl(...args), + + /** Creates a negation expression (`-x`). */ + // neg: (...args: ConstructorParameters) => new PrefixTsDsl(...args).neg(), + + /** Creates a new expression (e.g. `new ClassName()`). */ + // new: (...args: ConstructorParameters) => new NewTsDsl(...args), + + /** Creates a newline (for formatting purposes). */ + newline: (...args: ConstructorParameters) => new NewlinePyDsl(...args), + + /** Creates a logical NOT expression (`!x`). */ + // not: (...args: ConstructorParameters) => new PrefixTsDsl(...args).not(), + + /** Creates an object literal expression. */ + // object: (...args: ConstructorParameters) => new ObjectTsDsl(...args), + + /** Creates a parameter declaration for functions or methods. */ + // param: (...args: ConstructorParameters) => new ParamTsDsl(...args), + + /** Creates a pattern for destructuring or matching. */ + // pattern: (...args: ConstructorParameters) => new PatternTsDsl(...args), + + /** Creates a prefix unary expression (e.g. `-x`, `!x`, `~x`). */ + // prefix: (...args: ConstructorParameters) => new PrefixTsDsl(...args), + + /** Creates an object literal property (e.g. `{ foo: bar }`). */ + // prop: (...args: ConstructorParameters) => new ObjectPropTsDsl(...args), + + /** Creates a raise statement. */ + raise: (...args: ConstructorParameters) => new RaisePyDsl(...args), + + /** Creates a regular expression literal (e.g. `/foo/gi`). */ + // regexp: (...args: ConstructorParameters) => new RegExpTsDsl(...args), + + /** Creates a return statement. */ + return: (...args: ConstructorParameters) => new ReturnPyDsl(...args), + + /** Creates a set expression (e.g. `{1, 2, 3}`). */ + set: (...args: ConstructorParameters) => new SetPyDsl(...args), + + /** Creates a setter method declaration. */ + // setter: (...args: ConstructorParameters) => new SetterTsDsl(...args), + + /** Wraps an expression or statement-like value into a `StmtPyDsl`. */ + stmt: (...args: ConstructorParameters) => new StmtPyDsl(...args), + + /** Creates a template literal expression. */ + // template: (...args: ConstructorParameters) => new TemplateTsDsl(...args), + + /** Creates a ternary conditional expression (if ? then : else). */ + // ternary: (...args: ConstructorParameters) => new TernaryTsDsl(...args), + + // /** Creates a throw statement. */ + // throw: (...args: ConstructorParameters) => new ThrowTsDsl(...args), + + /** Creates a syntax token (e.g. `?`, `readonly`, `+`, `-`). */ + // token: (...args: ConstructorParameters) => new TokenTsDsl(...args), + + /** Creates a try/except/finally statement. */ + try: (...args: ConstructorParameters) => new TryPyDsl(...args), + + /** Creates a tuple expression (e.g. `(1, 2, 3)`). */ + tuple: (...args: ConstructorParameters) => new TuplePyDsl(...args), + + /** Creates a basic type reference or type expression (e.g. Foo or Foo). */ + // type: Object.assign( + // (...args: ConstructorParameters) => new TypeExprTsDsl(...args), + // { + /** Creates a type alias declaration (e.g. `type Foo = Bar`). */ + // alias: (...args: ConstructorParameters) => new TypeAliasTsDsl(...args), + /** Creates an intersection type (e.g. `A & B`). */ + // and: (...args: ConstructorParameters) => new TypeAndTsDsl(...args), + /** Creates a qualified type reference (e.g. Foo.Bar). */ + // attr: (...args: ConstructorParameters) => new TypeAttrTsDsl(...args), + /** Creates a basic type reference or type expression (e.g. Foo or Foo). */ + // expr: (...args: ConstructorParameters) => new TypeExprTsDsl(...args), + /** Converts a runtime value into a corresponding type expression node. */ + // fromValue: (...args: Parameters) => typeValue(...args), + /** Creates a function type node (e.g. `(a: string) => number`). */ + // func: (...args: ConstructorParameters) => new TypeFuncTsDsl(...args), + /** Creates an indexed-access type (e.g. `Foo[K]`). */ + // idx: (...args: ConstructorParameters) => new TypeIdxTsDsl(...args), + /** Creates a literal type node (e.g. 'foo', 42, or true). */ + // literal: (...args: ConstructorParameters) => + // new TypeLiteralTsDsl(...args), + /** Creates a mapped type (e.g. `{ [K in keyof T]: U }`). */ + // mapped: (...args: ConstructorParameters) => + // new TypeMappedTsDsl(...args), + /** Creates a type literal node (e.g. { foo: string }). */ + // object: (...args: ConstructorParameters) => + // new TypeObjectTsDsl(...args), + /** Creates a type operator node (e.g. `readonly T`, `keyof T`, `unique T`). */ + // operator: (...args: ConstructorParameters) => + // new TypeOperatorTsDsl(...args), + /** Represents a union type (e.g. `A | B | C`). */ + // or: (...args: ConstructorParameters) => new TypeOrTsDsl(...args), + /** Creates a type parameter (e.g. ``). */ + // param: (...args: ConstructorParameters) => new TypeParamTsDsl(...args), + /** Creates a type query node (e.g. `typeof Foo`). */ + // query: (...args: ConstructorParameters) => new TypeQueryTsDsl(...args), + /** Builds a TypeScript template literal *type* (e.g. `${Foo}-${Bar}` as a type). */ + // template: (...args: ConstructorParameters) => + // new TypeTemplateTsDsl(...args), + /** Creates a tuple type (e.g. [A, B, C]). */ + // tuple: (...args: ConstructorParameters) => new TypeTupleTsDsl(...args), + // }, + // ), + /** Creates a `typeof` expression (e.g. `typeof value`). */ + // typeofExpr: (...args: ConstructorParameters) => + // new TypeOfExprTsDsl(...args), + + /** Creates a variable assignment. */ + var: (...args: ConstructorParameters) => new VarPyDsl(...args), + + /** Creates a while statement (e.g. `while x:`). */ + while: (...args: ConstructorParameters) => new WhilePyDsl(...args), + + /** Creates a with statement (e.g. `with open(f) as file:`). */ + with: (...args: ConstructorParameters) => new WithPyDsl(...args), +}; + +export const $ = Object.assign( + (...args: ConstructorParameters) => new ExprPyDsl(...args), + pyDsl, +); + +export type DollarPyDsl = { + /** + * Entry point to the Python DSL. + * + * `$` creates a general expression node by default, but also exposes + * builders for all other constructs. + * + * Example: + * ```ts + * const node = $('console').attr('log').call($.literal('Hello')); + * ``` + * + * Returns: + * - A new `ExprPyDsl` instance when called directly. + * - The `pyDsl` object for constructing more specific nodes. + */ + $: typeof $; +}; + +export type { MaybePyDsl } from './base'; +// export type { MaybePyDsl, TypePyDsl } from './base'; +export { PyDsl } from './base'; +export type { CallArgs } from './expr/call'; +export type { ExampleOptions } from './utils/context'; +export { ctx, PyDslContext } from './utils/context'; +export { keywords } from './utils/keywords'; +export { regexp } from './utils/regexp'; +export { PythonRenderer } from './utils/render'; +export { reserved } from './utils/reserved'; diff --git a/packages/openapi-python/src/py-dsl/layout/doc.ts b/packages/openapi-python/src/py-dsl/layout/doc.ts new file mode 100644 index 000000000..3f1ed5f45 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/layout/doc.ts @@ -0,0 +1,50 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; +import type { MaybeArray } from '@hey-api/types'; + +import { py } from '../../ts-python'; +import { PyDsl } from '../base'; +import type { PyDslContext } from '../utils/context'; +import { ctx } from '../utils/context'; + +type DocMaybeLazy = ((ctx: PyDslContext) => T) | T; +export type DocFn = (d: DocPyDsl) => void; +export type DocLines = DocMaybeLazy>; + +export class DocPyDsl extends PyDsl { + readonly '~dsl' = 'DocPyDsl'; + + protected _lines: Array = []; + + constructor(lines?: DocLines, fn?: DocFn) { + super(); + if (lines) this.add(lines); + fn?.(this); + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + } + + add(lines: DocLines): this { + this._lines.push(lines); + return this; + } + + resolve(): string | undefined { + const lines = this._lines.reduce((lines: Array, line: DocLines) => { + if (typeof line === 'function') line = line(ctx); + for (const l of typeof line === 'string' ? [line] : line) { + if (l || l === '') lines.push(l); + } + return lines; + }, []); + if (!lines.length) return undefined; + return lines.join('\n'); + } + + override toAst(): py.Comment { + // Return a dummy comment node for compliance. + return py.factory.createComment(this.resolve() ?? ''); + // return this.$node(new IdTsDsl('')); + } +} diff --git a/packages/openapi-python/src/py-dsl/layout/hint.ts b/packages/openapi-python/src/py-dsl/layout/hint.ts new file mode 100644 index 000000000..ac2100281 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/layout/hint.ts @@ -0,0 +1,58 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; +import type { MaybeArray } from '@hey-api/types'; + +import { py } from '../../ts-python'; +import { PyDsl } from '../base'; +import type { PyDslContext } from '../utils/context'; +import { ctx } from '../utils/context'; + +type HintMaybeLazy = ((ctx: PyDslContext) => T) | T; +export type HintFn = (d: HintPyDsl) => void; +export type HintLines = HintMaybeLazy>; + +export class HintPyDsl extends PyDsl { + readonly '~dsl' = 'HintPyDsl'; + + protected _lines: Array = []; + + constructor(lines?: HintLines, fn?: HintFn) { + super(); + if (lines) this.add(lines); + fn?.(this); + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + } + + add(lines: HintLines): this { + this._lines.push(lines); + return this; + } + + apply(node: T): T { + const lines = this._resolveLines(); + if (!lines.length) return node; + + const existing = node.leadingComments ? [...node.leadingComments] : []; + existing.push(...lines); + node.leadingComments = existing; + return node; + } + + override toAst(): py.Comment { + // Return a dummy comment node for compliance. + const lines = this._resolveLines(); + return py.factory.createComment(lines.join('\n')); + } + + private _resolveLines(): Array { + return this._lines.reduce((lines: Array, line: HintLines) => { + if (typeof line === 'function') line = line(ctx); + for (const l of typeof line === 'string' ? [line] : line) { + if (l || l === '') lines.push(l); + } + return lines; + }, []); + } +} diff --git a/packages/openapi-python/src/py-dsl/layout/newline.ts b/packages/openapi-python/src/py-dsl/layout/newline.ts new file mode 100644 index 000000000..080b29cff --- /dev/null +++ b/packages/openapi-python/src/py-dsl/layout/newline.ts @@ -0,0 +1,16 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import { PyDsl } from '../base'; + +export class NewlinePyDsl extends PyDsl { + readonly '~dsl' = 'NewlinePyDsl'; + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + } + + override toAst(): py.EmptyStatement { + return py.factory.createEmptyStatement(); + } +} diff --git a/packages/openapi-python/src/py-dsl/mixins/args.ts b/packages/openapi-python/src/py-dsl/mixins/args.ts new file mode 100644 index 000000000..5c4117794 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/mixins/args.ts @@ -0,0 +1,47 @@ +import type { AnalysisContext, Node, NodeName, Ref } from '@hey-api/codegen-core'; +import { ref } from '@hey-api/codegen-core'; + +import type { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import type { BaseCtor, MixinCtor } from './types'; + +type Arg = NodeName | MaybePyDsl; + +export interface ArgsMethods extends Node { + $args(): ReadonlyArray; + arg(arg: Arg | undefined): this; + args(...args: ReadonlyArray): this; +} + +export function ArgsMixin>(Base: TBase) { + abstract class Args extends Base { + protected _args: Array> = []; + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + for (const arg of this._args) { + ctx.analyze(arg); + } + } + + protected arg(arg: Arg | undefined): this { + if (arg !== undefined) this._args.push(ref(arg)); + return this; + } + + protected args(...args: ReadonlyArray): this { + this._args.push( + ...args + .filter((a): a is NonNullable => a !== undefined) + .map((a) => ref(a as Arg)), + ); + return this; + } + + protected $args(): ReadonlyArray { + return this.$node(this._args).map((arg) => this.$node(arg) as py.Expression); + } + } + + return Args as unknown as MixinCtor; +} diff --git a/packages/openapi-python/src/py-dsl/mixins/decorator.ts b/packages/openapi-python/src/py-dsl/mixins/decorator.ts new file mode 100644 index 000000000..5cf0d882f --- /dev/null +++ b/packages/openapi-python/src/py-dsl/mixins/decorator.ts @@ -0,0 +1,54 @@ +import type { AnalysisContext, NodeName } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import type { BaseCtor, MixinCtor } from './types'; + +export interface DecoratorMethods extends Node { + $decorators(): ReadonlyArray; + decorator( + name: NodeName | MaybePyDsl, + ...args: ReadonlyArray> + ): this; +} + +export function DecoratorMixin>(Base: TBase) { + abstract class Decorator extends Base { + protected decorators: Array = []; + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + for (const decorator of this.decorators) { + ctx.analyze(decorator); + } + } + + protected decorator( + name: NodeName | MaybePyDsl, + ...args: ReadonlyArray> + ): this { + const nameNode = + typeof name === 'string' || isPyNode(name) + ? name + : py.factory.createIdentifier(String(name)); + const decoratorExpr = args.length + ? py.factory.createCallExpression( + nameNode as py.Expression, + args.map((a) => this.$node(a) as py.Expression), + ) + : (nameNode as py.Expression); + this.decorators.push(decoratorExpr); + return this; + } + + protected $decorators(): ReadonlyArray { + return this.decorators; + } + } + + return Decorator as unknown as MixinCtor; +} + +function isPyNode(value: unknown): value is { toAst(): unknown } { + return typeof value === 'object' && value !== null && 'toAst' in value; +} diff --git a/packages/openapi-python/src/py-dsl/mixins/do.ts b/packages/openapi-python/src/py-dsl/mixins/do.ts new file mode 100644 index 000000000..b5cb409bf --- /dev/null +++ b/packages/openapi-python/src/py-dsl/mixins/do.ts @@ -0,0 +1,45 @@ +import type { AnalysisContext, Node } from '@hey-api/codegen-core'; + +import type { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { StmtPyDsl } from '../stmt/stmt'; +import type { BaseCtor, MixinCtor } from './types'; + +export type DoExpr = MaybePyDsl; + +export interface DoMethods extends Node { + /** Renders the collected `.do()` calls into an array of `py.Statement` nodes. */ + $do(): ReadonlyArray; + _do: Array; + /** Adds one or more expressions/statements to the body. */ + do(...items: ReadonlyArray): this; +} + +export function DoMixin>(Base: TBase) { + abstract class Do extends Base { + protected _do: Array = []; + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + ctx.pushScope(); + try { + for (const item of this._do) { + ctx.analyze(item); + } + } finally { + ctx.popScope(); + } + } + + protected do(...items: ReadonlyArray): this { + this._do.push(...items); + return this; + } + + protected $do(): ReadonlyArray { + return this.$node(this._do.map((item) => new StmtPyDsl(item))); + } + } + + return Do as unknown as MixinCtor; +} diff --git a/packages/openapi-python/src/py-dsl/mixins/doc.ts b/packages/openapi-python/src/py-dsl/mixins/doc.ts new file mode 100644 index 000000000..cae48bb9e --- /dev/null +++ b/packages/openapi-python/src/py-dsl/mixins/doc.ts @@ -0,0 +1,32 @@ +import type { AnalysisContext, Node } from '@hey-api/codegen-core'; + +import type { py } from '../../ts-python'; +import type { DocFn, DocLines } from '../layout/doc'; +import { DocPyDsl } from '../layout/doc'; +import type { BaseCtor, MixinCtor } from './types'; + +export interface DocMethods extends Node { + $docs(): string | undefined; + doc(lines?: DocLines, fn?: DocFn): this; +} + +export function DocMixin>(Base: TBase) { + abstract class Doc extends Base { + private _doc?: DocPyDsl; + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + } + + protected doc(lines?: DocLines, fn?: DocFn): this { + this._doc = new DocPyDsl(lines, fn); + return this; + } + + protected $docs(): string | undefined { + return this._doc ? this._doc.resolve() : undefined; + } + } + + return Doc as unknown as MixinCtor; +} diff --git a/packages/openapi-python/src/py-dsl/mixins/expr.ts b/packages/openapi-python/src/py-dsl/mixins/expr.ts new file mode 100644 index 000000000..bb9549f19 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/mixins/expr.ts @@ -0,0 +1,33 @@ +import type { AnalysisContext, Node } from '@hey-api/codegen-core'; + +import type { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { CallPyDsl } from '../expr/call'; +import { AttrPyDsl } from '../expr/member'; +import type { BaseCtor, MixinCtor } from './types'; + +export interface ExprMethods extends Node { + attr(object: MaybePyDsl, prop: string): AttrPyDsl; + call(callee: MaybePyDsl, ...args: Array>): CallPyDsl; +} + +export function ExprMixin>(Base: TBase) { + abstract class Expr extends Base { + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + } + + protected attr(object: MaybePyDsl, prop: string): AttrPyDsl { + return AttrPyDsl.attr(object, prop); + } + + protected call( + callee: MaybePyDsl, + ...args: Array> + ): CallPyDsl { + return new CallPyDsl(callee, ...args); + } + } + + return Expr as unknown as MixinCtor; +} diff --git a/packages/openapi-python/src/py-dsl/mixins/hint.ts b/packages/openapi-python/src/py-dsl/mixins/hint.ts new file mode 100644 index 000000000..ba0c23020 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/mixins/hint.ts @@ -0,0 +1,32 @@ +import type { AnalysisContext, Node } from '@hey-api/codegen-core'; + +import type { py } from '../../ts-python'; +import type { HintFn, HintLines } from '../layout/hint'; +import { HintPyDsl } from '../layout/hint'; +import type { BaseCtor, MixinCtor } from './types'; + +export interface HintMethods extends Node { + $hint(node: T): T; + hint(lines?: HintLines, fn?: HintFn): this; +} + +export function HintMixin>(Base: TBase) { + abstract class Hint extends Base { + private _hint?: HintPyDsl; + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + } + + protected hint(lines?: HintLines, fn?: HintFn): this { + this._hint = new HintPyDsl(lines, fn); + return this; + } + + protected $hint(node: T): T { + return this._hint ? this._hint.apply(node) : node; + } + } + + return Hint as unknown as MixinCtor; +} diff --git a/packages/openapi-python/src/py-dsl/mixins/layout.ts b/packages/openapi-python/src/py-dsl/mixins/layout.ts new file mode 100644 index 000000000..dfc68c149 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/mixins/layout.ts @@ -0,0 +1,51 @@ +import type { AnalysisContext, Node } from '@hey-api/codegen-core'; + +import type { py } from '../../ts-python'; +import type { BaseCtor, MixinCtor } from './types'; + +export interface LayoutMethods extends Node { + /** Computes whether output should be multiline based on layout setting and element count. */ + $multiline(count: number): boolean; + /** Sets automatic line output with optional threshold (default: 3). */ + auto(threshold?: number): this; + /** Sets single line output. */ + inline(): this; + /** Sets multi line output. */ + pretty(): this; +} + +export function LayoutMixin>(Base: TBase) { + abstract class Layout extends Base { + protected static readonly DEFAULT_THRESHOLD = 3; + protected layout: boolean | number | undefined; + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + } + + protected auto(threshold: number = Layout.DEFAULT_THRESHOLD): this { + this.layout = threshold; + return this; + } + + protected inline(): this { + this.layout = false; + return this; + } + + protected pretty(): this { + this.layout = true; + return this; + } + + protected $multiline(count: number): boolean { + if (this.layout === undefined) { + this.layout = Layout.DEFAULT_THRESHOLD; + } + if (count === 0) return false; + return typeof this.layout === 'number' ? count >= this.layout : this.layout; + } + } + + return Layout as unknown as MixinCtor; +} diff --git a/packages/openapi-python/src/py-dsl/mixins/modifiers.ts b/packages/openapi-python/src/py-dsl/mixins/modifiers.ts new file mode 100644 index 000000000..18bde4a9f --- /dev/null +++ b/packages/openapi-python/src/py-dsl/mixins/modifiers.ts @@ -0,0 +1,86 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { BaseCtor, MixinCtor } from './types'; + +export type Modifiers = { + /** + * Checks if specified modifier is present. + * + * @param modifier - The modifier to check. + * @returns True if modifier is present, false otherwise. + */ + hasModifier(modifier: Modifier): boolean; + modifiers: Array; +}; + +type Modifier = 'async'; + +export interface ModifierMethods extends Modifiers { + /** + * Adds a modifier of specified kind to modifiers list if condition is true. + * + * @param modifier - The modifier to add. + * @param condition - Whether to add modifier. + * @returns The parent node for chaining. + */ + _m(modifier: Modifier, condition: boolean): this; +} + +function modifierToKind(modifier: Modifier): py.Expression { + switch (modifier) { + case 'async': + return py.factory.createIdentifier('async'); + } +} + +export function ModifiersMixin>(Base: TBase) { + abstract class Modifiers extends Base { + protected modifiers: Array = []; + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + } + + protected hasModifier(modifier: Modifier): boolean { + const kind = modifierToKind(modifier); + return Boolean(this.modifiers.find((mod) => mod === kind)); + } + + protected _m(modifier: Modifier, condition: boolean): this { + if (condition) { + const kind = modifierToKind(modifier); + this.modifiers.push(kind); + } + return this; + } + } + + return Modifiers as unknown as MixinCtor; +} + +export interface AsyncMethods extends Modifiers { + /** + * Adds an `async` keyword modifier if condition is true. + * + * @param condition - Whether to add modifier (default: true). + * @returns The target object for chaining. + */ + async(condition?: boolean): this; +} + +/** + * Mixin that adds an `async` modifier to a node. + */ +export function AsyncMixin>(Base: TBase) { + const Mixed = ModifiersMixin(Base as BaseCtor); + + abstract class Async extends Mixed { + protected async(condition?: boolean): this { + const cond = arguments.length === 0 ? true : Boolean(condition); + return this._m('async', cond); + } + } + + return Async as unknown as MixinCtor; +} diff --git a/packages/openapi-python/src/py-dsl/mixins/operator.ts b/packages/openapi-python/src/py-dsl/mixins/operator.ts new file mode 100644 index 000000000..64bdf4aab --- /dev/null +++ b/packages/openapi-python/src/py-dsl/mixins/operator.ts @@ -0,0 +1,192 @@ +import type { AnalysisContext, Node, NodeName } from '@hey-api/codegen-core'; + +import type { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { BinaryPyDsl } from '../expr/binary'; +import type { BaseCtor, MixinCtor } from './types'; + +type Expr = NodeName | MaybePyDsl; + +export interface OperatorMethods extends Node { + and(expr: Expr): BinaryPyDsl; + div(expr: Expr): BinaryPyDsl; + eq(expr: Expr): BinaryPyDsl; + floordiv(expr: Expr): BinaryPyDsl; + gt(expr: Expr): BinaryPyDsl; + gte(expr: Expr): BinaryPyDsl; + in_(expr: Expr): BinaryPyDsl; + is(expr: Expr): BinaryPyDsl; + isNot(expr: Expr): BinaryPyDsl; + lt(expr: Expr): BinaryPyDsl; + lte(expr: Expr): BinaryPyDsl; + minus(expr: Expr): BinaryPyDsl; + mod(expr: Expr): BinaryPyDsl; + neq(expr: Expr): BinaryPyDsl; + notIn(expr: Expr): BinaryPyDsl; + or(expr: Expr): BinaryPyDsl; + plus(expr: Expr): BinaryPyDsl; + pow(expr: Expr): BinaryPyDsl; + times(expr: Expr): BinaryPyDsl; +} + +export function OperatorMixin>(Base: TBase) { + abstract class Operator extends Base { + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + } + + protected and(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + 'and', + expr as MaybePyDsl, + ); + } + + protected div(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + '/', + expr as MaybePyDsl, + ); + } + + protected eq(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + '==', + expr as MaybePyDsl, + ); + } + + protected floordiv(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + '//', + expr as MaybePyDsl, + ); + } + + protected gt(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + '>', + expr as MaybePyDsl, + ); + } + + protected gte(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + '>=', + expr as MaybePyDsl, + ); + } + + protected in_(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + 'in', + expr as MaybePyDsl, + ); + } + + protected is(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + 'is', + expr as MaybePyDsl, + ); + } + + protected isNot(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + 'is not', + expr as MaybePyDsl, + ); + } + + protected lt(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + '<', + expr as MaybePyDsl, + ); + } + + protected lte(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + '<=', + expr as MaybePyDsl, + ); + } + + protected minus(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + '-', + expr as MaybePyDsl, + ); + } + + protected mod(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + '%', + expr as MaybePyDsl, + ); + } + + protected neq(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + '!=', + expr as MaybePyDsl, + ); + } + + protected notIn(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + 'not in', + expr as MaybePyDsl, + ); + } + + protected or(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + 'or', + expr as MaybePyDsl, + ); + } + + protected plus(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + '+', + expr as MaybePyDsl, + ); + } + + protected pow(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + '**', + expr as MaybePyDsl, + ); + } + + protected times(expr: Expr): BinaryPyDsl { + return new BinaryPyDsl( + this as unknown as MaybePyDsl, + '*', + expr as MaybePyDsl, + ); + } + } + + return Operator as unknown as MixinCtor; +} diff --git a/packages/openapi-python/src/py-dsl/mixins/types.ts b/packages/openapi-python/src/py-dsl/mixins/types.ts new file mode 100644 index 000000000..4964c57ae --- /dev/null +++ b/packages/openapi-python/src/py-dsl/mixins/types.ts @@ -0,0 +1,10 @@ +import type { py } from '../../ts-python'; +import type { PyDsl } from '../base'; + +export type BaseCtor = abstract new (...args: Array) => PyDsl; + +export type DropFirst> = T extends [any, ...infer Rest] ? Rest : never; + +export type MixinCtor, K> = abstract new ( + ...args: Array +) => InstanceType & K; diff --git a/packages/openapi-python/src/py-dsl/mixins/value.ts b/packages/openapi-python/src/py-dsl/mixins/value.ts new file mode 100644 index 000000000..998eb7808 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/mixins/value.ts @@ -0,0 +1,35 @@ +import type { AnalysisContext, Node } from '@hey-api/codegen-core'; + +import type { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import type { BaseCtor, MixinCtor } from './types'; + +export type ValueExpr = string | MaybePyDsl; + +export interface ValueMethods extends Node { + $value(): py.Expression | undefined; + /** Sets the initializer expression (e.g. `= expr`). */ + assign(expr: ValueExpr): this; +} + +export function ValueMixin>(Base: TBase) { + abstract class Value extends Base { + protected value?: ValueExpr; + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + ctx.analyze(this.value); + } + + protected assign(expr: ValueExpr): this { + this.value = expr; + return this; + } + + protected $value(): py.Expression | undefined { + return this.$node(this.value); + } + } + + return Value as unknown as MixinCtor; +} diff --git a/packages/openapi-python/src/py-dsl/stmt/block.ts b/packages/openapi-python/src/py-dsl/stmt/block.ts new file mode 100644 index 000000000..ed8f8f3bd --- /dev/null +++ b/packages/openapi-python/src/py-dsl/stmt/block.ts @@ -0,0 +1,27 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import { PyDsl } from '../base'; +import type { DoExpr } from '../mixins/do'; +import { DoMixin } from '../mixins/do'; +import { LayoutMixin } from '../mixins/layout'; + +const Mixed = DoMixin(LayoutMixin(PyDsl)); + +export class BlockPyDsl extends Mixed { + readonly '~dsl' = 'BlockPyDsl'; + + constructor(...items: Array) { + super(); + this.do(...items); + } + + override analyze(ctx: AnalysisContext) { + super.analyze(ctx); + } + + override toAst() { + const statements = this.$do(); + return py.factory.createBlock(statements); + } +} diff --git a/packages/openapi-python/src/py-dsl/stmt/break.ts b/packages/openapi-python/src/py-dsl/stmt/break.ts new file mode 100644 index 000000000..25db8b145 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/stmt/break.ts @@ -0,0 +1,18 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import { PyDsl } from '../base'; + +const Mixed = PyDsl; + +export class BreakPyDsl extends Mixed { + readonly '~dsl' = 'BreakPyDsl'; + + override analyze(_ctx: AnalysisContext): void { + super.analyze(_ctx); + } + + override toAst(): py.BreakStatement { + return py.factory.createBreakStatement(); + } +} diff --git a/packages/openapi-python/src/py-dsl/stmt/continue.ts b/packages/openapi-python/src/py-dsl/stmt/continue.ts new file mode 100644 index 000000000..2ddd6fdd6 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/stmt/continue.ts @@ -0,0 +1,18 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import { PyDsl } from '../base'; + +const Mixed = PyDsl; + +export class ContinuePyDsl extends Mixed { + readonly '~dsl' = 'ContinuePyDsl'; + + override analyze(_ctx: AnalysisContext): void { + super.analyze(_ctx); + } + + override toAst(): py.ContinueStatement { + return py.factory.createContinueStatement(); + } +} diff --git a/packages/openapi-python/src/py-dsl/stmt/for.ts b/packages/openapi-python/src/py-dsl/stmt/for.ts new file mode 100644 index 000000000..f65315900 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/stmt/for.ts @@ -0,0 +1,101 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; +import type { DoExpr } from '../mixins/do'; +import { BlockPyDsl } from './block'; + +const Mixed = PyDsl; + +export class ForPyDsl extends Mixed { + readonly '~dsl' = 'ForPyDsl'; + + protected _body?: Array; + protected _else?: Array; + protected _iterable?: MaybePyDsl; + protected _target?: MaybePyDsl; + + constructor( + target: MaybePyDsl, + iterable: MaybePyDsl, + ...body: Array + ) { + super(); + this._target = target; + this._iterable = iterable; + this._body = body; + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + + if (this._target) ctx.analyze(this._target); + if (this._iterable) ctx.analyze(this._iterable); + + if (this._body) { + ctx.pushScope(); + try { + for (const stmt of this._body) ctx.analyze(stmt); + } finally { + ctx.popScope(); + } + } + + if (this._else) { + ctx.pushScope(); + try { + for (const stmt of this._else) ctx.analyze(stmt); + } finally { + ctx.popScope(); + } + } + } + + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + + body(...items: Array): this { + this._body = items; + return this; + } + + else(...items: Array): this { + this._else = items; + return this; + } + + override toAst(): py.ForStatement { + this.$validate(); + + const body = new BlockPyDsl(...this._body!).$do(); + const elseBlock = this._else ? new BlockPyDsl(...this._else).$do() : undefined; + + return py.factory.createForStatement( + this.$node(this._target!) as py.Expression, + this.$node(this._iterable!) as py.Expression, + [...body], + elseBlock ? [...elseBlock] : undefined, + ); + } + + $validate(): asserts this is this & { + _body: Array; + _iterable: MaybePyDsl; + _target: MaybePyDsl; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`For statement missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._target) missing.push('target'); + if (!this._iterable) missing.push('iterable'); + if (!this._body || this._body.length === 0) missing.push('body'); + return missing; + } +} diff --git a/packages/openapi-python/src/py-dsl/stmt/if.ts b/packages/openapi-python/src/py-dsl/stmt/if.ts new file mode 100644 index 000000000..608edd4ea --- /dev/null +++ b/packages/openapi-python/src/py-dsl/stmt/if.ts @@ -0,0 +1,82 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; +import type { DoExpr } from '../mixins/do'; +import { DoMixin } from '../mixins/do'; +import { BlockPyDsl } from './block'; + +export type IfCondition = string | MaybePyDsl; + +const Mixed = DoMixin(PyDsl); + +export class IfPyDsl extends Mixed { + readonly '~dsl' = 'IfPyDsl'; + + protected _condition?: IfCondition; + protected _else?: Array; + + constructor(condition?: IfCondition) { + super(); + if (condition) this.condition(condition); + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + ctx.analyze(this._condition); + if (this._else) { + ctx.pushScope(); + try { + for (const stmt of this._else) { + ctx.analyze(stmt); + } + } finally { + ctx.popScope(); + } + } + } + + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + + condition(condition: IfCondition): this { + this._condition = condition; + return this; + } + + otherwise(...items: Array): this { + this._else = items; + return this; + } + + override toAst() { + this.$validate(); + + const thenStatements = this.$do(); + const elseStatements = this._else ? new BlockPyDsl(...this._else).$do() : undefined; + + return py.factory.createIfStatement( + this.$node(this._condition!), + [...thenStatements], + elseStatements ? [...elseStatements] : undefined, + ); + } + + $validate(): asserts this is this & { + _condition: IfCondition; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`If statement missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._condition) missing.push('.condition()'); + if (this._do.length === 0) missing.push('.do()'); + return missing; + } +} diff --git a/packages/openapi-python/src/py-dsl/stmt/import.ts b/packages/openapi-python/src/py-dsl/stmt/import.ts new file mode 100644 index 000000000..1e8782c42 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/stmt/import.ts @@ -0,0 +1,57 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import { PyDsl } from '../base'; + +type ImportName = { alias?: string; name: string }; + +const Mixed = PyDsl; + +export class ImportPyDsl extends Mixed { + readonly '~dsl' = 'ImportPyDsl'; + + protected isFrom: boolean = true; + protected module: string = ''; + protected names?: ReadonlyArray; + + constructor(module: string); + constructor(module: string, isFrom: boolean); + constructor(module: string, names: ReadonlyArray, isFrom: boolean); + constructor( + module: string, + namesOrIsFrom?: ReadonlyArray | boolean, + isFrom?: boolean, + ) { + super(); + this.module = module; + if (typeof namesOrIsFrom === 'boolean') { + this.isFrom = namesOrIsFrom; + } else if (Array.isArray(namesOrIsFrom)) { + this.names = namesOrIsFrom; + this.isFrom = isFrom ?? true; + } else { + this.isFrom = true; + } + } + + static from(module: string, names?: ReadonlyArray): ImportPyDsl { + return names ? new ImportPyDsl(module, names, true) : new ImportPyDsl(module, true); + } + + static direct(module: string): ImportPyDsl { + return new ImportPyDsl(module, false); + } + + override analyze(_ctx: AnalysisContext): void { + super.analyze(_ctx); + } + + override toAst(): py.ImportStatement { + return { + isFrom: this.isFrom, + kind: py.PyNodeKind.ImportStatement, + module: this.module, + names: this.names, + }; + } +} diff --git a/packages/openapi-python/src/py-dsl/stmt/raise.ts b/packages/openapi-python/src/py-dsl/stmt/raise.ts new file mode 100644 index 000000000..15257540b --- /dev/null +++ b/packages/openapi-python/src/py-dsl/stmt/raise.ts @@ -0,0 +1,40 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; +// import { LiteralPyDsl } from '../expr/literal'; + +const Mixed = PyDsl; + +export class RaisePyDsl extends Mixed { + readonly '~dsl' = 'RaisePyDsl'; + + protected _error?: string | MaybePyDsl; + // protected msg?: string | MaybePyDsl; + + constructor(error?: string | MaybePyDsl) { + super(); + this._error = error; + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + ctx.analyze(this._error); + // ctx.analyze(this.msg); + } + + // /** Sets the message argument for the exception (e.g. `raise ValueError('msg')`). */ + // message(value: string | MaybePyDsl): this { + // this.msg = value; + // return this; + // } + + override toAst() { + // Python's `raise` can be bare (re-raise), or `raise `. + // Unlike JS `throw new Error(msg)`, Python uses `raise ErrorType(msg)` directly, + // so the caller constructs the call expression themselves. + const errorNode = this._error ? this.$node(this._error) : undefined; + return py.factory.createRaiseStatement(errorNode); + } +} diff --git a/packages/openapi-python/src/py-dsl/stmt/return.ts b/packages/openapi-python/src/py-dsl/stmt/return.ts new file mode 100644 index 000000000..d382bd4d6 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/stmt/return.ts @@ -0,0 +1,34 @@ +import type { AnalysisContext, NodeName, Ref } from '@hey-api/codegen-core'; +import { ref } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; +// import { f } from '../utils/factories'; + +export type ReturnExpr = NodeName | MaybePyDsl; +export type ReturnCtor = (expr?: ReturnExpr) => ReturnPyDsl; + +const Mixed = PyDsl; + +export class ReturnPyDsl extends Mixed { + readonly '~dsl' = 'ReturnPyDsl'; + + protected _returnExpr?: Ref; + + constructor(expr?: ReturnExpr) { + super(); + if (expr) this._returnExpr = ref(expr); + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + ctx.analyze(this._returnExpr); + } + + override toAst() { + return py.factory.createReturnStatement(this.$node(this._returnExpr)); + } +} + +// f.return.set((...args) => new ReturnPyDsl(...args)); diff --git a/packages/openapi-python/src/py-dsl/stmt/stmt.ts b/packages/openapi-python/src/py-dsl/stmt/stmt.ts new file mode 100644 index 000000000..2577d3abf --- /dev/null +++ b/packages/openapi-python/src/py-dsl/stmt/stmt.ts @@ -0,0 +1,53 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import { PyDsl } from '../base'; + +const Mixed = PyDsl; + +export class StmtPyDsl extends Mixed { + readonly '~dsl' = 'StmtPyDsl'; + + protected _inner: py.Expression | py.Statement | PyDsl; + + constructor(inner: py.Expression | py.Statement | PyDsl) { + super(); + this._inner = inner; + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + ctx.analyze(this._inner); + } + + override toAst() { + const node = this.$node(this._inner); + if (isStatement(node)) return node; + return py.factory.createExpressionStatement(node as py.Expression); + } +} + +/** Checks whether a Python AST node is a statement. */ +function isStatement(node: py.Expression | py.Statement): node is py.Statement { + const statementKinds = new Set([ + py.PyNodeKind.Assignment, + py.PyNodeKind.AugmentedAssignment, + py.PyNodeKind.Block, + py.PyNodeKind.BreakStatement, + py.PyNodeKind.ClassDeclaration, + py.PyNodeKind.Comment, + py.PyNodeKind.ContinueStatement, + py.PyNodeKind.EmptyStatement, + py.PyNodeKind.ExpressionStatement, + py.PyNodeKind.ForStatement, + py.PyNodeKind.FunctionDeclaration, + py.PyNodeKind.IfStatement, + py.PyNodeKind.ImportStatement, + py.PyNodeKind.RaiseStatement, + py.PyNodeKind.ReturnStatement, + py.PyNodeKind.TryStatement, + py.PyNodeKind.WhileStatement, + py.PyNodeKind.WithStatement, + ]); + return statementKinds.has(node.kind); +} diff --git a/packages/openapi-python/src/py-dsl/stmt/try.ts b/packages/openapi-python/src/py-dsl/stmt/try.ts new file mode 100644 index 000000000..afa388f31 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/stmt/try.ts @@ -0,0 +1,231 @@ +import type { AnalysisContext, NodeName } from '@hey-api/codegen-core'; +import type { MaybeArray } from '@hey-api/types'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; +import type { DoExpr } from '../mixins/do'; +import { BlockPyDsl } from './block'; + +const Mixed = PyDsl; + +type ExceptType = string | MaybePyDsl; + +interface ExceptEntry { + body: Array; + name?: NodeName; + types: Array; +} + +function exceptKey(types: Array): string { + return types + .map((t) => (typeof t === 'string' ? t : '<>')) + .sort() + .join(','); +} + +export class TryPyDsl extends Mixed { + readonly '~dsl' = 'TryPyDsl'; + + /** + * Ordered list of except clauses. We also keep a lookup map + * (`_exceptIndex`) keyed by the normalised type key so that + * repeated `.except()` calls with the same type set merge their + * body statements instead of creating duplicate clauses. + */ + protected _excepts: Array = []; + protected _exceptIndex: Map = new Map(); + + protected _finally?: Array; + protected _try?: Array; + + constructor(...tryBlock: Array) { + super(); + this.try(...tryBlock); + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + + if (this._try) { + ctx.pushScope(); + try { + for (const stmt of this._try) ctx.analyze(stmt); + } finally { + ctx.popScope(); + } + } + + for (const entry of this._excepts) { + ctx.pushScope(); + try { + ctx.analyze(entry.name); + for (const t of entry.types) ctx.analyze(t); + for (const stmt of entry.body) ctx.analyze(stmt); + } finally { + ctx.popScope(); + } + } + + if (this._finally) { + ctx.pushScope(); + try { + for (const stmt of this._finally) ctx.analyze(stmt); + } finally { + ctx.popScope(); + } + } + } + + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + + /** + * Add (or merge into) an except clause. + * + * ```ts + * $.try(...) + * .except('ValueError', 'e', body1, body2) // except ValueError as e: + * .except(['TypeError', 'KeyError'], 'e', ...) // except (TypeError, KeyError) as e: + * .except('ValueError', moreBody) // merges into first clause + * ``` + * + * @param types Single exception type or array of types. + * @param nameOrBody Either the `as` variable name (`NodeName`) or the + * first body expression. If it looks like a `NodeName` (string that + * is a valid Python identifier and is *not* a DSL node), it is treated + * as the name; pass body items after it. + * @param body Remaining body statements. + */ + except( + types: MaybeArray, + nameOrBody?: NodeName | DoExpr, + ...body: Array + ): this { + const typeArr = Array.isArray(types) ? types : [types]; + const key = exceptKey(typeArr); + + let name: NodeName | undefined; + let bodyItems: Array; + + // Disambiguate: if the second arg is a plain string that looks like + // an identifier (no dots, no spaces, not a DSL node) treat it as + // the `as` name. Otherwise it's the first body expression. + if (nameOrBody !== undefined && this._isNodeName(nameOrBody)) { + name = nameOrBody as NodeName; + bodyItems = body; + } else if (nameOrBody !== undefined) { + bodyItems = [nameOrBody as DoExpr, ...body]; + } else { + bodyItems = body; + } + + const existing = this._exceptIndex.get(key); + if (existing !== undefined) { + const entry = this._excepts[existing]!; + entry.body.push(...bodyItems); + if (name !== undefined) entry.name = name; + } else { + this._exceptIndex.set(key, this._excepts.length); + this._excepts.push({ body: bodyItems, name, types: typeArr }); + } + + return this; + } + + /** Add a bare `except:` clause (catches everything). */ + exceptAll(...body: Array): this { + const key = ''; + const existing = this._exceptIndex.get(key); + if (existing !== undefined) { + this._excepts[existing]!.body.push(...body); + } else { + this._exceptIndex.set(key, this._excepts.length); + this._excepts.push({ body, types: [] }); + } + return this; + } + + finally(...items: Array): this { + this._finally = items; + return this; + } + + try(...items: Array): this { + this._try = items; + return this; + } + + override toAst() { + this.$validate(); + + const tryStatements = new BlockPyDsl(...this._try!).$do(); + + let exceptClauses: Array | undefined; + if (this._excepts.length) { + exceptClauses = this._excepts.map((entry) => { + const bodyStatements = new BlockPyDsl(...entry.body).$do(); + + let exceptionType: py.Expression | undefined; + if (entry.types.length === 1) { + exceptionType = this.$node(entry.types[0]!); + } else if (entry.types.length > 1) { + exceptionType = py.factory.createTupleExpression( + entry.types.map((t) => this.$node(t) as py.Expression), + ); + } + + const exceptionName = entry.name + ? py.factory.createIdentifier( + this.$name({ current: entry.name } as any) || String(entry.name), + ) + : undefined; + + return py.factory.createExceptClause([...bodyStatements], exceptionType, exceptionName); + }); + } + + const finallyStatements = this._finally + ? [...new BlockPyDsl(...this._finally).$do()] + : undefined; + + return py.factory.createTryStatement( + [...tryStatements], + exceptClauses, + undefined, + finallyStatements, + ); + } + + $validate(): asserts this is this & { + _try: Array; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Try statement missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._try || this._try.length === 0) missing.push('.try()'); + return missing; + } + + /** + * Heuristic: a value is a `NodeName` (intended as the `as` variable) + * if it is a plain string matching a Python identifier pattern, or a + * Symbol. + */ + private _isNodeName(value: unknown): boolean { + if (typeof value === 'string') { + return /^[A-Za-z_]\w*$/.test(value); + } + // Symbols from codegen-core have `~brand` + if (value && typeof value === 'object' && '~brand' in value) { + return true; + } + return false; + } +} diff --git a/packages/openapi-python/src/py-dsl/stmt/var.ts b/packages/openapi-python/src/py-dsl/stmt/var.ts new file mode 100644 index 000000000..f7688e18b --- /dev/null +++ b/packages/openapi-python/src/py-dsl/stmt/var.ts @@ -0,0 +1,68 @@ +import type { AnalysisContext, NodeName } from '@hey-api/codegen-core'; +import { isSymbol } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +// import { TypePyDsl } from '../base'; +import { PyDsl } from '../base'; +// import { DocMixin } from '../mixins/doc'; +// import { HintMixin } from '../mixins/hint'; +// import { DefaultMixin, ExportMixin } from '../mixins/modifiers'; +// import { PatternMixin } from '../mixins/pattern'; +import { ValueMixin } from '../mixins/value'; +// import { TypeExprPyDsl } from '../type/expr'; +import { safeRuntimeName } from '../utils/name'; + +// const Mixed = DefaultMixin( +// DocMixin(ExportMixin(HintMixin(PatternMixin(ValueMixin(PyDsl))))), +// ); +const Mixed = ValueMixin(PyDsl); + +export class VarPyDsl extends Mixed { + readonly '~dsl' = 'VarPyDsl'; + override readonly nameSanitizer = safeRuntimeName; + + // protected _type?: TypePyDsl; + + 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 === 0; + } + + // /** Sets the variable type annotation. */ + // type(type: string | TypePyDsl): this { + // this._type = type instanceof TypePyDsl ? type : new TypeExprPyDsl(type); + // return this; + // } + + override toAst() { + this.$validate(); + return py.factory.createAssignment(this.$node(this.name)!, this.$value()!); + } + + $validate(): asserts this { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Variable assignment missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this.$node(this.name)) missing.push('name'); + if (!this.$value()) missing.push('.value()'); + return missing; + } +} diff --git a/packages/openapi-python/src/py-dsl/stmt/while.ts b/packages/openapi-python/src/py-dsl/stmt/while.ts new file mode 100644 index 000000000..4a4bfb9c2 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/stmt/while.ts @@ -0,0 +1,91 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; +import type { DoExpr } from '../mixins/do'; +import { BlockPyDsl } from './block'; + +const Mixed = PyDsl; + +export class WhilePyDsl extends Mixed { + readonly '~dsl' = 'WhilePyDsl'; + + protected _body?: Array; + protected _condition?: MaybePyDsl; + protected _else?: Array; + + constructor(condition: MaybePyDsl, ...body: Array) { + super(); + this._condition = condition; + this._body = body; + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + + if (this._condition) ctx.analyze(this._condition); + + if (this._body) { + ctx.pushScope(); + try { + for (const stmt of this._body) ctx.analyze(stmt); + } finally { + ctx.popScope(); + } + } + + if (this._else) { + ctx.pushScope(); + try { + for (const stmt of this._else) ctx.analyze(stmt); + } finally { + ctx.popScope(); + } + } + } + + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + + body(...items: Array): this { + this._body = items; + return this; + } + + else(...items: Array): this { + this._else = items; + return this; + } + + override toAst(): py.WhileStatement { + this.$validate(); + + const body = new BlockPyDsl(...this._body!).$do(); + const elseBlock = this._else ? new BlockPyDsl(...this._else).$do() : undefined; + + return py.factory.createWhileStatement( + this.$node(this._condition!) as py.Expression, + [...body], + elseBlock ? [...elseBlock] : undefined, + ); + } + + $validate(): asserts this is this & { + _body: Array; + _condition: MaybePyDsl; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`While statement missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._condition) missing.push('condition'); + if (!this._body || this._body.length === 0) missing.push('body'); + return missing; + } +} diff --git a/packages/openapi-python/src/py-dsl/stmt/with.ts b/packages/openapi-python/src/py-dsl/stmt/with.ts new file mode 100644 index 000000000..8a890b6db --- /dev/null +++ b/packages/openapi-python/src/py-dsl/stmt/with.ts @@ -0,0 +1,111 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; +import type { MaybePyDsl } from '../base'; +import { PyDsl } from '../base'; +import type { DoExpr } from '../mixins/do'; +import { BlockPyDsl } from './block'; + +const Mixed = PyDsl; + +export type WithItemInput = + | MaybePyDsl + | { alias?: MaybePyDsl; context: MaybePyDsl }; + +export class WithPyDsl extends Mixed { + readonly '~dsl' = 'WithPyDsl'; + + protected _body?: Array; + protected _items: Array = []; + protected _modifier?: MaybePyDsl; + + constructor(...items: Array) { + super(); + this._items = items; + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + + for (const item of this._items) { + if (typeof item === 'object' && 'context' in item) { + ctx.analyze(item.context); + ctx.analyze(item.alias); + } else { + ctx.analyze(item); + } + } + if (this._modifier) ctx.analyze(this._modifier); + + if (this._body) { + ctx.pushScope(); + try { + for (const stmt of this._body) ctx.analyze(stmt); + } finally { + ctx.popScope(); + } + } + } + + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + + body(...items: Array): this { + this._body = items; + return this; + } + + item(item: WithItemInput): this { + this._items.push(item); + return this; + } + + modifier(expr: MaybePyDsl): this { + this._modifier = expr; + return this; + } + + async(): this { + this._modifier = py.factory.createIdentifier('async'); + return this; + } + + override toAst(): py.WithStatement { + this.$validate(); + + const astItems = this._items.map((item) => { + if (typeof item === 'object' && 'context' in item) { + return py.factory.createWithItem( + this.$node(item.context) as py.Expression, + item.alias ? (this.$node(item.alias) as py.Expression) : undefined, + ); + } + return py.factory.createWithItem(this.$node(item) as py.Expression, undefined); + }); + + const body = new BlockPyDsl(...this._body!).$do(); + + return py.factory.createWithStatement( + astItems, + [...body], + this._modifier ? [this.$node(this._modifier) as py.Expression] : undefined, + ); + } + + $validate(): asserts this is this & { + _body: Array; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`With statement missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._items.length) missing.push('items'); + if (!this._body || this._body.length === 0) missing.push('.body()'); + return missing; + } +} diff --git a/packages/openapi-python/src/py-dsl/utils/__tests__/render-utils.test.ts b/packages/openapi-python/src/py-dsl/utils/__tests__/render-utils.test.ts new file mode 100644 index 000000000..9698b68d8 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/utils/__tests__/render-utils.test.ts @@ -0,0 +1,207 @@ +import { moduleSortKey } from '../render-utils'; + +const createFile = (finalPath: string, external = false) => { + const parts = finalPath.split('/'); + const filename = parts.at(-1)!; + const dotIndex = filename.lastIndexOf('.'); + return { + extension: dotIndex > 0 ? filename.slice(dotIndex) : undefined, + external, + finalPath, + name: dotIndex > 0 ? filename.slice(0, dotIndex) : filename, + }; +}; + +const root = '/project/src'; + +describe('moduleSortKey', () => { + describe('external imports (group 0)', () => { + it('returns external module path unchanged', () => { + const file = createFile('/project/src/client.py'); + const fromFile = createFile('httpx', true); + + const [group, distance, modulePath] = moduleSortKey({ + file, + fromFile, + preferFileExtension: '.py', + root, + }); + + expect(group).toBe(0); + expect(distance).toBe(0); + expect(modulePath).toBe('httpx'); + }); + }); + + describe('local imports (group 2)', () => { + describe('same directory', () => { + it('converts sibling file to relative import', () => { + const file = createFile('/project/src/api/client.py'); + const fromFile = createFile('/project/src/api/types.py'); + + const [group, distance, modulePath] = moduleSortKey({ + file, + fromFile, + preferFileExtension: '.py', + root, + }); + + expect(group).toBe(2); + expect(distance).toBe(0); + expect(modulePath).toBe('.types'); + }); + + it('handles index.py as implicit module', () => { + const file = createFile('/project/src/api/client.py'); + const fromFile = createFile('/project/src/api/index.py'); + + const [, , modulePath] = moduleSortKey({ + file, + fromFile, + preferFileExtension: '.py', + root, + }); + + expect(modulePath).toBe('.'); + }); + + it('handles __init__.py as implicit module', () => { + const file = createFile('/project/src/api/client.py'); + const fromFile = createFile('/project/src/api/__init__.py'); + + const [, , modulePath] = moduleSortKey({ + file, + fromFile, + preferFileExtension: '.py', + root, + }); + + expect(modulePath).toBe('.'); + }); + }); + + describe('child directory', () => { + it('converts nested path to dotted module', () => { + const file = createFile('/project/src/client.py'); + const fromFile = createFile('/project/src/models/user.py'); + + const [group, distance, modulePath] = moduleSortKey({ + file, + fromFile, + preferFileExtension: '.py', + root, + }); + + expect(group).toBe(2); + expect(distance).toBe(0); + expect(modulePath).toBe('.models.user'); + }); + + it('handles deeply nested paths', () => { + const file = createFile('/project/src/client.py'); + const fromFile = createFile('/project/src/api/v1/endpoints/users.py'); + + const [, , modulePath] = moduleSortKey({ + file, + fromFile, + preferFileExtension: '.py', + root, + }); + + expect(modulePath).toBe('.api.v1.endpoints.users'); + }); + + it('handles index.py in child directory', () => { + const file = createFile('/project/src/client.py'); + const fromFile = createFile('/project/src/models/index.py'); + + const [, , modulePath] = moduleSortKey({ + file, + fromFile, + preferFileExtension: '.py', + root, + }); + + expect(modulePath).toBe('.models'); + }); + }); + + describe('parent directory', () => { + it('converts single parent traversal', () => { + const file = createFile('/project/src/api/client.py'); + const fromFile = createFile('/project/src/types.py'); + + const [group, distance, modulePath] = moduleSortKey({ + file, + fromFile, + preferFileExtension: '.py', + root, + }); + + expect(group).toBe(2); + expect(distance).toBe(1); + expect(modulePath).toBe('..types'); + }); + + it('converts double parent traversal', () => { + const file = createFile('/project/src/api/v1/client.py'); + const fromFile = createFile('/project/src/types.py'); + + const [, distance, modulePath] = moduleSortKey({ + file, + fromFile, + preferFileExtension: '.py', + root, + }); + + expect(distance).toBe(2); + expect(modulePath).toBe('...types'); + }); + + it('handles index.py in parent directory', () => { + const file = createFile('/project/src/api/client.py'); + const fromFile = createFile('/project/src/index.py'); + + const [, , modulePath] = moduleSortKey({ + file, + fromFile, + preferFileExtension: '.py', + root, + }); + + expect(modulePath).toBe('..'); + }); + }); + + describe('sibling directory', () => { + it('converts sibling directory path', () => { + const file = createFile('/project/src/api/client.py'); + const fromFile = createFile('/project/src/models/user.py'); + + const [, distance, modulePath] = moduleSortKey({ + file, + fromFile, + preferFileExtension: '.py', + root, + }); + + expect(distance).toBe(1); + expect(modulePath).toBe('..models.user'); + }); + + it('handles index.py in sibling directory', () => { + const file = createFile('/project/src/api/client.py'); + const fromFile = createFile('/project/src/models/index.py'); + + const [, , modulePath] = moduleSortKey({ + file, + fromFile, + preferFileExtension: '.py', + root, + }); + + expect(modulePath).toBe('..models'); + }); + }); + }); +}); diff --git a/packages/openapi-python/src/py-dsl/utils/context.ts b/packages/openapi-python/src/py-dsl/utils/context.ts new file mode 100644 index 000000000..406ae8cc1 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/utils/context.ts @@ -0,0 +1,249 @@ +import type { BindingKind } from '@hey-api/codegen-core'; +// import type { BindingKind, NodeScope, Symbol } from '@hey-api/codegen-core'; +// import { isSymbol } from '@hey-api/codegen-core'; +import type { MaybeFunc } from '@hey-api/types'; + +import type { DollarPyDsl } from '../../py-dsl'; +import type { py } from '../../ts-python'; +// import { $, PythonRenderer } from '../../py-dsl'; +import type { PyDsl } from '../base'; +import type { CallArgs } from '../expr/call'; + +export type NodeChain = ReadonlyArray; + +export interface AccessOptions { + /** The access context. */ + context?: 'example'; + /** Enable debug mode. */ + debug?: boolean; + /** Transform function for each node in the access chain. */ + transform?: (node: PyDsl, index: number, chain: NodeChain) => PyDsl; +} + +// export type AccessResult = ReturnType; + +export interface ExampleOptions { + /** Import kind for the root node. */ + importKind?: BindingKind; + /** Import name for the root node. */ + importName?: string; + /** Setup to run before calling the example. */ + importSetup?: MaybeFunc< + ( + ctx: DollarPyDsl & { + /** The imported expression. */ + node: PyDsl; + }, + ) => PyDsl + >; + /** Module to import from. */ + moduleName?: string; + /** Example request payload. */ + payload?: MaybeFunc<(ctx: DollarPyDsl) => CallArgs | CallArgs[number]>; + /** Variable name for setup node. */ + setupName?: string; +} + +// function accessChainToNode(accessChain: NodeChain): T { +// let result!: AccessResult; +// accessChain.forEach((node, index) => { +// if (index === 0) { +// // assume correct node +// result = node as typeof result; +// } else { +// result = result.attr(node.name); +// } +// }); +// return result as T; +// } + +// function getAccessChainForNode(node: PyDsl): NodeChain { +// const structuralChain = [...getStructuralChainForNode(node, new Set())]; +// const accessChain = structuralToAccessChain(structuralChain); +// if (accessChain.length === 0) { +// // I _think_ this should not happen, but it does and this fix works for now. +// // I assume this will cause issues with imports in some cases, investigate +// // when it actually happens. +// return [node.clone()]; +// } +// return accessChain.map((node) => node.clone()); +// } + +// function getScope(node: PyDsl): NodeScope { +// return node.scope ?? 'value'; +// } + +// function getStructuralChainForNode(node: PyDsl, visited: Set): NodeChain { +// if (visited.has(node)) return []; +// visited.add(node); + +// if (isStopNode(node)) return []; + +// if (node.structuralParents) { +// for (const [parent] of node.structuralParents) { +// if (getScope(parent) !== getScope(node)) continue; + +// const chain = getStructuralChainForNode(parent, visited); +// if (chain.length > 0) return [...chain, node]; +// } +// } + +// if (!node.root) return []; + +// return [node]; +// } + +// function isAccessorNode(node: PyDsl): boolean { +// return ( +// node['~dsl'] === 'FieldPyDsl' || +// node['~dsl'] === 'GetterPyDsl' || +// node['~dsl'] === 'MethodPyDsl' +// ); +// } + +// function isStopNode(node: PyDsl): boolean { +// return node['~dsl'] === 'FuncPyDsl' || node['~dsl'] === 'TemplatePyDsl'; +// } + +/** + * Fold a structural chain to an access chain by removing + * non-accessor nodes. + */ +// function structuralToAccessChain(structuralChain: NodeChain): NodeChain { +// const accessChain: Array = []; +// structuralChain.forEach((node, index) => { +// // assume first node is always included +// if (index === 0) { +// accessChain.push(node); +// } else if (isAccessorNode(node)) { +// accessChain.push(node); +// } +// }); +// return accessChain; +// } + +// function transformAccessChain(accessChain: NodeChain, options: AccessOptions = {}): NodeChain { +// return accessChain.map((node, index) => { +// const transformedNode = options.transform?.(node, index, accessChain); +// if (transformedNode) return transformedNode; +// const accessNode = node.toAccessNode?.(node, options, { +// chain: accessChain, +// index, +// isLeaf: index === accessChain.length - 1, +// isRoot: index === 0, +// length: accessChain.length, +// }); +// if (accessNode) return accessNode; +// if (index === 0) { +// if (node['~dsl'] === 'ClassPyDsl') { +// const nextNode = accessChain[index + 1]; +// if (nextNode && isAccessorNode(nextNode)) { +// if ((nextNode as ReturnType).hasModifier('static')) { +// return $(node.name); +// } +// } +// return $.new(node.name).args(); +// } +// return $(node.name); +// } +// return node; +// }); +// } + +export class PyDslContext { + /** + * Build an expression for accessing the node. + * + * @param node - The node or symbol to build access for + * @param options - Access options + * @returns Expression for accessing the node + * + * @example + * ```ts + * ctx.access(node); // → Expression for accessing the node + * ``` + */ + // access(node: PyDsl | Symbol, options?: AccessOptions): T { + // const n = isSymbol(node) ? node.node : node; + // if (!n) { + // throw new Error(`Symbol ${node.name} is not resolved to a node.`); + // } + // const accessChain = getAccessChainForNode(n); + // const finalChain = transformAccessChain(accessChain, options); + // return accessChainToNode(finalChain); + // } + /** + * Build an example. + * + * @param node - The node to generate an example for + * @param options - Example options + * @returns Full example string + * + * @example + * ```ts + * ctx.example(node, { moduleName: 'my-sdk' }); // → Full example string + * ``` + */ + // example( + // node: PyDsl, + // options?: ExampleOptions, + // astOptions?: Parameters[0], + // ): string { + // if (astOptions) { + // return PythonRenderer.astToString(astOptions); + // } + // options ||= {}; + // const accessChain = getAccessChainForNode(node); + // if (options.importName) { + // accessChain[0]!.name.set(options.importName); + // } + // const importNode = $(accessChain[0]!.name.toString()); // must store name before transform + // const finalChain = transformAccessChain(accessChain, { + // context: 'example', + // }); + // const setupNode = options.importSetup + // ? typeof options.importSetup === 'function' + // ? options.importSetup({ $, node: importNode }) + // : options.importSetup + // : (finalChain[0]! as PyDsl); + // const setupName = options.setupName; + // let payload = typeof options.payload === 'function' ? options.payload({ $ }) : options.payload; + // payload = payload instanceof Array ? payload : payload ? [payload] : []; + // let nodes: Array = []; + // if (setupName) { + // nodes = [ + // $.const(setupName).assign(setupNode), + // $.await(accessChainToNode([$(setupName), ...finalChain.slice(1)]).call(...payload)), + // ]; + // } else { + // nodes = [$.await(accessChainToNode([setupNode, ...finalChain.slice(1)]).call(...payload))]; + // } + // const localName = importNode.name.toString(); + // return PythonRenderer.astToString({ + // imports: [ + // [ + // { + // imports: + // !options.importKind || options.importKind === 'named' + // ? [ + // { + // isTypeOnly: false, + // localName, + // sourceName: localName, + // }, + // ] + // : [], + // isTypeOnly: false, + // kind: options.importKind ?? 'named', + // localName: options.importKind !== 'named' ? localName : undefined, + // modulePath: options.moduleName ?? 'your-package', + // }, + // ], + // ], + // nodes, + // trailingNewline: false, + // }); + // } +} + +export const ctx = new PyDslContext(); diff --git a/packages/openapi-python/src/py-dsl/utils/keywords.ts b/packages/openapi-python/src/py-dsl/utils/keywords.ts new file mode 100644 index 000000000..6dbb83282 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/utils/keywords.ts @@ -0,0 +1,115 @@ +const pythonKeywords = [ + 'and', + 'as', + 'assert', + 'async', + 'await', + 'break', + 'class', + 'continue', + 'def', + 'del', + 'elif', + 'else', + 'except', + 'False', + 'finally', + 'for', + 'from', + 'global', + 'if', + 'import', + 'in', + 'is', + 'lambda', + 'None', + 'nonlocal', + 'not', + 'or', + 'pass', + 'raise', + 'return', + 'True', + 'try', + 'while', + 'with', + 'yield', +]; + +const pythonBuiltins = [ + 'abs', + 'aiter', + 'all', + 'anext', + 'any', + 'ascii', + 'bin', + 'bool', + 'breakpoint', + 'bytearray', + 'bytes', + 'callable', + 'chr', + 'classmethod', + 'compile', + 'complex', + 'delattr', + 'dict', + 'dir', + 'divmod', + 'enumerate', + 'eval', + 'exec', + 'filter', + 'float', + 'format', + 'frozenset', + 'getattr', + 'globals', + 'hasattr', + 'hash', + 'help', + 'hex', + 'id', + 'input', + 'int', + 'isinstance', + 'issubclass', + 'iter', + 'len', + 'list', + 'locals', + 'map', + 'max', + 'memoryview', + 'min', + 'next', + 'object', + 'oct', + 'open', + 'ord', + 'pow', + 'print', + 'property', + 'range', + 'repr', + 'reversed', + 'round', + 'set', + 'setattr', + 'slice', + 'sorted', + 'staticmethod', + 'str', + 'sum', + 'super', + 'tuple', + 'type', + 'vars', + 'zip', +]; + +export const keywords = { + pythonBuiltins, + pythonKeywords, +}; diff --git a/packages/openapi-python/src/py-dsl/utils/lazy.ts b/packages/openapi-python/src/py-dsl/utils/lazy.ts new file mode 100644 index 000000000..271ac90d5 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/utils/lazy.ts @@ -0,0 +1,32 @@ +import type { AnalysisContext } from '@hey-api/codegen-core'; + +import type { py } from '../../ts-python'; +import { PyDsl } from '../base'; +import type { PyDslContext } from './context'; +import { ctx } from './context'; + +export type LazyThunk = (ctx: PyDslContext) => PyDsl; + +export class LazyPyDsl extends PyDsl { + readonly '~dsl' = 'LazyPyDsl'; + + private _thunk: LazyThunk; + + constructor(thunk: LazyThunk) { + super(); + this._thunk = thunk; + } + + override analyze(ctx: AnalysisContext): void { + super.analyze(ctx); + ctx.analyze(this.toResult()); + } + + toResult(): PyDsl { + return this._thunk(ctx); + } + + override toAst(): T { + return this.toResult().toAst(); + } +} diff --git a/packages/openapi-python/src/py-dsl/utils/name.ts b/packages/openapi-python/src/py-dsl/utils/name.ts new file mode 100644 index 000000000..90a0ffeb4 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/utils/name.ts @@ -0,0 +1,45 @@ +import { regexp } from './regexp'; +import type { ReservedList } from './reserved'; +import { reserved } from './reserved'; + +export const safeAccessorName = (name: string): string => { + regexp.number.lastIndex = 0; + if (regexp.number.test(name)) { + return name.startsWith('-') ? `'${name}'` : name; + } + + regexp.pythonIdentifier.lastIndex = 0; + if (regexp.pythonIdentifier.test(name)) { + return name; + } + return `'${name}'`; +}; + +const safeName = (name: string, reserved: ReservedList): string => { + let sanitized = ''; + let index: number; + + const first = name[0] ?? ''; + regexp.illegalStartCharacters.lastIndex = 0; + if (regexp.illegalStartCharacters.test(first)) { + sanitized += '_'; + index = 0; + } else { + sanitized += first; + index = 1; + } + + while (index < name.length) { + const char = name[index] ?? ''; + sanitized += /^[a-zA-Z0-9_]$/.test(char) ? char : '_'; + index += 1; + } + + if (reserved['~values'].has(sanitized)) { + sanitized = `${sanitized}_`; + } + + return sanitized || '_'; +}; + +export const safeRuntimeName = (name: string): string => safeName(name, reserved.runtime); diff --git a/packages/openapi-python/src/py-dsl/utils/regexp.ts b/packages/openapi-python/src/py-dsl/utils/regexp.ts new file mode 100644 index 000000000..63959f74b --- /dev/null +++ b/packages/openapi-python/src/py-dsl/utils/regexp.ts @@ -0,0 +1,41 @@ +/** + * Matches characters from the start that are not valid Python identifier starts. + * Python identifiers: starts with letter/underscore, followed by letters/digits/underscores. + */ +const illegalStartCharactersRegExp = /^[^a-zA-Z_]+/; + +/** + * Matches if string contains only digits and optionally decimal point or leading minus. + */ +const numberRegExp = /^-?\d+(\.\d+)?$/; + +/** + * Python identifier pattern: starts with letter or underscore, + * followed by letters, digits, or underscores. + * Uses Unicode categories for full Python 3 compliance. + */ +const validPythonIdentifierRegExp = /^[a-zA-Z_][a-zA-Z0-9_]*$/u; + +/** + * Matches if a string looks like a valid Python identifier. + */ +const looksLikeIdentifierRegExp = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +export const regexp = { + /** + * Matches characters from the start that are not valid Python identifier starts. + */ + illegalStartCharacters: illegalStartCharactersRegExp, + /** + * Simpler pattern for quick identifier checks. + */ + looksLikeIdentifier: looksLikeIdentifierRegExp, + /** + * Matches if string contains only digits and optionally decimal point or leading minus. + */ + number: numberRegExp, + /** + * Python identifier pattern for validation. + */ + pythonIdentifier: validPythonIdentifierRegExp, +}; diff --git a/packages/openapi-python/src/py-dsl/utils/render-utils.ts b/packages/openapi-python/src/py-dsl/utils/render-utils.ts new file mode 100644 index 000000000..70def1801 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/utils/render-utils.ts @@ -0,0 +1,88 @@ +import path from 'node:path'; + +import type { ExportModule, File, ImportModule } from '@hey-api/codegen-core'; + +import { py } from '../../ts-python'; + +const printer = py.createPrinter({ + indentSize: 4, +}); + +/** Print a Python node to a string. */ +export function astToString(node: py.Node): string { + const result = printer.printFile(node); + return result; +} + +export type SortGroup = number; +export type SortDistance = number; +export type SortModule = string; +export type SortKey = [SortGroup, SortDistance, SortModule]; + +export type ModuleExport = Omit & { + /** Module specifier for re-exports, e.g. `./foo`. */ + modulePath: string; +}; + +export type ModuleImport = Omit & { + /** Module specifier for imports, e.g. `./foo`. */ + modulePath: string; +}; + +export function moduleSortKey({ + file, + fromFile, + root, +}: { + file: Pick; + fromFile: Pick; + preferFileExtension: string; + root: string; +}): SortKey { + const filePath = file.finalPath!.split(path.sep).join('/'); + let modulePath = fromFile.finalPath!.split(path.sep).join('/'); + + // built-ins + // TODO + + // external + if (fromFile.external && !path.isAbsolute(modulePath)) { + return [0, 0, modulePath]; + } + + // outside project root + if (!modulePath.startsWith(root.split(path.sep).join('/'))) { + return [1, 0, modulePath]; + } + + // local + const rel = path + .relative(path.dirname(filePath), path.dirname(modulePath)) + .split(path.sep) + .join('/'); + + const segments = rel ? rel.split('/') : []; + const parentCount = segments.filter((s) => s === '..').length; + + const leadingDots = '.'.repeat(parentCount + 1); + + const pathSegments = segments.filter((s) => s !== '..' && s !== '.'); + + const filename = modulePath.split('/').at(-1)!; + // TODO: replace with extension check, there's an issue with external files + // not having extension set + const moduleName = filename.replace(/\.[^.]+$/, ''); + // const moduleName = fromFile.extension + // ? filename.slice(0, -fromFile.extension.length) + // : filename; + + // index/__init__ are implicit + const isImplicitModule = moduleName === 'index' || moduleName === '__init__'; + if (!isImplicitModule) { + pathSegments.push(moduleName); + } + + modulePath = pathSegments.length > 0 ? leadingDots + pathSegments.join('.') : leadingDots; + + return [2, parentCount, modulePath]; +} diff --git a/packages/openapi-python/src/py-dsl/utils/render.ts b/packages/openapi-python/src/py-dsl/utils/render.ts new file mode 100644 index 000000000..9e003b55e --- /dev/null +++ b/packages/openapi-python/src/py-dsl/utils/render.ts @@ -0,0 +1,311 @@ +import type { RenderContext, Renderer } from '@hey-api/codegen-core'; +import type { MaybeArray, MaybeFunc } from '@hey-api/types'; + +import type { PyDsl } from '../../py-dsl'; +import { py } from '../../ts-python'; +import type { ModuleExport, ModuleImport, SortGroup, SortKey, SortModule } from './render-utils'; +import { astToString, moduleSortKey } from './render-utils'; + +type Exports = ReadonlyArray>; +type ExportsOptions = { + preferExportAll?: boolean; +}; +type Header = MaybeArray | null | undefined; +type Imports = ReadonlyArray>; + +function headerToLines(header: Header): ReadonlyArray { + if (!header) return []; + const lines: Array = []; + if (typeof header === 'string') { + lines.push(...header.split(/\r?\n/)); + return lines; + } + for (const line of header) { + lines.push(...line.split(/\r?\n/)); + } + return lines; +} + +export class PythonRenderer implements Renderer { + /** + * Function to generate a file header. + * + * @private + */ + private _header?: MaybeFunc<(ctx: RenderContext) => Header>; + /** + * Whether `export * from 'module'` should be used when possible instead of named exports. + * + * @private + */ + private _preferExportAll: boolean; + /** + * Controls whether imports/exports include a file extension (e.g., '.ts' or '.js'). + * + * @private + */ + private _preferFileExtension: string; + /** + * Optional function to transform module specifiers. + * + * @private + */ + private _resolveModuleName?: (moduleName: string) => string | undefined; + + constructor( + args: { + header?: MaybeFunc<(ctx: RenderContext) => Header>; + preferExportAll?: boolean; + preferFileExtension?: string; + resolveModuleName?: (moduleName: string) => string | undefined; + } = {}, + ) { + this._header = args.header; + this._preferExportAll = args.preferExportAll ?? false; + this._preferFileExtension = args.preferFileExtension ?? ''; + this._resolveModuleName = args.resolveModuleName; + } + + render(ctx: RenderContext): string { + const header = typeof this._header === 'function' ? this._header(ctx) : this._header; + return PythonRenderer.astToString({ + // exports: this.getExports(ctx), + exportsOptions: { + preferExportAll: this._preferExportAll, + }, + header, + imports: this.getImports(ctx), + nodes: ctx.file.nodes, + }); + } + + supports(ctx: RenderContext): boolean { + return ctx.file.language === 'python'; + } + + static astToString(args: { + exports?: Exports; + exportsOptions?: ExportsOptions; + header?: Header; + imports?: Imports; + nodes?: ReadonlyArray; + /** + * Whether to include a trailing newline at the end of the file. + * + * @default true + */ + trailingNewline?: boolean; + }): string { + let text = ''; + for (const header of headerToLines(args.header)) { + text += `${header}\n`; + } + + let imports = ''; + for (const group of args.imports ?? []) { + if (imports) imports += '\n'; + for (const imp of group) { + imports += `${astToString(PythonRenderer.toImportAst(imp))}\n`; + } + } + text = `${text}${text && imports ? '\n' : ''}${imports}`; + + let nodes = ''; + for (const node of args.nodes ?? []) { + if (nodes) nodes += '\n'; + nodes += `${astToString(node.toAst())}\n`; + } + text = `${text}${text && nodes ? '\n' : ''}${nodes}`; + + const exports = ''; + // let exports = ''; + // for (const group of args.exports ?? []) { + // if ((!exports && nodes) || exports) exports += '\n'; + // for (const exp of group) { + // exports += `${astToString(PythonRenderer.toExportAst(exp, args.exportsOptions))}\n`; + // } + // } + text = `${text}${text && exports ? '\n' : ''}${exports}`; + + if (args.trailingNewline === false && text.endsWith('\n')) { + text = text.slice(0, -1); + } + + return text; + } + + // static toExportAst(group: ModuleExport, options?: ExportsOptions): ts.ExportDeclaration { + // const specifiers = group.exports.map((exp) => { + // const specifier = ts.factory.createExportSpecifier( + // exp.isTypeOnly, + // exp.sourceName !== exp.exportedName ? $.id(exp.sourceName).toAst() : undefined, + // $.id(exp.exportedName).toAst(), + // ); + // return specifier; + // }); + // const exportClause = group.namespaceExport + // ? ts.factory.createNamespaceExport($.id(group.namespaceExport).toAst()) + // : (!group.canExportAll || !options?.preferExportAll) && specifiers.length + // ? ts.factory.createNamedExports(specifiers) + // : undefined; + // return ts.factory.createExportDeclaration( + // undefined, + // group.isTypeOnly, + // exportClause, + // $.literal(group.modulePath).toAst(), + // ); + // } + + static toImportAst(group: ModuleImport): py.ImportStatement { + const names: Array<{ + alias?: string; + name: string; + }> = group.imports.map((imp) => ({ + alias: imp.localName !== imp.sourceName ? imp.localName : undefined, + name: imp.sourceName, + })); + return py.factory.createImportStatement(group.modulePath, names, group.imports.length > 0); + } + + // private getExports(ctx: RenderContext): Exports { + // type ModuleEntry = { + // group: ModuleExport; + // sortKey: SortKey; + // }; + + // const groups = new Map>(); + + // for (const exp of ctx.file.exports) { + // const sortKey = moduleSortKey({ + // file: ctx.file, + // fromFile: exp.from, + // preferFileExtension: this._preferFileExtension, + // root: ctx.project.root, + // }); + // const modulePath = this._resolveModuleName?.(sortKey[2]) ?? sortKey[2]; + // const [groupIndex] = sortKey; + + // if (!groups.has(groupIndex)) groups.set(groupIndex, new Map()); + // const moduleMap = groups.get(groupIndex)!; + + // if (!moduleMap.has(modulePath)) { + // moduleMap.set(modulePath, { + // group: { + // canExportAll: exp.canExportAll, + // exports: exp.exports, + // isTypeOnly: exp.isTypeOnly, + // modulePath, + // namespaceExport: exp.namespaceExport, + // }, + // sortKey, + // }); + // } + // } + + // const exports: Array> = Array.from(groups.entries()) + // .sort((a, b) => a[0] - b[0]) + // .map(([, moduleMap]) => { + // const entries = Array.from(moduleMap.values()); + + // entries.sort((a, b) => { + // const d = a.sortKey[1] - b.sortKey[1]; + // return d !== 0 ? d : a.group.modulePath.localeCompare(b.group.modulePath); + // }); + + // return entries.map((e) => { + // const group = e.group; + // if (group.namespaceExport) { + // group.exports = []; + // } else { + // const isTypeOnly = !group.exports.find((exp) => !exp.isTypeOnly); + // if (isTypeOnly) { + // group.isTypeOnly = true; + // for (const exp of group.exports) { + // exp.isTypeOnly = false; + // } + // } + // group.exports.sort((a, b) => a.exportedName.localeCompare(b.exportedName)); + // } + // return group; + // }); + // }); + + // return exports; + // } + + private getImports(ctx: RenderContext): Imports { + type ModuleEntry = { + group: ModuleImport; + sortKey: SortKey; + }; + + const groups = new Map>(); + + for (const imp of ctx.file.imports) { + const sortKey = moduleSortKey({ + file: ctx.file, + fromFile: imp.from, + preferFileExtension: this._preferFileExtension, + root: ctx.project.root, + }); + const modulePath = this._resolveModuleName?.(sortKey[2]) ?? sortKey[2]; + const [groupIndex] = sortKey; + + if (!groups.has(groupIndex)) groups.set(groupIndex, new Map()); + const moduleMap = groups.get(groupIndex)!; + + if (!moduleMap.has(modulePath)) { + moduleMap.set(modulePath, { + group: { + imports: [], + isTypeOnly: false, + kind: imp.kind, + modulePath, + }, + sortKey, + }); + } + + const entry = moduleMap.get(modulePath)!; + const group = entry.group; + + if (imp.kind !== 'named') { + group.isTypeOnly = imp.isTypeOnly; + group.kind = imp.kind; + group.localName = imp.localName; + } else { + group.imports.push(...imp.imports); + } + } + + const imports: Array> = Array.from(groups.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([, moduleMap]) => { + const entries = Array.from(moduleMap.values()); + + entries.sort((a, b) => { + const d = a.sortKey[1] - b.sortKey[1]; + return d !== 0 ? d : a.group.modulePath.localeCompare(b.group.modulePath); + }); + + return entries.map((e) => { + const group = e.group; + if (group.kind === 'namespace') { + group.imports = []; + } else { + const isTypeOnly = !group.imports.find((imp) => !imp.isTypeOnly); + if (isTypeOnly) { + group.isTypeOnly = true; + for (const imp of group.imports) { + imp.isTypeOnly = false; + } + } + group.imports.sort((a, b) => a.localName.localeCompare(b.localName)); + } + return group; + }); + }); + + return imports; + } +} diff --git a/packages/openapi-python/src/py-dsl/utils/reserved.ts b/packages/openapi-python/src/py-dsl/utils/reserved.ts new file mode 100644 index 000000000..ba9b74499 --- /dev/null +++ b/packages/openapi-python/src/py-dsl/utils/reserved.ts @@ -0,0 +1,44 @@ +import { keywords } from './keywords'; + +type List = ReadonlyArray; + +export class ReservedList { + private _array: List; + private _set: Set; + + constructor(values: List) { + this._array = values; + this._set = new Set(values); + } + + get '~values'() { + return this._set; + } + + /** + * Updates the reserved list with new values. + * + * @param values New reserved values or a function that receives the previous + * reserved values and returns the new ones. + */ + set(values: List | ((prev: List) => List)): void { + const vals = typeof values === 'function' ? values(this._array) : values; + this._array = vals; + this._set = new Set(vals); + } +} + +const runtimeReserved = new ReservedList([...keywords.pythonKeywords, ...keywords.pythonBuiltins]); + +/** + * Reserved names for identifiers. These names will not be used + * for variables, functions, classes, or other identifiers in generated code. + */ +export const reserved = { + /** + * Reserved names for runtime identifiers. These names will not be used + * for variables, functions, classes, or other runtime identifiers in + * generated code. + */ + runtime: runtimeReserved, +}; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/declarations/class.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/declarations/class.test.ts index 095594f74..0bac169b3 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/declarations/class.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/declarations/class.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/declarations/function.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/declarations/function.test.ts index 5ae74d23b..496b85dfa 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/declarations/function.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/declarations/function.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/await.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/await.test.ts index cdd469d7b..408235094 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/await.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/await.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/binary.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/binary.test.ts index e4b84f42d..6cc2b6284 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/binary.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/binary.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/call.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/call.test.ts index 854a2fb26..35a060c71 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/call.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/call.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/dict.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/dict.test.ts index 85c08e882..0c1244fe8 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/dict.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/dict.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../../index'; import { assertPrintedMatchesSnapshot } from '../../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/list.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/list.test.ts index dec52782e..b95e28588 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/list.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/list.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../../index'; import { assertPrintedMatchesSnapshot } from '../../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/nested.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/nested.test.ts index 97dbf1e9c..28cbfc017 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/nested.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/nested.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../../index'; import { assertPrintedMatchesSnapshot } from '../../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/set.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/set.test.ts index 8d7eb71a5..481843e7a 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/set.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/comprehensions/set.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../../index'; import { assertPrintedMatchesSnapshot } from '../../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/dict.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/dict.test.ts index 2f33c6f6f..e92f3c040 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/dict.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/dict.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/fString.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/fString.test.ts index d0ad22455..5d737a7bc 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/fString.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/fString.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/generator.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/generator.test.ts index 80c7a5846..76f695fec 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/generator.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/generator.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/identifier.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/identifier.test.ts index 6698a000a..dfa528334 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/identifier.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/identifier.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/lambda.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/lambda.test.ts index 231f3b64e..0178f8f8b 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/lambda.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/lambda.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/list.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/list.test.ts index 86d68f1d2..cb29454fd 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/list.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/list.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/literal.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/literal.test.ts index fdae63695..7d72f6a11 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/literal.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/literal.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/set.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/set.test.ts index a69559595..eeb8591d4 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/set.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/set.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/tuple.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/tuple.test.ts index a7311e85b..00be79a15 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/tuple.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/tuple.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/yield.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/yield.test.ts index 5c0ec8549..5404f1ac7 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/yield.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/expressions/yield.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/assignment.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/assignment.test.ts index bc8449c78..0539f9862 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/assignment.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/assignment.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/augmentedAssignment.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/augmentedAssignment.test.ts index 913483fab..ea040ac3c 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/augmentedAssignment.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/augmentedAssignment.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/block.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/block.test.ts index c10ac8327..c2ed60e86 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/block.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/block.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../..'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/break.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/break.test.ts index 13e643b86..9d45258b8 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/break.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/break.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/continue.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/continue.test.ts index d24e20bba..81993ee5c 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/continue.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/continue.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/expression.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/expression.test.ts index 60ec732cf..181e8e8ba 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/expression.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/expression.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/for.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/for.test.ts index d0f390004..6397044a6 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/for.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/for.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/if.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/if.test.ts index 3f1aa4a34..95dfe2daf 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/if.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/if.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/import.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/import.test.ts index 6901b962c..a4c780198 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/import.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/import.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/raise.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/raise.test.ts index 93f06ee34..aa90c9718 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/raise.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/raise.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/return.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/return.test.ts index 8afd5ff7d..730f9141e 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/return.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/return.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/try.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/try.test.ts index 923593276..bd543eb70 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/try.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/try.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/while.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/while.test.ts index bb66537a8..0cb875aea 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/while.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/while.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/with.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/with.test.ts index 380e0a8c1..d8fc97b77 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/statements/with.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/statements/with.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/structure/comment.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/structure/comment.test.ts index f3bc65fab..d2ec17b1d 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/structure/comment.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/structure/comment.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/structure/sourceFile.test.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/structure/sourceFile.test.ts index bbc0f4f14..b1d95811a 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/structure/sourceFile.test.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/structure/sourceFile.test.ts @@ -1,5 +1,3 @@ -import { describe, it } from 'vitest'; - import { py } from '../../../index'; import { assertPrintedMatchesSnapshot } from '../utils'; diff --git a/packages/openapi-python/src/ts-python/__tests__/nodes/utils.ts b/packages/openapi-python/src/ts-python/__tests__/nodes/utils.ts index 410f94590..b01297bdf 100644 --- a/packages/openapi-python/src/ts-python/__tests__/nodes/utils.ts +++ b/packages/openapi-python/src/ts-python/__tests__/nodes/utils.ts @@ -1,10 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { expect } from 'vitest'; - import { py } from '../../index'; -import type { PySourceFile } from '../../nodes/structure/sourceFile'; import { snapshotsDir, tmpDir } from '../constants'; function getCallerFile(): string { @@ -38,7 +35,7 @@ function ensureInitFiles(dir: string, rootDir: string): void { } export async function assertPrintedMatchesSnapshot( - file: PySourceFile, + file: py.SourceFile, filename: string, ): Promise { const result = py.createPrinter().printFile(file); diff --git a/packages/openapi-python/src/ts-python/index.ts b/packages/openapi-python/src/ts-python/index.ts index 787055dd6..da903865f 100644 --- a/packages/openapi-python/src/ts-python/index.ts +++ b/packages/openapi-python/src/ts-python/index.ts @@ -1,7 +1,130 @@ +import type { PyNode as _PyNode, PyNodeBase as _PyNodeBase } from './nodes/base'; +import type { + PyComprehension as _PyComprehension, + PyComprehensionNode as _PyComprehensionNode, +} from './nodes/comprehension'; +import type { PyClassDeclaration as _PyClassDeclaration } from './nodes/declarations/class'; +import type { PyFunctionDeclaration as _PyFunctionDeclaration } from './nodes/declarations/function'; +import type { PyFunctionParameter as _PyFunctionParameter } from './nodes/declarations/functionParameter'; +import type { PyExpression as _PyExpression } from './nodes/expression'; +import type { PyAsyncExpression as _PyAsyncExpression } from './nodes/expressions/async'; +import type { PyAwaitExpression as _PyAwaitExpression } from './nodes/expressions/await'; +import type { + PyBinaryExpression as _PyBinaryExpression, + PyBinaryOperator as _PyBinaryOperator, +} from './nodes/expressions/binary'; +import type { PyCallExpression as _PyCallExpression } from './nodes/expressions/call'; +import type { PyDictComprehension as _PyDictComprehension } from './nodes/expressions/comprehensions/dict'; +import type { PyListComprehension as _PyListComprehension } from './nodes/expressions/comprehensions/list'; +import type { PySetComprehension as _PySetComprehension } from './nodes/expressions/comprehensions/set'; +import type { PyDictExpression as _PyDictExpression } from './nodes/expressions/dict'; +import type { PyFStringExpression as _PyFStringExpression } from './nodes/expressions/fString'; +import type { PyGeneratorExpression as _PyGeneratorExpression } from './nodes/expressions/generator'; +import type { PyIdentifier as _PyIdentifier } from './nodes/expressions/identifier'; +import type { PyLambdaExpression as _PyLambdaExpression } from './nodes/expressions/lambda'; +import type { PyListExpression as _PyListExpression } from './nodes/expressions/list'; +import type { PyLiteral as _PyLiteral } from './nodes/expressions/literal'; +import type { PyMemberExpression as _PyMemberExpression } from './nodes/expressions/member'; +import type { PySetExpression as _PySetExpression } from './nodes/expressions/set'; +import type { PyTupleExpression as _PyTupleExpression } from './nodes/expressions/tuple'; +import type { PyYieldExpression as _PyYieldExpression } from './nodes/expressions/yield'; +import type { PyYieldFromExpression as _PyYieldFromExpression } from './nodes/expressions/yieldFrom'; import { factory } from './nodes/factory'; import { PyNodeKind } from './nodes/kinds'; +import type { PyStatement as _PyStatement } from './nodes/statement'; +import type { PyAssignment as _PyAssignment } from './nodes/statements/assignment'; +import type { + PyAugmentedAssignment as _PyAugmentedAssignment, + PyAugmentedOperator as _PyAugmentedOperator, +} from './nodes/statements/augmentedAssignment'; +import type { PyBlock as _PyBlock } from './nodes/statements/block'; +import type { PyBreakStatement as _PyBreakStatement } from './nodes/statements/break'; +import type { PyContinueStatement as _PyContinueStatement } from './nodes/statements/continue'; +import type { PyEmptyStatement as _PyEmptyStatement } from './nodes/statements/empty'; +import type { PyExceptClause as _PyExceptClause } from './nodes/statements/except'; +import type { PyExpressionStatement as _PyExpressionStatement } from './nodes/statements/expression'; +import type { PyForStatement as _PyForStatement } from './nodes/statements/for'; +import type { PyIfStatement as _PyIfStatement } from './nodes/statements/if'; +import type { PyImportStatement as _PyImportStatement } from './nodes/statements/import'; +import type { PyRaiseStatement as _PyRaiseStatement } from './nodes/statements/raise'; +import type { PyReturnStatement as _PyReturnStatement } from './nodes/statements/return'; +import type { PyTryStatement as _PyTryStatement } from './nodes/statements/try'; +import type { PyWhileStatement as _PyWhileStatement } from './nodes/statements/while'; +import type { PyWithStatement as _PyWithStatement } from './nodes/statements/with'; +import type { PyWithItem as _PyWithItem } from './nodes/statements/withItem'; +import type { PyComment as _PyComment } from './nodes/structure/comment'; +import type { PySourceFile as _PySourceFile } from './nodes/structure/sourceFile'; +import type { PyPrinterOptions as _PyPrinterOptions } from './printer'; import { createPrinter, printAst } from './printer'; +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace py { + // Base / Core + export type Node = _PyNode; + export type NodeBase = _PyNodeBase; + export type NodeKind = PyNodeKind; + export type Expression = _PyExpression; + export type Statement = _PyStatement; + + // Structure + export type SourceFile = _PySourceFile; + export type Comment = _PyComment; + + // Declarations + export type ClassDeclaration = _PyClassDeclaration; + export type FunctionDeclaration = _PyFunctionDeclaration; + export type FunctionParameter = _PyFunctionParameter; + + // Statements + export type Assignment = _PyAssignment; + export type AugmentedAssignment = _PyAugmentedAssignment; + export type AugmentedOperator = _PyAugmentedOperator; + export type Block = _PyBlock; + export type BreakStatement = _PyBreakStatement; + export type ContinueStatement = _PyContinueStatement; + export type EmptyStatement = _PyEmptyStatement; + export type ExceptClause = _PyExceptClause; + export type ExpressionStatement = _PyExpressionStatement; + export type ForStatement = _PyForStatement; + export type IfStatement = _PyIfStatement; + export type ImportStatement = _PyImportStatement; + export type RaiseStatement = _PyRaiseStatement; + export type ReturnStatement = _PyReturnStatement; + export type TryStatement = _PyTryStatement; + export type WhileStatement = _PyWhileStatement; + export type WithItem = _PyWithItem; + export type WithStatement = _PyWithStatement; + + // Expressions + export type AsyncExpression = _PyAsyncExpression; + export type AwaitExpression = _PyAwaitExpression; + export type BinaryExpression = _PyBinaryExpression; + export type BinaryOperator = _PyBinaryOperator; + export type CallExpression = _PyCallExpression; + export type DictExpression = _PyDictExpression; + export type FStringExpression = _PyFStringExpression; + export type GeneratorExpression = _PyGeneratorExpression; + export type Identifier = _PyIdentifier; + export type LambdaExpression = _PyLambdaExpression; + export type ListExpression = _PyListExpression; + export type Literal = _PyLiteral; + export type MemberExpression = _PyMemberExpression; + export type SetExpression = _PySetExpression; + export type TupleExpression = _PyTupleExpression; + export type YieldExpression = _PyYieldExpression; + export type YieldFromExpression = _PyYieldFromExpression; + + // Comprehensions + export type Comprehension = _PyComprehension; + export type ComprehensionNode = _PyComprehensionNode; + export type DictComprehension = _PyDictComprehension; + export type ListComprehension = _PyListComprehension; + export type SetComprehension = _PySetComprehension; + + // Printer + export type PrinterOptions = _PyPrinterOptions; +} + export const py = { PyNodeKind, createPrinter, diff --git a/packages/openapi-python/src/ts-python/printer.ts b/packages/openapi-python/src/ts-python/printer.ts index ce7f46887..cc5b9420e 100644 --- a/packages/openapi-python/src/ts-python/printer.ts +++ b/packages/openapi-python/src/ts-python/printer.ts @@ -10,7 +10,7 @@ export function createPrinter(options?: PyPrinterOptions) { let indentLevel = 0; - function processComments( + function printComments( parts: Array, lines: ReadonlyArray, indent?: boolean, @@ -20,7 +20,21 @@ export function createPrinter(options?: PyPrinterOptions) { if (indent) indentLevel -= 1; } + function printDocstring(docstring: string): Array { + const lines = docstring.split('\n'); + const parts: Array = []; + if (lines.length === 1) { + parts.push(printLine(`"""${lines[0]}"""`), ''); + } else { + parts.push(printLine(`"""`)); + parts.push(...lines.map((line) => printLine(line))); + parts.push(printLine(`"""`), ''); + } + return parts; + } + function printLine(line: string): string { + if (line === '') return ''; return ' '.repeat(indentLevel * indentSize) + line; } @@ -28,7 +42,7 @@ export function createPrinter(options?: PyPrinterOptions) { const parts: Array = []; if (node.leadingComments) { - processComments(parts, node.leadingComments); + printComments(parts, node.leadingComments); } let indentTrailingComments = false; @@ -85,7 +99,7 @@ export function createPrinter(options?: PyPrinterOptions) { parts.push(printLine(`class ${node.name}${bases}:`)); if (node.docstring) { indentLevel += 1; - parts.push(printLine(`"""${node.docstring}"""`), ''); + parts.push(...printDocstring(node.docstring)); indentLevel -= 1; } parts.push(printNode(node.body)); @@ -165,7 +179,7 @@ export function createPrinter(options?: PyPrinterOptions) { ); if (node.docstring) { indentLevel += 1; - parts.push(printLine(`"""${node.docstring}"""`), ''); + parts.push(...printDocstring(node.docstring)); indentLevel -= 1; } parts.push(printNode(node.body)); @@ -309,7 +323,7 @@ export function createPrinter(options?: PyPrinterOptions) { case PyNodeKind.SourceFile: if (node.docstring) { - parts.push(printLine(`"""${node.docstring}"""`), ''); + parts.push(...printDocstring(node.docstring)); } parts.push(...node.statements.map(printNode)); break; @@ -382,7 +396,7 @@ export function createPrinter(options?: PyPrinterOptions) { } if (node.trailingComments) { - processComments(parts, node.trailingComments, indentTrailingComments); + printComments(parts, node.trailingComments, indentTrailingComments); } return parts.join('\n'); diff --git a/packages/openapi-python/tsdown.config.ts b/packages/openapi-python/tsdown.config.ts index 437933dd3..6cc4797d2 100644 --- a/packages/openapi-python/tsdown.config.ts +++ b/packages/openapi-python/tsdown.config.ts @@ -1,13 +1,42 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { defineConfig } from 'tsdown'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + export default defineConfig({ clean: true, - dts: { - build: true, - }, + dts: true, entry: ['./src/{index,run}.ts'], format: ['esm'], minify: false, + onSuccess: async () => { + // Copy client files to dist folder for runtime access + const pluginNames = ['client-httpx']; + + for (const pluginName of pluginNames) { + const srcPath = path.resolve(__dirname, 'src', 'plugins', '@hey-api', pluginName, 'bundle'); + const destPath = path.resolve( + __dirname, + 'dist', + 'clients', + pluginName.slice('client-'.length), + ); + + if (fs.existsSync(srcPath)) { + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.cpSync(srcPath, destPath, { recursive: true }); + + // replace core imports in client bundle + // const clientFiles = fs.readdirSync(destPath); + // for (const file of clientFiles) { + // replaceCoreImports(path.resolve(destPath, file)); + // } + } + } + }, sourcemap: true, treeshake: true, }); diff --git a/packages/openapi-ts-tests/sdks/test/method-class-conflict.test.ts b/packages/openapi-ts-tests/sdks/test/method-class-conflict.test.ts index a78d6f454..c11aa833b 100644 --- a/packages/openapi-ts-tests/sdks/test/method-class-conflict.test.ts +++ b/packages/openapi-ts-tests/sdks/test/method-class-conflict.test.ts @@ -23,7 +23,7 @@ describe(`SDK: ${namespace}`, () => { config: createConfig({ input: specPath, output: { - indexFile: false, + entryFile: false, path: 'class', }, plugins: [ @@ -40,7 +40,7 @@ describe(`SDK: ${namespace}`, () => { config: createConfig({ input: specPath, output: { - indexFile: false, + entryFile: false, path: 'flat', }, plugins: [ @@ -57,7 +57,7 @@ describe(`SDK: ${namespace}`, () => { config: createConfig({ input: specPath, output: { - indexFile: false, + entryFile: false, path: 'instance', }, plugins: [ diff --git a/packages/openapi-ts/src/config/output/config.ts b/packages/openapi-ts/src/config/output/config.ts index 3667fd135..9e6d86eec 100644 --- a/packages/openapi-ts/src/config/output/config.ts +++ b/packages/openapi-ts/src/config/output/config.ts @@ -28,6 +28,7 @@ export function getOutput(userConfig: { output: MaybeArray const output = valueToObject({ defaultValue: { clean: true, + entryFile: true, fileName: { case: 'preserve', name: '{{name}}', @@ -35,7 +36,6 @@ export function getOutput(userConfig: { output: MaybeArray }, format: null, header: '// This file is auto-generated by @hey-api/openapi-ts', - indexFile: true, lint: null, path: '', postProcess: [], diff --git a/packages/openapi-ts/src/config/utils.ts b/packages/openapi-ts/src/config/utils.ts index 85dee7724..d0acd100c 100644 --- a/packages/openapi-ts/src/config/utils.ts +++ b/packages/openapi-ts/src/config/utils.ts @@ -2,7 +2,9 @@ import type { Context, PluginInstance } from '@hey-api/shared'; import type { Config } from './types'; -export function getTypedConfig(plugin: PluginInstance | Context): Config { +export function getTypedConfig( + plugin: Pick | Pick, +): Config { if ('context' in plugin) { return plugin.context.config as Config; } diff --git a/packages/openapi-ts/src/index.ts b/packages/openapi-ts/src/index.ts index a78079d02..90dca3303 100644 --- a/packages/openapi-ts/src/index.ts +++ b/packages/openapi-ts/src/index.ts @@ -128,6 +128,10 @@ export { createClient } from './generate'; /** * Type helper for configuration object, returns {@link MaybeArray} object(s) */ +export function defineConfig( + config: LazyOrAsync>, +): Promise>; +export function defineConfig(config: LazyOrAsync): Promise; export async function defineConfig>( config: LazyOrAsync, ): Promise { diff --git a/packages/openapi-ts/src/plugins/@angular/common/config.ts b/packages/openapi-ts/src/plugins/@angular/common/config.ts index da8c46ceb..9eba7b791 100644 --- a/packages/openapi-ts/src/plugins/@angular/common/config.ts +++ b/packages/openapi-ts/src/plugins/@angular/common/config.ts @@ -7,7 +7,7 @@ import type { AngularCommonPlugin } from './types'; export const defaultConfig: AngularCommonPlugin['Config'] = { config: { - exportFromIndex: false, + includeInEntry: false, }, dependencies: ['@hey-api/client-angular', '@hey-api/sdk'], handler, diff --git a/packages/openapi-ts/src/plugins/@angular/common/types.ts b/packages/openapi-ts/src/plugins/@angular/common/types.ts index 69383c37b..73c16956e 100644 --- a/packages/openapi-ts/src/plugins/@angular/common/types.ts +++ b/packages/openapi-ts/src/plugins/@angular/common/types.ts @@ -1,18 +1,11 @@ -import type { IndexExportOption } from '@hey-api/shared'; -import type { DefinePlugin, Plugin } from '@hey-api/shared'; -import type { OperationsStrategy } from '@hey-api/shared'; +import type { DefinePlugin, OperationsStrategy, Plugin } from '@hey-api/shared'; import type { HttpRequestsConfig, UserHttpRequestsConfig } from './httpRequests'; import type { HttpResourcesConfig, UserHttpResourcesConfig } from './httpResources'; export type UserConfig = Plugin.Name<'@angular/common'> & - Plugin.Hooks & { - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; + Plugin.Hooks & + Plugin.UserExports & { /** * Options for generating HTTP Request instances. * @@ -29,7 +22,7 @@ export type UserConfig = Plugin.Name<'@angular/common'> & export type Config = Plugin.Name<'@angular/common'> & Plugin.Hooks & - IndexExportOption & { + Plugin.Exports & { /** * Options for generating HTTP Request instances. */ diff --git a/packages/openapi-ts/src/plugins/@faker-js/faker/config.ts b/packages/openapi-ts/src/plugins/@faker-js/faker/config.ts index 61f67e86b..8c7576498 100644 --- a/packages/openapi-ts/src/plugins/@faker-js/faker/config.ts +++ b/packages/openapi-ts/src/plugins/@faker-js/faker/config.ts @@ -8,7 +8,7 @@ export const defaultConfig: FakerJsFakerPlugin['Config'] = { api: new Api(), config: { case: 'camelCase', - exportFromIndex: false, + includeInEntry: false, }, // handler, handler: () => {}, diff --git a/packages/openapi-ts/src/plugins/@faker-js/faker/resolvers/types.ts b/packages/openapi-ts/src/plugins/@faker-js/faker/resolvers/types.ts index fa7708ea1..0c9dca4d9 100644 --- a/packages/openapi-ts/src/plugins/@faker-js/faker/resolvers/types.ts +++ b/packages/openapi-ts/src/plugins/@faker-js/faker/resolvers/types.ts @@ -1,7 +1,7 @@ // TODO: later import type { Symbol } from '@hey-api/codegen-core'; +import type { Plugin, SchemaWithType } from '@hey-api/shared'; -import type { Plugin, SchemaWithType } from '../../../../plugins'; import type { $, DollarTsDsl } from '../../../../ts-dsl'; import type { FakerJsFakerPlugin } from '../types'; diff --git a/packages/openapi-ts/src/plugins/@faker-js/faker/types.ts b/packages/openapi-ts/src/plugins/@faker-js/faker/types.ts index 85297d0b7..7b2feacc6 100644 --- a/packages/openapi-ts/src/plugins/@faker-js/faker/types.ts +++ b/packages/openapi-ts/src/plugins/@faker-js/faker/types.ts @@ -1,16 +1,11 @@ -import type { - Casing, - FeatureToggle, - IndexExportOption, - NameTransformer, - NamingOptions, -} from '@hey-api/shared'; +import type { Casing, FeatureToggle, NameTransformer, NamingOptions } from '@hey-api/shared'; import type { DefinePlugin, Plugin } from '@hey-api/shared'; import type { IApi } from './api'; export type UserConfig = Plugin.Name<'@faker-js/faker'> & - Plugin.Hooks & { + Plugin.Hooks & + Plugin.UserExports & { // Resolvers & { /** * Casing convention for generated names. @@ -49,12 +44,6 @@ export type UserConfig = Plugin.Name<'@faker-js/faker'> & */ name?: NameTransformer; }; - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; /** * Faker locale for generated data. * @@ -70,7 +59,7 @@ export type UserConfig = Plugin.Name<'@faker-js/faker'> & export type Config = Plugin.Name<'@faker-js/faker'> & Plugin.Hooks & - IndexExportOption & { + Plugin.Exports & { // Resolvers & { /** * Casing convention for generated names. diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-core/config.ts b/packages/openapi-ts/src/plugins/@hey-api/client-core/config.ts index 3457041ff..bb19bf070 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/client-core/config.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/client-core/config.ts @@ -1,7 +1,7 @@ export const clientDefaultConfig = { baseUrl: true, bundle: true, - exportFromIndex: false, + includeInEntry: false, } as const; export const clientDefaultMeta = { diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-core/types.ts b/packages/openapi-ts/src/plugins/@hey-api/client-core/types.ts index 7df366237..1248dd696 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/client-core/types.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/client-core/types.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-namespace */ -import type { Plugin } from '../../../plugins'; +import type { Plugin } from '@hey-api/shared'; + import type { HeyApiClientAngularPlugin } from '../../../plugins/@hey-api/client-angular'; import type { HeyApiClientAxiosPlugin } from '../../../plugins/@hey-api/client-axios'; import type { HeyApiClientFetchPlugin } from '../../../plugins/@hey-api/client-fetch'; @@ -20,55 +21,50 @@ export interface PluginHandler { * Public Client API. */ export namespace Client { - export type Config = Plugin.Hooks & { - /** - * Set a default base URL when creating the client? You can set `baseUrl` - * to a string which will be used as the base URL. If your input defines - * server(s), you can set `baseUrl` to a number to pick a specific server - * to use as the base URL. You can disable setting the base URL by setting - * `baseUrl` to `false`. By default, `baseUrl` is `true` and it will try to - * use the first defined server value. If there's none, we won't set a - * base URL. - * - * If the matched URL contains template literals, it will be ignored. - * - * @default true - */ - baseUrl?: string | number | boolean; - /** - * Bundle the client module? When `true`, the client module will be copied - * from the client plugin and bundled with the generated output. - * - * @default true - */ - bundle?: boolean; - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; - /** - * Relative path to the runtime configuration file. This file must export - * a `createClientConfig()` function. The `createClientConfig()` function - * will be called on client initialization and the returned object will - * become the client's initial configuration. - * - * You may want to initialize your client this way instead of calling - * `setConfig()`. This is useful for example if you're using Next.js - * to ensure your client always has the correct values. - */ - runtimeConfigPath?: string; - /** - * Should the type helper for base URL allow only values matching the - * server(s) defined in the input? By default, `strictBaseUrl` is `false` - * which will provide type hints and allow you to pass any string. - * - * Note that setting `strictBaseUrl` to `true` can produce an invalid - * build if you specify `baseUrl` which doesn't conform to the type helper. - * - * @default false - */ - strictBaseUrl?: boolean; - }; + export type Config = Plugin.Hooks & + Plugin.UserExports & { + /** + * Set a default base URL when creating the client? You can set `baseUrl` + * to a string which will be used as the base URL. If your input defines + * server(s), you can set `baseUrl` to a number to pick a specific server + * to use as the base URL. You can disable setting the base URL by setting + * `baseUrl` to `false`. By default, `baseUrl` is `true` and it will try to + * use the first defined server value. If there's none, we won't set a + * base URL. + * + * If the matched URL contains template literals, it will be ignored. + * + * @default true + */ + baseUrl?: string | number | boolean; + /** + * Bundle the client module? When `true`, the client module will be copied + * from the client plugin and bundled with the generated output. + * + * @default true + */ + bundle?: boolean; + /** + * Relative path to the runtime configuration file. This file must export + * a `createClientConfig()` function. The `createClientConfig()` function + * will be called on client initialization and the returned object will + * become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ + runtimeConfigPath?: string; + /** + * Should the type helper for base URL allow only values matching the + * server(s) defined in the input? By default, `strictBaseUrl` is `false` + * which will provide type hints and allow you to pass any string. + * + * Note that setting `strictBaseUrl` to `true` can produce an invalid + * build if you specify `baseUrl` which doesn't conform to the type helper. + * + * @default false + */ + strictBaseUrl?: boolean; + }; } diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-core/utils.ts b/packages/openapi-ts/src/plugins/@hey-api/client-core/utils.ts index f5f451845..ebbc4f84f 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/client-core/utils.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/client-core/utils.ts @@ -1,17 +1,17 @@ import type { Config } from '../../../config/types'; import type { PluginClientNames } from '../../../plugins/types'; -export const getClientBaseUrlKey = (config: Config) => { +export function getClientBaseUrlKey(config: Config) { const client = getClientPlugin(config); if (client.name === '@hey-api/client-axios' || client.name === '@hey-api/client-nuxt') { return 'baseURL'; } return 'baseUrl'; -}; +} -export const getClientPlugin = ( +export function getClientPlugin( config: Config, -): Config['plugins'][PluginClientNames] & { name: PluginClientNames } => { +): Config['plugins'][PluginClientNames] & { name: PluginClientNames } { for (const name of config.pluginOrder) { const plugin = config.plugins[name]; if (plugin?.tags?.includes('client')) { @@ -29,4 +29,4 @@ export const getClientPlugin = ( // @ts-expect-error name: '', }; -}; +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/schemas/config.ts b/packages/openapi-ts/src/plugins/@hey-api/schemas/config.ts index 5cdd14f02..e264a9728 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/schemas/config.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/schemas/config.ts @@ -5,7 +5,7 @@ import type { HeyApiSchemasPlugin } from './types'; export const defaultConfig: HeyApiSchemasPlugin['Config'] = { config: { - exportFromIndex: false, + includeInEntry: false, nameBuilder: (name) => `${name}Schema`, type: 'json', }, diff --git a/packages/openapi-ts/src/plugins/@hey-api/schemas/types.ts b/packages/openapi-ts/src/plugins/@hey-api/schemas/types.ts index d90ed6069..9dd2821f6 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/schemas/types.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/schemas/types.ts @@ -7,13 +7,8 @@ import type { } from '@hey-api/shared'; export type UserConfig = Plugin.Name<'@hey-api/schemas'> & - Plugin.Hooks & { - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; + Plugin.Hooks & + Plugin.UserExports & { /** * Customise the schema name. By default, `{{name}}Schema` is used. `name` is a * valid JavaScript/TypeScript identifier, e.g. if your schema name is diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts index 78c64f1e6..6f9e00250 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/config.ts @@ -9,7 +9,7 @@ export const defaultConfig: HeyApiSdkPlugin['Config'] = { config: { auth: true, client: true, - exportFromIndex: true, + includeInEntry: true, paramsStructure: 'grouped', responseStyle: 'fields', transformer: false, diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/types.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/types.ts index 9cfbb6c5e..e7aee215f 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/types.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/types.ts @@ -1,13 +1,16 @@ -import type { IndexExportOption, NameTransformer } from '@hey-api/shared'; -import type { DefinePlugin, Plugin } from '@hey-api/shared'; -import type { OperationsStrategy } from '@hey-api/shared'; +import type { DefinePlugin, NameTransformer, OperationsStrategy, Plugin } from '@hey-api/shared'; -import type { PluginClientNames, PluginValidatorNames } from '../../../plugins/types'; +import type { + PluginClientNames, + PluginTransformerNames, + PluginValidatorNames, +} from '../../../plugins/types'; import type { ExamplesConfig, UserExamplesConfig } from './examples'; import type { OperationsConfig, UserOperationsConfig } from './operations'; export type UserConfig = Plugin.Name<'@hey-api/sdk'> & - Plugin.Hooks & { + Plugin.Hooks & + Plugin.UserExports & { /** * Should the generated functions contain auth mechanisms? You may want to * disable this option if you're handling auth yourself or defining it @@ -38,12 +41,6 @@ export type UserConfig = Plugin.Name<'@hey-api/sdk'> & * @default false */ examples?: boolean | UserExamplesConfig; - /** - * Whether exports should be re-exported in the index file. - * - * @default true - */ - exportFromIndex?: boolean; /** * Define the structure of generated SDK operations. * @@ -88,7 +85,7 @@ export type UserConfig = Plugin.Name<'@hey-api/sdk'> & * * @default false */ - transformer?: '@hey-api/transformers' | boolean; + transformer?: PluginTransformerNames | boolean; /** * Validate request and/or response data against schema before returning. * This is useful if you want to ensure the request and/or response conforms @@ -199,7 +196,7 @@ export type UserConfig = Plugin.Name<'@hey-api/sdk'> & export type Config = Plugin.Name<'@hey-api/sdk'> & Plugin.Hooks & - IndexExportOption & { + Plugin.Exports & { /** * Should the generated functions contain auth mechanisms? You may want to * disable this option if you're handling auth yourself or defining it @@ -258,7 +255,7 @@ export type Config = Plugin.Name<'@hey-api/sdk'> & * * @default false */ - transformer: '@hey-api/transformers' | false; + transformer: PluginTransformerNames | false; /** * Validate request and/or response data against schema before returning. * This is useful if you want to ensure the request and/or response conforms diff --git a/packages/openapi-ts/src/plugins/@hey-api/transformers/config.ts b/packages/openapi-ts/src/plugins/@hey-api/transformers/config.ts index 02f23758b..90ae4d4fe 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/transformers/config.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/transformers/config.ts @@ -8,7 +8,7 @@ export const defaultConfig: HeyApiTransformersPlugin['Config'] = { config: { bigInt: true, dates: true, - exportFromIndex: false, + includeInEntry: false, transformers: [], typeTransformers: [], }, diff --git a/packages/openapi-ts/src/plugins/@hey-api/transformers/types.ts b/packages/openapi-ts/src/plugins/@hey-api/transformers/types.ts index a5dc12afc..3217cedd8 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/transformers/types.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/transformers/types.ts @@ -1,4 +1,3 @@ -import type { IndexExportOption } from '@hey-api/shared'; import type { IR } from '@hey-api/shared'; import type { DefinePlugin, Plugin } from '@hey-api/shared'; import type ts from 'typescript'; @@ -12,7 +11,8 @@ import type { ExpressionTransformer } from './expressions'; export type TypeTransformer = ({ schema }: { schema: IR.SchemaObject }) => ts.TypeNode | undefined; export type UserConfig = Plugin.Name<'@hey-api/transformers'> & - Plugin.Hooks & { + Plugin.Hooks & + Plugin.UserExports & { /** * Convert long integers into BigInt values? * @@ -25,12 +25,6 @@ export type UserConfig = Plugin.Name<'@hey-api/transformers'> & * @default true */ dates?: boolean; - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; /** * Custom transforms to apply to the generated code. */ @@ -43,7 +37,7 @@ export type UserConfig = Plugin.Name<'@hey-api/transformers'> & export type Config = Plugin.Name<'@hey-api/transformers'> & Plugin.Hooks & - IndexExportOption & { + Plugin.Exports & { /** * Convert long integers into BigInt values? * diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/config.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/config.ts index 601660134..b4f2473e6 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/config.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/config.ts @@ -8,7 +8,7 @@ export const defaultConfig: HeyApiTypeScriptPlugin['Config'] = { api: new Api(), config: { case: 'PascalCase', - exportFromIndex: true, + includeInEntry: true, topType: 'unknown', }, handler, diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/types.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/types.ts index 82ae19eae..ab0c9effa 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/types.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/types.ts @@ -1,10 +1,4 @@ -import type { - Casing, - FeatureToggle, - IndexExportOption, - NameTransformer, - NamingOptions, -} from '@hey-api/shared'; +import type { Casing, FeatureToggle, NameTransformer, NamingOptions } from '@hey-api/shared'; import type { DefinePlugin, Plugin } from '@hey-api/shared'; import type { IApi } from './api'; @@ -12,7 +6,8 @@ import type { IApi } from './api'; export type EnumsType = 'javascript' | 'typescript' | 'typescript-const'; export type UserConfig = Plugin.Name<'@hey-api/typescript'> & - Plugin.Hooks & { + Plugin.Hooks & + Plugin.UserExports & { /** * Casing convention for generated names. * @@ -127,12 +122,6 @@ export type UserConfig = Plugin.Name<'@hey-api/typescript'> & */ name?: NameTransformer; }; - /** - * Whether exports should be re-exported in the index file. - * - * @default true - */ - exportFromIndex?: boolean; /** * Configuration for request-specific types. * @@ -241,7 +230,7 @@ export type UserConfig = Plugin.Name<'@hey-api/typescript'> & export type Config = Plugin.Name<'@hey-api/typescript'> & Plugin.Hooks & - IndexExportOption & { + Plugin.Exports & { /** * Casing convention for generated names. */ diff --git a/packages/openapi-ts/src/plugins/@pinia/colada/config.ts b/packages/openapi-ts/src/plugins/@pinia/colada/config.ts index 6ce684021..0b527f7a7 100644 --- a/packages/openapi-ts/src/plugins/@pinia/colada/config.ts +++ b/packages/openapi-ts/src/plugins/@pinia/colada/config.ts @@ -7,7 +7,7 @@ export const defaultConfig: PiniaColadaPlugin['Config'] = { config: { case: 'camelCase', comments: true, - exportFromIndex: false, + includeInEntry: false, }, dependencies: ['@hey-api/typescript', '@hey-api/sdk'], handler: handler as PiniaColadaPlugin['Handler'], diff --git a/packages/openapi-ts/src/plugins/@pinia/colada/types.ts b/packages/openapi-ts/src/plugins/@pinia/colada/types.ts index 1c3adebf2..27df269d3 100644 --- a/packages/openapi-ts/src/plugins/@pinia/colada/types.ts +++ b/packages/openapi-ts/src/plugins/@pinia/colada/types.ts @@ -1,15 +1,10 @@ -import type { - Casing, - FeatureToggle, - IndexExportOption, - NameTransformer, - NamingOptions, -} from '@hey-api/shared'; +import type { Casing, FeatureToggle, NameTransformer, NamingOptions } from '@hey-api/shared'; import type { IR } from '@hey-api/shared'; import type { DefinePlugin, Plugin } from '@hey-api/shared'; export type UserConfig = Plugin.Name<'@pinia/colada'> & - Plugin.Hooks & { + Plugin.Hooks & + Plugin.UserExports & { /** * Casing convention for generated names. * @@ -27,12 +22,6 @@ export type UserConfig = Plugin.Name<'@pinia/colada'> & * @default true */ comments?: boolean; - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; /** * Configuration for generated mutation options helpers. * @@ -195,7 +184,7 @@ export type UserConfig = Plugin.Name<'@pinia/colada'> & export type Config = Plugin.Name<'@pinia/colada'> & Plugin.Hooks & - IndexExportOption & { + Plugin.Exports & { /** * Casing convention for generated names. */ diff --git a/packages/openapi-ts/src/plugins/@tanstack/angular-query-experimental/config.ts b/packages/openapi-ts/src/plugins/@tanstack/angular-query-experimental/config.ts index ab7f78c62..60b9efb71 100644 --- a/packages/openapi-ts/src/plugins/@tanstack/angular-query-experimental/config.ts +++ b/packages/openapi-ts/src/plugins/@tanstack/angular-query-experimental/config.ts @@ -7,7 +7,7 @@ export const defaultConfig: TanStackAngularQueryPlugin['Config'] = { config: { case: 'camelCase', comments: true, - exportFromIndex: false, + includeInEntry: false, }, dependencies: ['@hey-api/sdk', '@hey-api/typescript'], handler: handler as TanStackAngularQueryPlugin['Handler'], diff --git a/packages/openapi-ts/src/plugins/@tanstack/angular-query-experimental/types.ts b/packages/openapi-ts/src/plugins/@tanstack/angular-query-experimental/types.ts index c77c6ea5e..f529c20d0 100644 --- a/packages/openapi-ts/src/plugins/@tanstack/angular-query-experimental/types.ts +++ b/packages/openapi-ts/src/plugins/@tanstack/angular-query-experimental/types.ts @@ -1,15 +1,10 @@ -import type { - Casing, - FeatureToggle, - IndexExportOption, - NameTransformer, - NamingOptions, -} from '@hey-api/shared'; +import type { Casing, FeatureToggle, NameTransformer, NamingOptions } from '@hey-api/shared'; import type { IR } from '@hey-api/shared'; import type { DefinePlugin, Plugin } from '@hey-api/shared'; export type UserConfig = Plugin.Name<'@tanstack/angular-query-experimental'> & - Plugin.Hooks & { + Plugin.Hooks & + Plugin.UserExports & { /** * Casing convention for generated names. * @@ -27,12 +22,6 @@ export type UserConfig = Plugin.Name<'@tanstack/angular-query-experimental'> & * @default true */ comments?: boolean; - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; /** * Configuration for generated infinite query key helpers. * @@ -303,7 +292,7 @@ export type UserConfig = Plugin.Name<'@tanstack/angular-query-experimental'> & export type Config = Plugin.Name<'@tanstack/angular-query-experimental'> & Plugin.Hooks & - IndexExportOption & { + Plugin.Exports & { /** * Casing convention for generated names. */ diff --git a/packages/openapi-ts/src/plugins/@tanstack/react-query/config.ts b/packages/openapi-ts/src/plugins/@tanstack/react-query/config.ts index 0b81a8dff..08bfc3fb5 100644 --- a/packages/openapi-ts/src/plugins/@tanstack/react-query/config.ts +++ b/packages/openapi-ts/src/plugins/@tanstack/react-query/config.ts @@ -7,7 +7,7 @@ export const defaultConfig: TanStackReactQueryPlugin['Config'] = { config: { case: 'camelCase', comments: true, - exportFromIndex: false, + includeInEntry: false, }, dependencies: ['@hey-api/sdk', '@hey-api/typescript'], handler: handler as TanStackReactQueryPlugin['Handler'], diff --git a/packages/openapi-ts/src/plugins/@tanstack/react-query/types.ts b/packages/openapi-ts/src/plugins/@tanstack/react-query/types.ts index 587c2b7c7..fe254908e 100644 --- a/packages/openapi-ts/src/plugins/@tanstack/react-query/types.ts +++ b/packages/openapi-ts/src/plugins/@tanstack/react-query/types.ts @@ -1,15 +1,10 @@ -import type { - Casing, - FeatureToggle, - IndexExportOption, - NameTransformer, - NamingOptions, -} from '@hey-api/shared'; +import type { Casing, FeatureToggle, NameTransformer, NamingOptions } from '@hey-api/shared'; import type { IR } from '@hey-api/shared'; import type { DefinePlugin, Plugin } from '@hey-api/shared'; export type UserConfig = Plugin.Name<'@tanstack/react-query'> & - Plugin.Hooks & { + Plugin.Hooks & + Plugin.UserExports & { /** * Casing convention for generated names. * @@ -27,12 +22,6 @@ export type UserConfig = Plugin.Name<'@tanstack/react-query'> & * @default true */ comments?: boolean; - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; /** * Configuration for generated infinite query key helpers. * @@ -348,7 +337,7 @@ export type UserConfig = Plugin.Name<'@tanstack/react-query'> & export type Config = Plugin.Name<'@tanstack/react-query'> & Plugin.Hooks & - IndexExportOption & { + Plugin.Exports & { /** * Casing convention for generated names. */ diff --git a/packages/openapi-ts/src/plugins/@tanstack/solid-query/config.ts b/packages/openapi-ts/src/plugins/@tanstack/solid-query/config.ts index d4351ee12..c68e89ebb 100644 --- a/packages/openapi-ts/src/plugins/@tanstack/solid-query/config.ts +++ b/packages/openapi-ts/src/plugins/@tanstack/solid-query/config.ts @@ -7,7 +7,7 @@ export const defaultConfig: TanStackSolidQueryPlugin['Config'] = { config: { case: 'camelCase', comments: true, - exportFromIndex: false, + includeInEntry: false, }, dependencies: ['@hey-api/sdk', '@hey-api/typescript'], handler: handler as TanStackSolidQueryPlugin['Handler'], diff --git a/packages/openapi-ts/src/plugins/@tanstack/solid-query/types.ts b/packages/openapi-ts/src/plugins/@tanstack/solid-query/types.ts index 0da86ca7b..29e15d40b 100644 --- a/packages/openapi-ts/src/plugins/@tanstack/solid-query/types.ts +++ b/packages/openapi-ts/src/plugins/@tanstack/solid-query/types.ts @@ -1,15 +1,16 @@ import type { Casing, + DefinePlugin, FeatureToggle, - IndexExportOption, + IR, NameTransformer, NamingOptions, + Plugin, } from '@hey-api/shared'; -import type { IR } from '@hey-api/shared'; -import type { DefinePlugin, Plugin } from '@hey-api/shared'; export type UserConfig = Plugin.Name<'@tanstack/solid-query'> & - Plugin.Hooks & { + Plugin.Hooks & + Plugin.UserExports & { /** * Casing convention for generated names. * @@ -27,12 +28,6 @@ export type UserConfig = Plugin.Name<'@tanstack/solid-query'> & * @default true */ comments?: boolean; - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; /** * Configuration for generated infinite query key helpers. * @@ -304,7 +299,7 @@ export type UserConfig = Plugin.Name<'@tanstack/solid-query'> & export type Config = Plugin.Name<'@tanstack/solid-query'> & Plugin.Hooks & - IndexExportOption & { + Plugin.Exports & { /** * Casing convention for generated names. */ diff --git a/packages/openapi-ts/src/plugins/@tanstack/svelte-query/config.ts b/packages/openapi-ts/src/plugins/@tanstack/svelte-query/config.ts index 40ae7dfe1..168403f68 100644 --- a/packages/openapi-ts/src/plugins/@tanstack/svelte-query/config.ts +++ b/packages/openapi-ts/src/plugins/@tanstack/svelte-query/config.ts @@ -7,7 +7,7 @@ export const defaultConfig: TanStackSvelteQueryPlugin['Config'] = { config: { case: 'camelCase', comments: true, - exportFromIndex: false, + includeInEntry: false, }, dependencies: ['@hey-api/sdk', '@hey-api/typescript'], handler: handler as TanStackSvelteQueryPlugin['Handler'], diff --git a/packages/openapi-ts/src/plugins/@tanstack/svelte-query/types.ts b/packages/openapi-ts/src/plugins/@tanstack/svelte-query/types.ts index 9a9a0f30d..1268b2eb7 100644 --- a/packages/openapi-ts/src/plugins/@tanstack/svelte-query/types.ts +++ b/packages/openapi-ts/src/plugins/@tanstack/svelte-query/types.ts @@ -1,15 +1,16 @@ import type { Casing, + DefinePlugin, FeatureToggle, - IndexExportOption, + IR, NameTransformer, NamingOptions, + Plugin, } from '@hey-api/shared'; -import type { IR } from '@hey-api/shared'; -import type { DefinePlugin, Plugin } from '@hey-api/shared'; export type UserConfig = Plugin.Name<'@tanstack/svelte-query'> & - Plugin.Hooks & { + Plugin.Hooks & + Plugin.UserExports & { /** * Casing convention for generated names. * @@ -27,12 +28,6 @@ export type UserConfig = Plugin.Name<'@tanstack/svelte-query'> & * @default true */ comments?: boolean; - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; /** * Configuration for generated infinite query key helpers. * @@ -303,7 +298,7 @@ export type UserConfig = Plugin.Name<'@tanstack/svelte-query'> & export type Config = Plugin.Name<'@tanstack/svelte-query'> & Plugin.Hooks & - IndexExportOption & { + Plugin.Exports & { /** * Casing convention for generated names. */ diff --git a/packages/openapi-ts/src/plugins/@tanstack/vue-query/config.ts b/packages/openapi-ts/src/plugins/@tanstack/vue-query/config.ts index 2fc3c64ad..ff0b4d23f 100644 --- a/packages/openapi-ts/src/plugins/@tanstack/vue-query/config.ts +++ b/packages/openapi-ts/src/plugins/@tanstack/vue-query/config.ts @@ -7,7 +7,7 @@ export const defaultConfig: TanStackVueQueryPlugin['Config'] = { config: { case: 'camelCase', comments: true, - exportFromIndex: false, + includeInEntry: false, }, dependencies: ['@hey-api/sdk', '@hey-api/typescript'], handler: handler as TanStackVueQueryPlugin['Handler'], diff --git a/packages/openapi-ts/src/plugins/@tanstack/vue-query/types.ts b/packages/openapi-ts/src/plugins/@tanstack/vue-query/types.ts index daba09314..8a3871dbd 100644 --- a/packages/openapi-ts/src/plugins/@tanstack/vue-query/types.ts +++ b/packages/openapi-ts/src/plugins/@tanstack/vue-query/types.ts @@ -1,15 +1,16 @@ import type { Casing, + DefinePlugin, FeatureToggle, - IndexExportOption, + IR, NameTransformer, NamingOptions, + Plugin, } from '@hey-api/shared'; -import type { IR } from '@hey-api/shared'; -import type { DefinePlugin, Plugin } from '@hey-api/shared'; export type UserConfig = Plugin.Name<'@tanstack/vue-query'> & - Plugin.Hooks & { + Plugin.Hooks & + Plugin.UserExports & { /** * Casing convention for generated names. * @@ -27,12 +28,6 @@ export type UserConfig = Plugin.Name<'@tanstack/vue-query'> & * @default true */ comments?: boolean; - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; /** * Configuration for generated infinite query key helpers. * @@ -306,7 +301,7 @@ export type UserConfig = Plugin.Name<'@tanstack/vue-query'> & export type Config = Plugin.Name<'@tanstack/vue-query'> & Plugin.Hooks & - IndexExportOption & { + Plugin.Exports & { /** * Casing convention for generated names. */ diff --git a/packages/openapi-ts/src/plugins/arktype/config.ts b/packages/openapi-ts/src/plugins/arktype/config.ts index 26e0e9cd6..c4064362e 100644 --- a/packages/openapi-ts/src/plugins/arktype/config.ts +++ b/packages/openapi-ts/src/plugins/arktype/config.ts @@ -9,7 +9,7 @@ export const defaultConfig: ArktypePlugin['Config'] = { config: { case: 'PascalCase', comments: true, - exportFromIndex: false, + includeInEntry: false, metadata: false, }, handler, diff --git a/packages/openapi-ts/src/plugins/arktype/types.ts b/packages/openapi-ts/src/plugins/arktype/types.ts index 3600efa97..f0cfcfcc8 100644 --- a/packages/openapi-ts/src/plugins/arktype/types.ts +++ b/packages/openapi-ts/src/plugins/arktype/types.ts @@ -1,16 +1,17 @@ import type { Casing, + DefinePlugin, FeatureToggle, - IndexExportOption, NameTransformer, NamingOptions, + Plugin, } from '@hey-api/shared'; -import type { DefinePlugin, Plugin } from '@hey-api/shared'; import type { IApi } from './api'; export type UserConfig = Plugin.Name<'arktype'> & - Plugin.Hooks & { + Plugin.Hooks & + Plugin.UserExports & { /** * Casing convention for generated names. * @@ -97,12 +98,6 @@ export type UserConfig = Plugin.Name<'arktype'> & }; }; }; - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; /** * Enable Arktype metadata support? It's often useful to associate a schema * with some additional metadata for documentation, code generation, AI @@ -376,7 +371,7 @@ export type UserConfig = Plugin.Name<'arktype'> & export type Config = Plugin.Name<'arktype'> & Plugin.Hooks & - IndexExportOption & { + Plugin.Exports & { /** * Casing convention for generated names. */ diff --git a/packages/openapi-ts/src/plugins/arktype/v2/plugin.ts b/packages/openapi-ts/src/plugins/arktype/v2/plugin.ts index 4e55eb71d..5b8361216 100644 --- a/packages/openapi-ts/src/plugins/arktype/v2/plugin.ts +++ b/packages/openapi-ts/src/plugins/arktype/v2/plugin.ts @@ -1,11 +1,8 @@ import type { SymbolMeta } from '@hey-api/codegen-core'; import { fromRef, refs } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; -import { applyNaming } from '@hey-api/shared'; -import { deduplicateSchema } from '@hey-api/shared'; -import { pathToJsonPointer, refToName } from '@hey-api/shared'; +import type { IR, SchemaWithType } from '@hey-api/shared'; +import { applyNaming, deduplicateSchema, pathToJsonPointer, refToName } from '@hey-api/shared'; -import type { SchemaWithType } from '../../../plugins'; import { $ } from '../../../ts-dsl'; import { exportAst } from '../shared/export'; import type { Ast, IrSchemaToAstOptions, PluginState } from '../shared/types'; diff --git a/packages/openapi-ts/src/plugins/arktype/v2/toAst/index.ts b/packages/openapi-ts/src/plugins/arktype/v2/toAst/index.ts index 498e99454..06b235a92 100644 --- a/packages/openapi-ts/src/plugins/arktype/v2/toAst/index.ts +++ b/packages/openapi-ts/src/plugins/arktype/v2/toAst/index.ts @@ -1,4 +1,5 @@ -import type { SchemaWithType } from '../../../../plugins'; +import type { SchemaWithType } from '@hey-api/shared'; + import { $ } from '../../../../ts-dsl'; import type { Ast, IrSchemaToAstOptions } from '../../shared/types'; import { nullToAst } from './null'; diff --git a/packages/openapi-ts/src/plugins/arktype/v2/toAst/null.ts b/packages/openapi-ts/src/plugins/arktype/v2/toAst/null.ts index 9c16bcedd..f007e1c92 100644 --- a/packages/openapi-ts/src/plugins/arktype/v2/toAst/null.ts +++ b/packages/openapi-ts/src/plugins/arktype/v2/toAst/null.ts @@ -1,4 +1,5 @@ -import type { SchemaWithType } from '../../../../plugins'; +import type { SchemaWithType } from '@hey-api/shared'; + import { identifiers } from '../../constants'; import type { Ast, IrSchemaToAstOptions } from '../../shared/types'; diff --git a/packages/openapi-ts/src/plugins/arktype/v2/toAst/object.ts b/packages/openapi-ts/src/plugins/arktype/v2/toAst/object.ts index 60fc3b234..817937c1f 100644 --- a/packages/openapi-ts/src/plugins/arktype/v2/toAst/object.ts +++ b/packages/openapi-ts/src/plugins/arktype/v2/toAst/object.ts @@ -1,6 +1,6 @@ import { fromRef, ref } from '@hey-api/codegen-core'; +import type { SchemaWithType } from '@hey-api/shared'; -import type { SchemaWithType } from '../../../../plugins'; import { $ } from '../../../../ts-dsl'; // import { identifiers } from '../../constants'; import type { Ast, IrSchemaToAstOptions } from '../../shared/types'; diff --git a/packages/openapi-ts/src/plugins/arktype/v2/toAst/string.ts b/packages/openapi-ts/src/plugins/arktype/v2/toAst/string.ts index 4ee55821c..247a5ca2a 100644 --- a/packages/openapi-ts/src/plugins/arktype/v2/toAst/string.ts +++ b/packages/openapi-ts/src/plugins/arktype/v2/toAst/string.ts @@ -1,4 +1,5 @@ -import type { SchemaWithType } from '../../../../plugins'; +import type { SchemaWithType } from '@hey-api/shared'; + import { identifiers } from '../../constants'; import type { Ast, IrSchemaToAstOptions } from '../../shared/types'; diff --git a/packages/openapi-ts/src/plugins/fastify/config.ts b/packages/openapi-ts/src/plugins/fastify/config.ts index e4090ff85..ee2474835 100644 --- a/packages/openapi-ts/src/plugins/fastify/config.ts +++ b/packages/openapi-ts/src/plugins/fastify/config.ts @@ -5,7 +5,7 @@ import type { FastifyPlugin } from './types'; export const defaultConfig: FastifyPlugin['Config'] = { config: { - exportFromIndex: false, + includeInEntry: false, }, dependencies: ['@hey-api/typescript'], handler, diff --git a/packages/openapi-ts/src/plugins/fastify/types.ts b/packages/openapi-ts/src/plugins/fastify/types.ts index 435d1776d..f5cd29555 100644 --- a/packages/openapi-ts/src/plugins/fastify/types.ts +++ b/packages/openapi-ts/src/plugins/fastify/types.ts @@ -1,13 +1,5 @@ import type { DefinePlugin, Plugin } from '@hey-api/shared'; -export type UserConfig = Plugin.Name<'fastify'> & - Plugin.Hooks & { - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; - }; +export type UserConfig = Plugin.Name<'fastify'> & Plugin.Hooks & Plugin.UserExports; export type FastifyPlugin = DefinePlugin; diff --git a/packages/openapi-ts/src/plugins/index.ts b/packages/openapi-ts/src/plugins/index.ts deleted file mode 100644 index 716edc928..000000000 --- a/packages/openapi-ts/src/plugins/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { DefinePlugin, Plugin, SchemaWithType } from '@hey-api/shared'; diff --git a/packages/openapi-ts/src/plugins/swr/config.ts b/packages/openapi-ts/src/plugins/swr/config.ts index fc72ea4bf..8ef25e9f5 100644 --- a/packages/openapi-ts/src/plugins/swr/config.ts +++ b/packages/openapi-ts/src/plugins/swr/config.ts @@ -7,7 +7,7 @@ export const defaultConfig: SwrPlugin['Config'] = { config: { case: 'camelCase', comments: true, - exportFromIndex: false, + includeInEntry: false, }, dependencies: ['@hey-api/sdk', '@hey-api/typescript'], handler: handler as SwrPlugin['Handler'], diff --git a/packages/openapi-ts/src/plugins/swr/types.ts b/packages/openapi-ts/src/plugins/swr/types.ts index 47cd72f78..f47bdb1a1 100644 --- a/packages/openapi-ts/src/plugins/swr/types.ts +++ b/packages/openapi-ts/src/plugins/swr/types.ts @@ -1,15 +1,16 @@ import type { Casing, + DefinePlugin, FeatureToggle, - IndexExportOption, + IR, NameTransformer, NamingOptions, + Plugin, } from '@hey-api/shared'; -import type { IR } from '@hey-api/shared'; -import type { DefinePlugin, Plugin } from '@hey-api/shared'; export type UserConfig = Plugin.Name<'swr'> & - Plugin.Hooks & { + Plugin.Hooks & + Plugin.UserExports & { /** * Casing convention for generated names. * @@ -27,12 +28,6 @@ export type UserConfig = Plugin.Name<'swr'> & * @default true */ comments?: boolean; - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; /** * Configuration for generated infinite query key helpers. * @@ -348,7 +343,7 @@ export type UserConfig = Plugin.Name<'swr'> & export type Config = Plugin.Name<'swr'> & Plugin.Hooks & - IndexExportOption & { + Plugin.Exports & { /** * Casing convention for generated names. */ diff --git a/packages/openapi-ts/src/plugins/types.ts b/packages/openapi-ts/src/plugins/types.ts index b56b4786d..4da1edded 100644 --- a/packages/openapi-ts/src/plugins/types.ts +++ b/packages/openapi-ts/src/plugins/types.ts @@ -9,4 +9,6 @@ export type PluginClientNames = export type PluginMockNames = '@faker-js/faker'; +export type PluginTransformerNames = '@hey-api/transformers'; + export type PluginValidatorNames = 'arktype' | 'valibot' | 'zod'; diff --git a/packages/openapi-ts/src/plugins/valibot/config.ts b/packages/openapi-ts/src/plugins/valibot/config.ts index 3a82967db..31d5406f5 100644 --- a/packages/openapi-ts/src/plugins/valibot/config.ts +++ b/packages/openapi-ts/src/plugins/valibot/config.ts @@ -9,7 +9,7 @@ export const defaultConfig: ValibotPlugin['Config'] = { config: { case: 'camelCase', comments: true, - exportFromIndex: false, + includeInEntry: false, metadata: false, }, handler, diff --git a/packages/openapi-ts/src/plugins/valibot/resolvers/types.ts b/packages/openapi-ts/src/plugins/valibot/resolvers/types.ts index b561e59a3..dd3f1f9c8 100644 --- a/packages/openapi-ts/src/plugins/valibot/resolvers/types.ts +++ b/packages/openapi-ts/src/plugins/valibot/resolvers/types.ts @@ -1,7 +1,6 @@ import type { Refs, Symbol } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; +import type { IR, Plugin, SchemaWithType } from '@hey-api/shared'; -import type { Plugin, SchemaWithType } from '../../../plugins'; import type { MaybeBigInt, ShouldCoerceToBigInt } from '../../../plugins/shared/utils/coerce'; import type { GetIntegerLimit } from '../../../plugins/shared/utils/formats'; import type { $, DollarTsDsl } from '../../../ts-dsl'; diff --git a/packages/openapi-ts/src/plugins/valibot/shared/operation.ts b/packages/openapi-ts/src/plugins/valibot/shared/operation.ts index bf6418e27..7050be938 100644 --- a/packages/openapi-ts/src/plugins/valibot/shared/operation.ts +++ b/packages/openapi-ts/src/plugins/valibot/shared/operation.ts @@ -1,7 +1,6 @@ import { fromRef } from '@hey-api/codegen-core'; import type { IR } from '@hey-api/shared'; -import { applyNaming } from '@hey-api/shared'; -import { operationResponsesMap } from '@hey-api/shared'; +import { applyNaming, operationResponsesMap } from '@hey-api/shared'; import { exportAst } from './export'; import type { Ast, IrSchemaToAstOptions } from './types'; diff --git a/packages/openapi-ts/src/plugins/valibot/types.ts b/packages/openapi-ts/src/plugins/valibot/types.ts index 9cf2d6544..a2308159a 100644 --- a/packages/openapi-ts/src/plugins/valibot/types.ts +++ b/packages/openapi-ts/src/plugins/valibot/types.ts @@ -1,17 +1,18 @@ import type { Casing, + DefinePlugin, FeatureToggle, - IndexExportOption, NameTransformer, NamingOptions, + Plugin, } from '@hey-api/shared'; -import type { DefinePlugin, Plugin } from '@hey-api/shared'; import type { IApi } from './api'; import type { Resolvers } from './resolvers'; export type UserConfig = Plugin.Name<'valibot'> & Plugin.Hooks & + Plugin.UserExports & Resolvers & { /** * Casing convention for generated names. @@ -59,12 +60,6 @@ export type UserConfig = Plugin.Name<'valibot'> & */ name?: NameTransformer; }; - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; /** * Enable Valibot metadata support? It's often useful to associate a schema * with some additional metadata for documentation, code generation, AI @@ -180,8 +175,8 @@ export type UserConfig = Plugin.Name<'valibot'> & export type Config = Plugin.Name<'valibot'> & Plugin.Hooks & - Resolvers & - IndexExportOption & { + Plugin.Exports & + Resolvers & { /** * Casing convention for generated names. */ diff --git a/packages/openapi-ts/src/plugins/zod/config.ts b/packages/openapi-ts/src/plugins/zod/config.ts index 6fc57bc03..9968e3957 100644 --- a/packages/openapi-ts/src/plugins/zod/config.ts +++ b/packages/openapi-ts/src/plugins/zod/config.ts @@ -12,7 +12,7 @@ export const defaultConfig: ZodPlugin['Config'] = { config: { case: 'camelCase', comments: true, - exportFromIndex: false, + includeInEntry: false, metadata: false, }, handler, diff --git a/packages/openapi-ts/src/plugins/zod/mini/plugin.ts b/packages/openapi-ts/src/plugins/zod/mini/plugin.ts index f4092b372..4d383a983 100644 --- a/packages/openapi-ts/src/plugins/zod/mini/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/mini/plugin.ts @@ -1,10 +1,7 @@ import type { SymbolMeta } from '@hey-api/codegen-core'; import { fromRef, ref, refs } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; -import type { SchemaWithType } from '@hey-api/shared'; -import { applyNaming } from '@hey-api/shared'; -import { deduplicateSchema } from '@hey-api/shared'; -import { pathToJsonPointer, refToName } from '@hey-api/shared'; +import type { IR, SchemaWithType } from '@hey-api/shared'; +import { applyNaming, deduplicateSchema, pathToJsonPointer, refToName } from '@hey-api/shared'; import { maybeBigInt } from '../../../plugins/shared/utils/coerce'; import { $ } from '../../../ts-dsl'; diff --git a/packages/openapi-ts/src/plugins/zod/resolvers/types.ts b/packages/openapi-ts/src/plugins/zod/resolvers/types.ts index ace10f9c5..6de7ffaa5 100644 --- a/packages/openapi-ts/src/plugins/zod/resolvers/types.ts +++ b/packages/openapi-ts/src/plugins/zod/resolvers/types.ts @@ -1,9 +1,8 @@ import type { Refs, Symbol } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; +import type { IR, Plugin, SchemaWithType } from '@hey-api/shared'; import type { MaybeArray } from '@hey-api/types'; import type ts from 'typescript'; -import type { Plugin, SchemaWithType } from '../../../plugins'; import type { MaybeBigInt, ShouldCoerceToBigInt } from '../../../plugins/shared/utils/coerce'; import type { GetIntegerLimit } from '../../../plugins/shared/utils/formats'; import type { $, DollarTsDsl, TsDsl } from '../../../ts-dsl'; diff --git a/packages/openapi-ts/src/plugins/zod/shared/operation.ts b/packages/openapi-ts/src/plugins/zod/shared/operation.ts index 30a2a3072..b76bd2810 100644 --- a/packages/openapi-ts/src/plugins/zod/shared/operation.ts +++ b/packages/openapi-ts/src/plugins/zod/shared/operation.ts @@ -1,7 +1,6 @@ import { fromRef } from '@hey-api/codegen-core'; import type { IR } from '@hey-api/shared'; -import { applyNaming } from '@hey-api/shared'; -import { operationResponsesMap } from '@hey-api/shared'; +import { applyNaming, operationResponsesMap } from '@hey-api/shared'; import { exportAst } from './export'; import type { Ast, IrSchemaToAstOptions } from './types'; diff --git a/packages/openapi-ts/src/plugins/zod/types.ts b/packages/openapi-ts/src/plugins/zod/types.ts index 83ec13663..ede551bf8 100644 --- a/packages/openapi-ts/src/plugins/zod/types.ts +++ b/packages/openapi-ts/src/plugins/zod/types.ts @@ -1,17 +1,18 @@ import type { Casing, + DefinePlugin, FeatureToggle, - IndexExportOption, NameTransformer, NamingOptions, + Plugin, } from '@hey-api/shared'; -import type { DefinePlugin, Plugin } from '@hey-api/shared'; import type { IApi } from './api'; import type { Resolvers } from './resolvers'; export type UserConfig = Plugin.Name<'zod'> & Plugin.Hooks & + Plugin.UserExports & Resolvers & { /** * Casing convention for generated names. @@ -138,12 +139,6 @@ export type UserConfig = Plugin.Name<'zod'> & }; }; }; - /** - * Whether exports should be re-exported in the index file. - * - * @default false - */ - exportFromIndex?: boolean; /** * Enable Zod metadata support? It's often useful to associate a schema with * some additional metadata for documentation, code generation, AI @@ -417,8 +412,8 @@ export type UserConfig = Plugin.Name<'zod'> & export type Config = Plugin.Name<'zod'> & Plugin.Hooks & - Resolvers & - IndexExportOption & { + Plugin.Exports & + Resolvers & { /** * Casing convention for generated names. */ diff --git a/packages/openapi-ts/src/plugins/zod/v3/plugin.ts b/packages/openapi-ts/src/plugins/zod/v3/plugin.ts index dc272312d..55bdcd4fb 100644 --- a/packages/openapi-ts/src/plugins/zod/v3/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/v3/plugin.ts @@ -1,10 +1,7 @@ import type { SymbolMeta } from '@hey-api/codegen-core'; import { fromRef, ref, refs } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; -import type { SchemaWithType } from '@hey-api/shared'; -import { applyNaming } from '@hey-api/shared'; -import { deduplicateSchema } from '@hey-api/shared'; -import { pathToJsonPointer, refToName } from '@hey-api/shared'; +import type { IR, SchemaWithType } from '@hey-api/shared'; +import { applyNaming, deduplicateSchema, pathToJsonPointer, refToName } from '@hey-api/shared'; import { maybeBigInt } from '../../../plugins/shared/utils/coerce'; import { $ } from '../../../ts-dsl'; diff --git a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts index 40df25da0..6c15ed1fc 100644 --- a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts @@ -1,10 +1,7 @@ import type { SymbolMeta } from '@hey-api/codegen-core'; import { fromRef, ref, refs } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; -import type { SchemaWithType } from '@hey-api/shared'; -import { applyNaming } from '@hey-api/shared'; -import { deduplicateSchema } from '@hey-api/shared'; -import { pathToJsonPointer, refToName } from '@hey-api/shared'; +import type { IR, SchemaWithType } from '@hey-api/shared'; +import { applyNaming, deduplicateSchema, pathToJsonPointer, refToName } from '@hey-api/shared'; import { maybeBigInt } from '../../../plugins/shared/utils/coerce'; import { $ } from '../../../ts-dsl'; diff --git a/packages/openapi-ts/src/ts-dsl/decl/func.ts b/packages/openapi-ts/src/ts-dsl/decl/func.ts index 02323c12e..cfb03bfb8 100644 --- a/packages/openapi-ts/src/ts-dsl/decl/func.ts +++ b/packages/openapi-ts/src/ts-dsl/decl/func.ts @@ -83,6 +83,11 @@ class ImplFuncTsDsl extends Mixed { } } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + /** Switches the function to an arrow function form. */ arrow(): FuncTsDsl<'arrow'> { this.mode = 'arrow'; @@ -107,11 +112,10 @@ class ImplFuncTsDsl extends Mixed { : M extends 'expr' ? ts.FunctionExpression : ts.ArrowFunction { + this.$validate(); const body = this.$node(new BlockTsDsl(...this._do).pretty()); if (this.mode === 'decl') { - const name = this.name.toString(); - if (!name) throw new Error('Function declaration requires a name'); const node = ts.factory.createFunctionDeclaration( [...this.$decorators(), ...this.modifiers], undefined, @@ -151,6 +155,18 @@ class ImplFuncTsDsl extends Mixed { ) as any; return this.$docs(node); } + + $validate(): asserts this { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Function ${this.mode} missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (this.mode === 'decl' && !this.name.toString()) missing.push('name'); + return missing; + } } export const FuncTsDsl = ImplFuncTsDsl as { diff --git a/packages/openapi-ts/src/ts-dsl/decl/param.ts b/packages/openapi-ts/src/ts-dsl/decl/param.ts index 2489b0d1d..63701291b 100644 --- a/packages/openapi-ts/src/ts-dsl/decl/param.ts +++ b/packages/openapi-ts/src/ts-dsl/decl/param.ts @@ -39,6 +39,11 @@ export class ParamTsDsl extends Mixed { ctx.analyze(this._type); } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + /** Sets the parameter type. */ type(type: string | TypeTsDsl): this { this._type = type instanceof TypeTsDsl ? type : new TypeExprTsDsl(type); @@ -46,17 +51,27 @@ export class ParamTsDsl extends Mixed { } override toAst() { - const name = this.$pattern() || this.name.toString(); - if (!name) { - throw new Error('Param must have either a name or a destructuring pattern'); - } + this.$validate(); return ts.factory.createParameterDeclaration( this.$decorators(), undefined, - name, + this.$pattern() ?? this.name.toString(), this._optional ? this.$node(new TokenTsDsl().optional()) : undefined, this.$type(this._type), this.$value(), ); } + + $validate(): asserts this { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Parameter missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this.$pattern() && !this.name.toString()) + missing.push('name or pattern (.array()/.object())'); + return missing; + } } diff --git a/packages/openapi-ts/src/ts-dsl/decl/pattern.ts b/packages/openapi-ts/src/ts-dsl/decl/pattern.ts index 78ab25f52..b0ff5f519 100644 --- a/packages/openapi-ts/src/ts-dsl/decl/pattern.ts +++ b/packages/openapi-ts/src/ts-dsl/decl/pattern.ts @@ -23,6 +23,11 @@ export class PatternTsDsl extends Mixed { super.analyze(ctx); } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + /** Defines an array pattern (e.g. `[a, b, c]`). */ array(...props: ReadonlyArray | [ReadonlyArray]): this { const values = props[0] instanceof Array ? [...props[0]] : (props as ReadonlyArray); @@ -49,9 +54,7 @@ export class PatternTsDsl extends Mixed { } override toAst() { - if (!this.pattern) { - throw new Error('PatternTsDsl requires object() or array() pattern'); - } + this.$validate(); if (this.pattern.kind === 'object') { const elements = Object.entries(this.pattern.values).map(([key, alias]) => @@ -76,6 +79,22 @@ export class PatternTsDsl extends Mixed { throw new Error('PatternTsDsl requires object() or array() pattern'); } + $validate(): asserts this is this & { + pattern: + | { kind: 'array'; values: ReadonlyArray } + | { kind: 'object'; values: Record }; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Binding pattern missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this.pattern) missing.push('.array() or .object()'); + return missing; + } + private createSpread(): ts.BindingElement | undefined { return this._spread ? ts.factory.createBindingElement( diff --git a/packages/openapi-ts/src/ts-dsl/expr/binary.ts b/packages/openapi-ts/src/ts-dsl/expr/binary.ts index f02c971ed..14f89ecf8 100644 --- a/packages/openapi-ts/src/ts-dsl/expr/binary.ts +++ b/packages/openapi-ts/src/ts-dsl/expr/binary.ts @@ -50,6 +50,11 @@ export class BinaryTsDsl extends Mixed { ctx.analyze(this._expr); } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + /** Logical AND — `this && expr` */ and(expr: Expr): this { return this.opAndExpr('&&', expr); @@ -136,16 +141,26 @@ export class BinaryTsDsl extends Mixed { } override toAst() { - if (!this._op) { - throw new Error('BinaryTsDsl: missing operator'); - } - const expr = this.$node(this._expr); - if (!expr) { - throw new Error('BinaryTsDsl: missing right-hand expression'); - } + this.$validate(); const base = this.$node(this._base); const operator = typeof this._op === 'string' ? this.opToToken(this._op) : this._op; - return ts.factory.createBinaryExpression(base, operator, expr); + return ts.factory.createBinaryExpression(base, operator, this.$node(this._expr)); + } + + $validate(): asserts this is this & { + _expr: Ref; + _op: Op; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Binary expression missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._op) missing.push('operator (e.g., .eq(), .plus())'); + if (!this._expr) missing.push('right-hand expression'); + return missing; } /** Sets the binary operator and right-hand operand for this expression. */ diff --git a/packages/openapi-ts/src/ts-dsl/expr/prefix.ts b/packages/openapi-ts/src/ts-dsl/expr/prefix.ts index 7e4244c4e..83667fe51 100644 --- a/packages/openapi-ts/src/ts-dsl/expr/prefix.ts +++ b/packages/openapi-ts/src/ts-dsl/expr/prefix.ts @@ -23,6 +23,11 @@ export class PrefixTsDsl extends Mixed { ctx.analyze(this._expr); } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + /** Sets the operand (the expression being prefixed). */ expr(expr: string | MaybeTsDsl): this { this._expr = expr; @@ -48,12 +53,23 @@ export class PrefixTsDsl extends Mixed { } override toAst() { - if (!this._expr) { - throw new Error('Missing expression for prefix unary expression'); - } - if (!this._op) { - throw new Error('Missing operator for prefix unary expression'); - } + this.$validate(); return ts.factory.createPrefixUnaryExpression(this._op, this.$node(this._expr)); } + + $validate(): asserts this is this & { + _expr: string | MaybeTsDsl; + _op: ts.PrefixUnaryOperator; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Prefix unary expression missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._expr) missing.push('.expr()'); + if (!this._op) missing.push('operator (e.g., .not(), .neg())'); + return missing; + } } diff --git a/packages/openapi-ts/src/ts-dsl/expr/ternary.ts b/packages/openapi-ts/src/ts-dsl/expr/ternary.ts index b88091ff4..6df3f05a1 100644 --- a/packages/openapi-ts/src/ts-dsl/expr/ternary.ts +++ b/packages/openapi-ts/src/ts-dsl/expr/ternary.ts @@ -25,6 +25,11 @@ export class TernaryTsDsl extends Mixed { ctx.analyze(this._else); } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + condition(condition: string | MaybeTsDsl) { this._condition = condition; return this; @@ -41,10 +46,7 @@ export class TernaryTsDsl extends Mixed { } override toAst() { - if (!this._condition) throw new Error('Missing condition in ternary'); - if (!this._then) throw new Error('Missing then expression in ternary'); - if (!this._else) throw new Error('Missing else expression in ternary'); - + this.$validate(); return ts.factory.createConditionalExpression( this.$node(this._condition), undefined, @@ -53,4 +55,22 @@ export class TernaryTsDsl extends Mixed { this.$node(this._else), ); } + + $validate(): asserts this is this & { + _condition: string | MaybeTsDsl; + _else: string | MaybeTsDsl; + _then: string | MaybeTsDsl; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Ternary expression missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._condition) missing.push('.condition()'); + if (!this._then) missing.push('.do()'); + if (!this._else) missing.push('.otherwise()'); + return missing; + } } diff --git a/packages/openapi-ts/src/ts-dsl/stmt/if.ts b/packages/openapi-ts/src/ts-dsl/stmt/if.ts index c09cc251b..982b77661 100644 --- a/packages/openapi-ts/src/ts-dsl/stmt/if.ts +++ b/packages/openapi-ts/src/ts-dsl/stmt/if.ts @@ -37,6 +37,11 @@ export class IfTsDsl extends Mixed { } } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + condition(condition: IfCondition): this { this._condition = condition; return this; @@ -48,13 +53,26 @@ export class IfTsDsl extends Mixed { } override toAst() { - if (!this._condition) throw new Error('Missing condition in if'); - if (!this._do) throw new Error('Missing then block in if'); - + this.$validate(); return ts.factory.createIfStatement( this.$node(this._condition), this.$node(new BlockTsDsl(...this._do).pretty()), this._else ? this.$node(new BlockTsDsl(...this._else).pretty()) : undefined, ); } + + $validate(): asserts this is this & { + _condition: IfCondition; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`If statement missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._condition) missing.push('.condition()'); + if (this._do.length === 0) missing.push('.do()'); + return missing; + } } diff --git a/packages/openapi-ts/src/ts-dsl/stmt/try.ts b/packages/openapi-ts/src/ts-dsl/stmt/try.ts index 5d7938d06..414e9b5ac 100644 --- a/packages/openapi-ts/src/ts-dsl/stmt/try.ts +++ b/packages/openapi-ts/src/ts-dsl/stmt/try.ts @@ -54,6 +54,11 @@ export class TryTsDsl extends Mixed { } } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + catch(...items: Array): this { this._catch = items; return this; @@ -75,8 +80,7 @@ export class TryTsDsl extends Mixed { } override toAst() { - if (!this._try?.length) throw new Error('Missing try block'); - + this.$validate(); const catchParam = this._catchArg ? (this.$node(this._catchArg) as ts.BindingName) : undefined; return ts.factory.createTryStatement( @@ -88,4 +92,18 @@ export class TryTsDsl extends Mixed { this._finally ? this.$node(new BlockTsDsl(...this._finally).pretty()) : undefined, ); } + + $validate(): asserts this is this & { + _try: Array; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Try statement missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._try || this._try.length === 0) missing.push('.try()'); + return missing; + } } diff --git a/packages/openapi-ts/src/ts-dsl/stmt/var.ts b/packages/openapi-ts/src/ts-dsl/stmt/var.ts index 3660efe38..31e6fcef8 100644 --- a/packages/openapi-ts/src/ts-dsl/stmt/var.ts +++ b/packages/openapi-ts/src/ts-dsl/stmt/var.ts @@ -36,6 +36,11 @@ export class VarTsDsl extends Mixed { ctx.analyze(this._type); } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + const(): this { this.kind = ts.NodeFlags.Const; return this; @@ -58,14 +63,13 @@ export class VarTsDsl extends Mixed { } override toAst() { - const name = this.$pattern() ?? this.$node(this.name); - if (!name) throw new Error('Var must have either a name or a destructuring pattern'); + this.$validate(); const node = ts.factory.createVariableStatement( this.modifiers, ts.factory.createVariableDeclarationList( [ ts.factory.createVariableDeclaration( - name as ts.BindingName, + this.$pattern() ?? (this.$node(this.name) as ts.BindingName), undefined, this.$type(this._type), this.$value(), @@ -76,4 +80,17 @@ export class VarTsDsl extends Mixed { ); return this.$docs(this.$hint(node)); } + + $validate(): asserts this { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Variable declaration missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this.$pattern() && !this.name.toString()) + missing.push('name or pattern (.array()/.object())'); + return missing; + } } diff --git a/packages/openapi-ts/src/ts-dsl/token.ts b/packages/openapi-ts/src/ts-dsl/token.ts index 10172bbe4..d28721bcc 100644 --- a/packages/openapi-ts/src/ts-dsl/token.ts +++ b/packages/openapi-ts/src/ts-dsl/token.ts @@ -46,10 +46,27 @@ export class TokenTsDsl extends TsDsl { - if (!this._kind) { - throw new Error(`Token missing \`.kind(kind)\``); - } + this.$validate(); // @ts-expect-error return ts.factory.createToken(this._kind); } + + $validate(): asserts this is this & { + _kind: K; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Token missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._kind) missing.push('.kind()'); + return missing; + } + + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } } diff --git a/packages/openapi-ts/src/ts-dsl/type/alias.ts b/packages/openapi-ts/src/ts-dsl/type/alias.ts index 9916debca..1b11a1d25 100644 --- a/packages/openapi-ts/src/ts-dsl/type/alias.ts +++ b/packages/openapi-ts/src/ts-dsl/type/alias.ts @@ -35,6 +35,11 @@ export class TypeAliasTsDsl extends Mixed { ctx.analyze(this.value); } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + /** Sets the type expression on the right-hand side of `= ...`. */ type(node: Value): this { this.value = node; @@ -42,8 +47,7 @@ export class TypeAliasTsDsl extends Mixed { } override toAst() { - if (!this.value) - throw new Error(`Type alias '${this.name.toString()}' is missing a type definition`); + this.$validate(); const node = ts.factory.createTypeAliasDeclaration( this.modifiers, this.$node(this.name) as ts.Identifier, @@ -52,4 +56,19 @@ export class TypeAliasTsDsl extends Mixed { ); return this.$docs(node); } + + $validate(): asserts this is this & { + value: Value; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + const name = this.name.toString(); + throw new Error(`Type alias${name ? ` "${name}"` : ''} missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this.value) missing.push('.type()'); + return missing; + } } diff --git a/packages/openapi-ts/src/ts-dsl/type/attr.ts b/packages/openapi-ts/src/ts-dsl/type/attr.ts index b8be67f61..3ec1783d0 100644 --- a/packages/openapi-ts/src/ts-dsl/type/attr.ts +++ b/packages/openapi-ts/src/ts-dsl/type/attr.ts @@ -37,6 +37,11 @@ export class TypeAttrTsDsl extends Mixed { ctx.analyze(this._right); } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + base(base?: Base | Ref): this { if (isRef(base)) { this._base = base; @@ -52,13 +57,27 @@ export class TypeAttrTsDsl extends Mixed { } override toAst() { - if (!this._base) { - throw new Error('TypeAttrTsDsl: missing base for qualified name'); - } + this.$validate(); const left = this.$node(this._base); if (!ts.isEntityName(left)) { throw new Error('TypeAttrTsDsl: base must be an EntityName'); } return ts.factory.createQualifiedName(left, this.$node(this._right) as ts.Identifier); } + + $validate(): asserts this is this & { + _base: Ref; + _right: Ref; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Type attribute missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._base) missing.push('.base()'); + if (!this._right) missing.push('.right()'); + return missing; + } } diff --git a/packages/openapi-ts/src/ts-dsl/type/expr.ts b/packages/openapi-ts/src/ts-dsl/type/expr.ts index 2854910bd..8e03cfb47 100644 --- a/packages/openapi-ts/src/ts-dsl/type/expr.ts +++ b/packages/openapi-ts/src/ts-dsl/type/expr.ts @@ -39,6 +39,11 @@ export class TypeExprTsDsl extends Mixed { ctx.analyze(this._exprInput); } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + /** Accesses a nested type (e.g. `Foo.Bar`). */ attr(right: string | ts.Identifier | TypeAttrTsDsl): this { this._exprInput = isNode(right) @@ -48,12 +53,26 @@ export class TypeExprTsDsl extends Mixed { } override toAst() { - if (!this._exprInput) throw new Error('TypeExpr must have an expression'); + this.$validate(); return ts.factory.createTypeReferenceNode( this.$type(this._exprInput) as ts.EntityName, this.$generics(), ); } + + $validate(): asserts this is this & { + _exprInput: Ref; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Type expression missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._exprInput) missing.push('name or .attr()'); + return missing; + } } f.type.expr.set( diff --git a/packages/openapi-ts/src/ts-dsl/type/func.ts b/packages/openapi-ts/src/ts-dsl/type/func.ts index 8046d319e..ce203b52a 100644 --- a/packages/openapi-ts/src/ts-dsl/type/func.ts +++ b/packages/openapi-ts/src/ts-dsl/type/func.ts @@ -17,12 +17,30 @@ export class TypeFuncTsDsl extends Mixed { super.analyze(ctx); } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + override toAst() { - const returns = this.$returns(); - if (returns === undefined) { - throw new Error('Missing return type in function type DSL'); - } - const node = ts.factory.createFunctionTypeNode(this.$generics(), this.$params(), returns); + this.$validate(); + const node = ts.factory.createFunctionTypeNode( + this.$generics(), + this.$params(), + this.$returns()!, + ); return this.$docs(node); } + + $validate(): asserts this { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Function type missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (this.$returns() === undefined) missing.push('.returns()'); + return missing; + } } diff --git a/packages/openapi-ts/src/ts-dsl/type/idx.ts b/packages/openapi-ts/src/ts-dsl/type/idx.ts index ed8f5c533..63184f033 100644 --- a/packages/openapi-ts/src/ts-dsl/type/idx.ts +++ b/packages/openapi-ts/src/ts-dsl/type/idx.ts @@ -31,6 +31,11 @@ export class TypeIdxTsDsl extends Mixed { ctx.analyze(this._index); } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + base(base: Base): this { this._base = base; return this; @@ -42,8 +47,25 @@ export class TypeIdxTsDsl extends Mixed { } override toAst() { + this.$validate(); return ts.factory.createIndexedAccessTypeNode(this.$type(this._base), this.$type(this._index)); } + + $validate(): asserts this is this & { + _base: Base; + _index: Index; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + throw new Error(`Indexed access type missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (this._base === undefined) missing.push('.base()'); + if (this._index === undefined) missing.push('.index()'); + return missing; + } } f.type.idx.set((...args) => new TypeIdxTsDsl(...args)); diff --git a/packages/openapi-ts/src/ts-dsl/type/prop.ts b/packages/openapi-ts/src/ts-dsl/type/prop.ts index 9a26e8f7c..c0e0f85d9 100644 --- a/packages/openapi-ts/src/ts-dsl/type/prop.ts +++ b/packages/openapi-ts/src/ts-dsl/type/prop.ts @@ -42,6 +42,11 @@ export class TypePropTsDsl extends Mixed { ctx.analyze(this._type); } + /** Returns true when all required builder calls are present. */ + get isValid(): boolean { + return this.missingRequiredCalls().length === 0; + } + /** Sets the property type. */ type(type: TypePropType): this { this._type = ref(type); @@ -49,10 +54,8 @@ export class TypePropTsDsl extends Mixed { } override toAst() { + this.$validate(); const name = this.name.toString(); - if (!this._type || !name) { - throw new Error(`Type not specified for property '${name}'`); - } const node = ts.factory.createPropertySignature( this.modifiers, this.$node(safePropName(name)), @@ -61,4 +64,19 @@ export class TypePropTsDsl extends Mixed { ); return this.$docs(node); } + + $validate(): asserts this is this & { + _type: Ref; + } { + const missing = this.missingRequiredCalls(); + if (missing.length === 0) return; + const name = this.name.toString(); + throw new Error(`Type property${name ? ` "${name}"` : ''} missing ${missing.join(' and ')}`); + } + + private missingRequiredCalls(): ReadonlyArray { + const missing: Array = []; + if (!this._type) missing.push('.type()'); + return missing; + } } diff --git a/packages/openapi-ts/src/ts-dsl/utils/name.ts b/packages/openapi-ts/src/ts-dsl/utils/name.ts index 3f4314227..8c2c93908 100644 --- a/packages/openapi-ts/src/ts-dsl/utils/name.ts +++ b/packages/openapi-ts/src/ts-dsl/utils/name.ts @@ -50,7 +50,7 @@ const safeName = (name: string, reserved: ReservedList): string => { let sanitized = ''; let index: number; - const first = name[0]!; + const first = name[0] ?? ''; regexp.illegalStartCharacters.lastIndex = 0; if (regexp.illegalStartCharacters.test(first)) { sanitized += '_'; @@ -61,7 +61,7 @@ const safeName = (name: string, reserved: ReservedList): string => { } while (index < name.length) { - const char = name[index]!; + const char = name[index] ?? ''; sanitized += /^[\u200c\u200d\p{ID_Continue}]$/u.test(char) ? char : '_'; index += 1; } diff --git a/packages/openapi-ts/src/ts-dsl/utils/render-utils.ts b/packages/openapi-ts/src/ts-dsl/utils/render-utils.ts index bacbb3994..dd1641baa 100644 --- a/packages/openapi-ts/src/ts-dsl/utils/render-utils.ts +++ b/packages/openapi-ts/src/ts-dsl/utils/render-utils.ts @@ -80,17 +80,17 @@ export type ModuleImport = Omit & { modulePath: string; }; -export const moduleSortKey = ({ +export function moduleSortKey({ file, fromFile, preferFileExtension, root, }: { - file: File; - fromFile: File; + file: Pick; + fromFile: Pick; preferFileExtension: string; root: string; -}): SortKey => { +}): SortKey { const filePath = file.finalPath!.split(path.sep).join('/'); let modulePath = fromFile.finalPath!.split(path.sep).join('/'); @@ -133,4 +133,4 @@ export const moduleSortKey = ({ } return [2, parentCount, modulePath]; -}; +} diff --git a/packages/openapi-ts/tsdown.config.ts b/packages/openapi-ts/tsdown.config.ts index 1cc48e3a6..87bfe8382 100644 --- a/packages/openapi-ts/tsdown.config.ts +++ b/packages/openapi-ts/tsdown.config.ts @@ -15,9 +15,7 @@ const replaceCoreImports = (filePath: string) => { export default defineConfig({ clean: true, - dts: { - build: true, - }, + dts: true, entry: ['./src/{index,internal,run}.ts'], format: ['esm'], minify: false, diff --git a/packages/shared/src/config/shared.ts b/packages/shared/src/config/shared.ts index 803211d4b..e65eaac5a 100644 --- a/packages/shared/src/config/shared.ts +++ b/packages/shared/src/config/shared.ts @@ -1,4 +1,4 @@ -import type { NameConflictResolver } from '@hey-api/codegen-core'; +import type { NameConflictResolver, Symbol } from '@hey-api/codegen-core'; import type { MaybeArray } from '@hey-api/types'; import type { Plugin } from '../plugins/types'; @@ -17,11 +17,40 @@ export type FeatureToggle = { enabled: boolean; }; +export type UserIndexExportOption = { + /** + * Whether exports should be re-exported from the entry file. + * + * - `true` — include all exports + * - `false` — exclude all exports + * - `(symbol) => boolean` — include exports matching the predicate + * + * @default false + * @deprecated use `includeInEntry` instead + */ + exportFromIndex?: boolean | ((symbol: Symbol) => boolean); + /** + * Whether exports should be re-exported from the entry file. + * + * - `true` — include all exports + * - `false` — exclude all exports + * - `(symbol) => boolean` — include exports matching the predicate + * + * @default false + */ + includeInEntry?: boolean | ((symbol: Symbol) => boolean); +}; export type IndexExportOption = { /** - * Whether exports should be re-exported in the index file. + * Whether exports should be re-exported from the entry file. + * + * @deprecated use `includeInEntry` instead */ - exportFromIndex: boolean; + exportFromIndex: boolean | ((symbol: Symbol) => boolean); + /** + * Whether exports should be re-exported from the entry file. + */ + includeInEntry: boolean | ((symbol: Symbol) => boolean); }; export type NamingOptions = { @@ -55,6 +84,14 @@ export interface BaseUserOutput { * @default true */ clean?: boolean; + /** + * Whether to generate an entry file that re-exports symbols for convenient imports. + * + * Plugins control their inclusion via `includeInEntry`. + * + * @default true + */ + entryFile?: boolean; /** * Optional function to transform file names before they are used. * @@ -95,11 +132,12 @@ export interface BaseUserOutput { */ header?: OutputHeader; /** - * Should the exports from plugin files be re-exported in the index - * barrel file? By default, this is enabled and only default plugins - * are re-exported. + * Whether to generate an entry file that re-exports symbols for convenient imports. + * + * Plugins control their inclusion via `includeInEntry`. * * @default true + * @deprecated use `entryFile` instead */ indexFile?: boolean; /** @@ -145,6 +183,10 @@ export interface BaseOutput { * input, or package version changes. */ clean: boolean; + /** + * Whether to generate an entry file that re-exports symbols for convenient imports. + */ + entryFile: boolean; /** * Optional function to transform file names before they are used. * @@ -169,9 +211,9 @@ export interface BaseOutput { */ header: OutputHeader; /** - * Should the exports from plugin files be re-exported in the index - * barrel file? By default, this is enabled and only default plugins - * are re-exported. + * Whether to generate an entry file that re-exports symbols for convenient imports. + * + * @deprecated use `entryFile` instead */ indexFile: boolean; /** diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 95a903435..0de41e49e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -22,6 +22,7 @@ export type { FeatureToggle, IndexExportOption, NamingOptions, + UserIndexExportOption, } from './config/shared'; export type { ValueToObject } from './config/utils/config'; export { valueToObject } from './config/utils/config'; diff --git a/packages/shared/src/ir/context.ts b/packages/shared/src/ir/context.ts index a8656f376..45e7c3bd6 100644 --- a/packages/shared/src/ir/context.ts +++ b/packages/shared/src/ir/context.ts @@ -98,8 +98,6 @@ export class Context = any, Config extends AnyC private registerPlugin( name: T, ): T extends keyof PluginConfigMap ? PluginInstance : PluginInstance { - // Cast to a loose type internally - the config structure is guaranteed - // by the config resolution layer, not by this method const plugin = (this.config.plugins as Record>)[name]!; const instance = new PluginInstance({ api: plugin.api, diff --git a/packages/shared/src/openApi/__tests__/index.test.ts b/packages/shared/src/openApi/__tests__/index.test.ts index ed451f305..72432d8ec 100644 --- a/packages/shared/src/openApi/__tests__/index.test.ts +++ b/packages/shared/src/openApi/__tests__/index.test.ts @@ -47,7 +47,7 @@ describe('OpenAPI parser', () => { // @ts-expect-error output: { case: undefined, - indexFile: false, + entryFile: false, path: '', }, // @ts-expect-error @@ -79,7 +79,7 @@ describe('OpenAPI parser', () => { // @ts-expect-error output: { case: undefined, - indexFile: false, + entryFile: false, path: '', }, // @ts-expect-error @@ -111,7 +111,7 @@ describe('OpenAPI parser', () => { // @ts-expect-error output: { case: undefined, - indexFile: false, + entryFile: false, path: '', }, // @ts-expect-error @@ -143,7 +143,7 @@ describe('OpenAPI parser', () => { // @ts-expect-error output: { case: undefined, - indexFile: false, + entryFile: false, path: '', }, // @ts-expect-error @@ -175,7 +175,7 @@ describe('OpenAPI parser', () => { // @ts-expect-error output: { case: undefined, - indexFile: false, + entryFile: false, path: '', }, // @ts-expect-error @@ -206,7 +206,7 @@ describe('OpenAPI parser', () => { // @ts-expect-error output: { case: undefined, - indexFile: false, + entryFile: false, path: '', }, // @ts-expect-error @@ -237,7 +237,7 @@ describe('OpenAPI parser', () => { // @ts-expect-error output: { case: undefined, - indexFile: false, + entryFile: false, path: '', }, // @ts-expect-error diff --git a/packages/shared/src/parser/hooks.ts b/packages/shared/src/parser/hooks.ts index 44003c3bd..d7fbcce29 100644 --- a/packages/shared/src/parser/hooks.ts +++ b/packages/shared/src/parser/hooks.ts @@ -174,7 +174,15 @@ export type Hooks = { /** * Optional output strategy to override default plugin behavior. * - * Use this to route generated symbols to specific files. + * Use this to re-export symbols from specific files. + * + * @returns The file path(s) that re-export this symbol, or undefined to fallback to default behavior. + */ + getExportFromFilePath?: (symbol: Symbol) => ReadonlyArray | undefined; + /** + * Optional output strategy to override default plugin behavior. + * + * Use this to route symbols to specific files. * * @returns The file path to output the symbol to, or undefined to fallback to default behavior. */ diff --git a/packages/shared/src/plugins/shared/utils/instance.ts b/packages/shared/src/plugins/shared/utils/instance.ts index 718a74ec6..a00ad4717 100644 --- a/packages/shared/src/plugins/shared/utils/instance.ts +++ b/packages/shared/src/plugins/shared/utils/instance.ts @@ -371,18 +371,18 @@ export class PluginInstance { symbol(name: SymbolIn['name'], symbol?: Omit): Symbol { const symbolIn: SymbolIn = { ...symbol, - exportFrom: - symbol?.exportFrom ?? - (!symbol?.external && this.context.config.output.indexFile && this.config.exportFromIndex - ? ['index'] - : undefined), - getFilePath: symbol?.getFilePath ?? this.getSymbolFilePath.bind(this), meta: { pluginName: path.isAbsolute(this.name) ? 'custom' : this.name, ...symbol?.meta, }, name, }; + if (symbolIn.getExportFromFilePath === undefined) { + symbolIn.getExportFromFilePath = this.getSymbolExportFromFilePath.bind(this); + } + if (symbolIn.getFilePath === undefined) { + symbolIn.getFilePath = this.getSymbolFilePath.bind(this); + } for (const hook of this.eventHooks['symbol:register:before']) { hook({ plugin: this as any, symbol: symbolIn }); } @@ -442,6 +442,34 @@ export class PluginInstance { }); } + private getSymbolExportFromFilePath(symbol: Symbol): ReadonlyArray | undefined { + const hooks = [this.config['~hooks']?.symbols, this.context.config.parser.hooks.symbols]; + for (const hook of hooks) { + const result = hook?.getExportFromFilePath?.(symbol); + if (result !== undefined) return result; + } + + // default logic below + const entryFile = this.context.config.output.indexFile ?? this.context.config.output.entryFile; + if (symbol.external || !entryFile) return; + + const includeInEntry = this.config.exportFromIndex ?? this.config.includeInEntry; + if ( + (typeof includeInEntry === 'boolean' && !includeInEntry) || + (typeof includeInEntry === 'function' && !includeInEntry(symbol)) + ) { + return; + } + + const language = symbol.node?.language; + if (!language) return; + + const moduleEntryName = this.gen.moduleEntryNames[language]; + if (!moduleEntryName) return; + + return [moduleEntryName]; + } + private getSymbolFilePath(symbol: Symbol): string | undefined { const hooks = [this.config['~hooks']?.symbols, this.context.config.parser.hooks.symbols]; for (const hook of hooks) { diff --git a/packages/shared/src/plugins/types.ts b/packages/shared/src/plugins/types.ts index 3330b197f..85ba45e1b 100644 --- a/packages/shared/src/plugins/types.ts +++ b/packages/shared/src/plugins/types.ts @@ -1,9 +1,10 @@ /* eslint-disable @typescript-eslint/no-namespace */ import type { AnyString } from '@hey-api/types'; +import type { IndexExportOption, UserIndexExportOption } from '../config/shared'; import type { ValueToObject } from '../config/utils/config'; import type { Dependency } from '../config/utils/dependencies'; -import type { Hooks } from '../parser/hooks'; +import type { Hooks as ParserHooks } from '../parser/hooks'; import type { PluginInstance } from './shared/utils/instance'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type @@ -29,11 +30,7 @@ export type PluginContext = { type BaseApi = Record; -type BaseConfig = { - /** - * Whether exports should be re-exported in the index file. - */ - exportFromIndex?: boolean; +type PluginBaseConfig = UserIndexExportOption & { name: AnyPluginName; /** * Optional hooks to override default plugin behavior. @@ -41,7 +38,7 @@ type BaseConfig = { * Use these to classify resources, control which outputs are generated, * or provide custom behavior for specific resources. */ - '~hooks'?: Hooks; + '~hooks'?: ParserHooks; }; /** @@ -75,10 +72,13 @@ export namespace Plugin { tags?: ReadonlyArray; }; + export type Exports = IndexExportOption; + export type UserExports = UserIndexExportOption; + /** * Generic wrapper for plugin hooks. */ - export type Hooks = Pick; + export type Hooks = Pick; export interface Name { name: Name; @@ -102,8 +102,8 @@ export namespace Plugin { }; export type Types< - Config extends BaseConfig = BaseConfig, - ResolvedConfig extends BaseConfig = Config, + Config extends PluginBaseConfig = PluginBaseConfig, + ResolvedConfig extends PluginBaseConfig = Config, Api extends BaseApi = never, > = ([Api] extends [never] ? { api?: BaseApi } : { api: Api }) & { config: Config; @@ -112,8 +112,8 @@ export namespace Plugin { } export type DefinePlugin< - Config extends BaseConfig = BaseConfig, - ResolvedConfig extends BaseConfig = Config, + Config extends PluginBaseConfig = PluginBaseConfig, + ResolvedConfig extends PluginBaseConfig = Config, Api extends BaseApi = never, > = { Config: Plugin.Config>; diff --git a/pyproject.toml b/pyproject.toml index 3aa021c6d..616404b43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "hey-api-dev" version = "0.0.0" -requires-python = ">=3.10" +requires-python = ">=3.9" [dependency-groups] -dev = ["mypy>=1.15", "ruff>=0.11"] +dev = ["httpx>=0.27", "mypy>=1.15", "ruff>=0.11"] [tool.mypy] -python_version = "3.10" +python_version = "3.9" strict = false warn_return_any = true warn_unused_configs = true diff --git a/turbo.json b/turbo.json index 9d76168c6..46f3f047e 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,7 @@ "tasks": { "build": { "dependsOn": ["^build"], - "inputs": ["src/**", "package.json", "tsconfig.json"], + "inputs": ["src/**", "package.json", "tsdown.config.ts", "tsconfig.json"], "outputs": [ ".next/**", "!.next/cache/**", diff --git a/uv.lock b/uv.lock index 1041a6500..7db157b8a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,50 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.9" + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] [[package]] name = "hey-api-dev" @@ -9,6 +53,7 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "mypy" }, { name = "ruff" }, ] @@ -17,10 +62,48 @@ dev = [ [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = ">=0.27" }, { name = "mypy", specifier = ">=1.15" }, { name = "ruff", specifier = ">=0.11" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + [[package]] name = "librt" version = "0.7.8" @@ -92,6 +175,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9b/2668bb01f568bc89ace53736df950845f8adfcacdf6da087d5cef12110cb/librt-0.7.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c7e8f88f79308d86d8f39c491773cbb533d6cb7fa6476f35d711076ee04fceb6", size = 56680, upload-time = "2026-01-14T12:56:02.602Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d4/dbb3edf2d0ec4ba08dcaf1865833d32737ad208962d4463c022cea6e9d3c/librt-0.7.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:389bd25a0db916e1d6bcb014f11aa9676cedaa485e9ec3752dfe19f196fd377b", size = 58612, upload-time = "2026-01-14T12:56:03.616Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/64b029de4ac9901fcd47832c650a0fd050555a452bd455ce8deddddfbb9f/librt-0.7.8-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73fd300f501a052f2ba52ede721232212f3b06503fa12665408ecfc9d8fd149c", size = 163654, upload-time = "2026-01-14T12:56:04.975Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/95e2abb1b48eb8f8c7fc2ae945321a6b82777947eb544cc785c3f37165b2/librt-0.7.8-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d772edc6a5f7835635c7562f6688e031f0b97e31d538412a852c49c9a6c92d5", size = 172477, upload-time = "2026-01-14T12:56:06.103Z" }, + { url = "https://files.pythonhosted.org/packages/7e/27/9bdf12e05b0eb089dd008d9c8aabc05748aad9d40458ade5e627c9538158/librt-0.7.8-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde8a130bd0f239e45503ab39fab239ace094d63ee1d6b67c25a63d741c0f71", size = 186220, upload-time = "2026-01-14T12:56:09.958Z" }, + { url = "https://files.pythonhosted.org/packages/53/6a/c3774f4cc95e68ed444a39f2c8bd383fd18673db7d6b98cfa709f6634b93/librt-0.7.8-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fdec6e2368ae4f796fc72fad7fd4bd1753715187e6d870932b0904609e7c878e", size = 183841, upload-time = "2026-01-14T12:56:11.109Z" }, + { url = "https://files.pythonhosted.org/packages/58/6b/48702c61cf83e9c04ad5cec8cad7e5e22a2cde23a13db8ef341598897ddd/librt-0.7.8-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:00105e7d541a8f2ee5be52caacea98a005e0478cfe78c8080fbb7b5d2b340c63", size = 179751, upload-time = "2026-01-14T12:56:12.278Z" }, + { url = "https://files.pythonhosted.org/packages/35/87/5f607fc73a131d4753f4db948833063c6aad18e18a4e6fbf64316c37ae65/librt-0.7.8-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c6f8947d3dfd7f91066c5b4385812c18be26c9d5a99ca56667547f2c39149d94", size = 199319, upload-time = "2026-01-14T12:56:13.425Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cc/b7c5ac28ae0f0645a9681248bae4ede665bba15d6f761c291853c5c5b78e/librt-0.7.8-cp39-cp39-win32.whl", hash = "sha256:41d7bb1e07916aeb12ae4a44e3025db3691c4149ab788d0315781b4d29b86afb", size = 43434, upload-time = "2026-01-14T12:56:14.781Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5d/dce0c92f786495adf2c1e6784d9c50a52fb7feb1cfb17af97a08281a6e82/librt-0.7.8-cp39-cp39-win_amd64.whl", hash = "sha256:e90a8e237753c83b8e484d478d9a996dc5e39fd5bd4c6ce32563bc8123f132be", size = 49801, upload-time = "2026-01-14T12:56:15.827Z" }, ] [[package]] @@ -137,6 +230,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/88436084550ca9af5e610fa45286be04c3b63374df3e021c762fe8c4369f/mypy-1.19.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bcfc336a03a1aaa26dfce9fff3e287a3ba99872a157561cbfcebe67c13308e3", size = 13102606, upload-time = "2025-12-15T05:02:46.833Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a5/43dfad311a734b48a752790571fd9e12d61893849a01bff346a54011957f/mypy-1.19.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b7951a701c07ea584c4fe327834b92a30825514c868b1f69c30445093fdd9d5a", size = 12164496, upload-time = "2025-12-15T05:03:41.947Z" }, + { url = "https://files.pythonhosted.org/packages/88/f0/efbfa391395cce2f2771f937e0620cfd185ec88f2b9cd88711028a768e96/mypy-1.19.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b13cfdd6c87fc3efb69ea4ec18ef79c74c3f98b4e5498ca9b85ab3b2c2329a67", size = 12772068, upload-time = "2025-12-15T05:02:53.689Z" }, + { url = "https://files.pythonhosted.org/packages/25/05/58b3ba28f5aed10479e899a12d2120d582ba9fa6288851b20bf1c32cbb4f/mypy-1.19.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f28f99c824ecebcdaa2e55d82953e38ff60ee5ec938476796636b86afa3956e", size = 13520385, upload-time = "2025-12-15T05:02:38.328Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a0/c006ccaff50b31e542ae69b92fe7e2f55d99fba3a55e01067dd564325f85/mypy-1.19.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c608937067d2fc5a4dd1a5ce92fd9e1398691b8c5d012d66e1ddd430e9244376", size = 13796221, upload-time = "2025-12-15T05:03:22.147Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ff/8bdb051cd710f01b880472241bd36b3f817a8e1c5d5540d0b761675b6de2/mypy-1.19.1-cp39-cp39-win_amd64.whl", hash = "sha256:409088884802d511ee52ca067707b90c883426bd95514e8cfda8281dc2effe24", size = 10055456, upload-time = "2025-12-15T05:03:35.169Z" }, { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] diff --git a/vercel.json b/vercel.json index 17d7771da..4f9088842 100644 --- a/vercel.json +++ b/vercel.json @@ -1,10 +1,10 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", - "buildCommand": "pnpm --filter @docs/openapi-ts build", + "buildCommand": "pnpm --filter docs build", "cleanUrls": true, - "devCommand": "pnpm --filter @docs/openapi-ts dev", + "devCommand": "pnpm --filter docs dev", "framework": "vitepress", - "installCommand": "pnpm install --filter @docs/openapi-ts", + "installCommand": "pnpm install --filter docs", "outputDirectory": "docs/.vitepress/dist", "redirects": [ { -- 2.51.2 From f814172036244b331d3a7c02dc7c56be1c814d92 Mon Sep 17 00:00:00 2001 From: Lubos Date: Thu, 5 Feb 2026 14:36:42 +0800 Subject: [PATCH 2/2] chore: fix types --- packages/openapi-python/src/config/plugins.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/openapi-python/src/config/plugins.ts b/packages/openapi-python/src/config/plugins.ts index 32701f9dc..2bda621e6 100644 --- a/packages/openapi-python/src/config/plugins.ts +++ b/packages/openapi-python/src/config/plugins.ts @@ -83,6 +83,7 @@ function getPluginsConfig({ }, valueToObject, }; + // @ts-expect-error plugin.resolveConfig(plugin, context); }