From d573f549f00a52afa05ad1c9d029aafd54cd3013 Mon Sep 17 00:00:00 2001 From: Mauricio Gardini Date: Mon, 16 Feb 2026 21:00:04 +0000 Subject: [PATCH] Add Type Imports for Python --- .../feature-python-type-imports-2026-2-16.md | 8 + .../python/src/components/CallSignature.tsx | 8 +- .../python/src/components/ImportStatement.tsx | 55 ++- packages/python/src/components/Reference.tsx | 4 +- packages/python/src/components/SourceFile.tsx | 52 ++- .../python/src/components/TypeRefContext.tsx | 36 ++ .../python/src/components/TypeReference.tsx | 22 +- .../src/components/VariableDeclaration.tsx | 6 +- packages/python/src/components/index.ts | 2 +- packages/python/src/context/index.ts | 1 + .../python/src/context/type-ref-context.tsx | 16 + .../python/src/symbols/python-module-scope.ts | 57 ++- .../src/symbols/python-output-symbol.ts | 33 +- packages/python/src/symbols/reference.tsx | 10 + .../test/dataclassdeclarations.test.tsx | 10 +- packages/python/test/externals.test.tsx | 10 +- .../python/test/functiondeclaration.test.tsx | 9 +- packages/python/test/imports.test.tsx | 12 +- packages/python/test/references.test.tsx | 2 +- packages/python/test/sourcefiles.test.tsx | 26 +- .../test/type-checking-imports.test.tsx | 363 ++++++++++++++++++ .../python/test/uniontypeexpression.test.tsx | 5 +- packages/python/test/variables.test.tsx | 5 +- pnpm-lock.yaml | 6 - 24 files changed, 696 insertions(+), 62 deletions(-) create mode 100644 .chronus/changes/feature-python-type-imports-2026-2-16.md create mode 100644 packages/python/src/components/TypeRefContext.tsx create mode 100644 packages/python/src/context/index.ts create mode 100644 packages/python/src/context/type-ref-context.tsx create mode 100644 packages/python/test/type-checking-imports.test.tsx diff --git a/.chronus/changes/feature-python-type-imports-2026-2-16.md b/.chronus/changes/feature-python-type-imports-2026-2-16.md new file mode 100644 index 000000000..4d383d942 --- /dev/null +++ b/.chronus/changes/feature-python-type-imports-2026-2-16.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@alloy-js/python" +--- + +Add type-only imports support in Python code generation. + diff --git a/packages/python/src/components/CallSignature.tsx b/packages/python/src/components/CallSignature.tsx index c09ad1250..e19214e84 100644 --- a/packages/python/src/components/CallSignature.tsx +++ b/packages/python/src/components/CallSignature.tsx @@ -13,6 +13,7 @@ import { import { createPythonSymbol } from "../symbol-creation.js"; import { PythonOutputSymbol } from "../symbols/index.js"; import { Atom } from "./Atom.jsx"; +import { TypeRefContext } from "./TypeRefContext.jsx"; export type ParameterMarker = "*" | "/"; @@ -75,7 +76,10 @@ function parameter(param: DeclaredParameterDescriptor) { {param.symbol.name} - : {param.type} + :{" "} + + {param.type} + = @@ -184,7 +188,7 @@ export function CallSignature(props: CallSignatureProps) { props.returnType ? <> {" -> "} - {props.returnType} + {props.returnType} : undefined; diff --git a/packages/python/src/components/ImportStatement.tsx b/packages/python/src/components/ImportStatement.tsx index 35ca1e937..09d54b242 100644 --- a/packages/python/src/components/ImportStatement.tsx +++ b/packages/python/src/components/ImportStatement.tsx @@ -6,6 +6,53 @@ export interface ImportStatementsProps { joinImportsFromSameModule?: boolean; } +export interface CategorizedImports { + /** Imports used only in type annotation contexts (for TYPE_CHECKING block) */ + typeImports: ImportRecords; + /** Regular imports used at runtime */ + valueImports: ImportRecords; +} + +/** + * Categorize import records into type-only and value imports. + * Type-only imports are those used only in type annotation contexts. + * Value imports are regular imports used at runtime. + */ +export function categorizeImportRecords( + records: ImportRecords, +): CategorizedImports { + const typeImports = new Map() as ImportRecords; + const valueImports = new Map() as ImportRecords; + + for (const [module, properties] of records) { + if (!properties.symbols || properties.symbols.size === 0) { + // Module-level imports without symbols go to value imports + valueImports.set(module, properties); + continue; + } + + const typeSymbols = new Set(); + const valueSymbols = new Set(); + + for (const sym of properties.symbols) { + if (sym.local.isTypeOnly) { + typeSymbols.add(sym); + } else { + valueSymbols.add(sym); + } + } + + if (typeSymbols.size > 0) { + typeImports.set(module, { symbols: typeSymbols }); + } + if (valueSymbols.size > 0) { + valueImports.set(module, { symbols: valueSymbols }); + } + } + + return { typeImports, valueImports }; +} + /** * A component that renders import statements based on the provided import records. * @@ -16,11 +63,11 @@ export interface ImportStatementsProps { */ export function ImportStatements(props: ImportStatementsProps) { // Sort the import records by module name - const imports = computed(() => - [...props.records].sort(([a], [b]) => { + const imports = computed(() => { + return [...props.records].sort(([a], [b]) => { return a.name.localeCompare(b.name); - }), - ); + }); + }); return mapJoin( () => imports.value, diff --git a/packages/python/src/components/Reference.tsx b/packages/python/src/components/Reference.tsx index 6ac0a5629..eadd2ee08 100644 --- a/packages/python/src/components/Reference.tsx +++ b/packages/python/src/components/Reference.tsx @@ -1,4 +1,5 @@ import { computed, emitSymbol, Refkey } from "@alloy-js/core"; +import { isTypeRefContext } from "../context/type-ref-context.js"; import { ref } from "../symbols/index.js"; export interface ReferenceProps { @@ -13,7 +14,8 @@ export interface ReferenceProps { * It takes a `refkey` prop which is the key of the symbol to reference. */ export function Reference({ refkey }: ReferenceProps) { - const reference = ref(refkey); + const inTypeRef = isTypeRefContext(); + const reference = ref(refkey, { type: inTypeRef }); const symbolRef = computed(() => reference()[1]); emitSymbol(symbolRef); diff --git a/packages/python/src/components/SourceFile.tsx b/packages/python/src/components/SourceFile.tsx index 3dd517d85..0c7999cdf 100644 --- a/packages/python/src/components/SourceFile.tsx +++ b/packages/python/src/components/SourceFile.tsx @@ -1,6 +1,7 @@ import { childrenArray, ComponentContext, + computed, SourceFile as CoreSourceFile, createNamedContext, createScope, @@ -14,8 +15,12 @@ import { } from "@alloy-js/core"; import { join } from "pathe"; import { PythonModuleScope } from "../symbols/index.js"; -import { ImportStatements } from "./ImportStatement.js"; +import { + categorizeImportRecords, + ImportStatements, +} from "./ImportStatement.js"; import { SimpleCommentBlock } from "./PyDoc.js"; +import { PythonBlock } from "./PythonBlock.js"; import { Reference } from "./Reference.js"; // Non top-level definitions @@ -99,7 +104,7 @@ export interface SourceFileProps { /** * __future__ imports to render after the docstring but before regular imports. */ - futureImports?: Children; + futureImports?: Children[]; } /** @@ -167,6 +172,28 @@ export function SourceFile(props: SourceFileProps) { props.doc !== undefined || props.futureImports !== undefined; + const imports = computed(() => { + // Quick scan for any type-only imports + const hasTypeImports = [...scope.importedModules.values()].some( + (props) => + props.symbols && [...props.symbols].some((s) => s.local.isTypeOnly), + ); + + // Add TYPE_CHECKING before categorizing so it's naturally included + const typeImportSymbol = hasTypeImports ? scope.addTypeImport() : undefined; + + // Single categorize - TYPE_CHECKING is already in scope.importedModules + const { valueImports, typeImports } = categorizeImportRecords( + scope.importedModules, + ); + + return { + valueImports, + typeImports, + typeImportSymbol, + }; + }); + return ( - 0}> - - - - - + {/* Regular (non-type-only) imports */} + 0}> + + + + {/* TYPE_CHECKING block with type-only imports */} + 0}> + + + + + + {/* Spacing after imports */} + 0}> + {/* Extra blank line before top-level definitions */} + * {someTypeRefkey} + * + * ``` + */ +export const TypeRefContext = ({ children }: TypeRefContextProps) => { + return ( + {children} + ); +}; diff --git a/packages/python/src/components/TypeReference.tsx b/packages/python/src/components/TypeReference.tsx index e0c014a4a..8b61b7415 100644 --- a/packages/python/src/components/TypeReference.tsx +++ b/packages/python/src/components/TypeReference.tsx @@ -1,5 +1,6 @@ import { Children, Refkey, Show } from "@alloy-js/core"; import { TypeArguments } from "./TypeArguments.js"; +import { TypeRefContext } from "./TypeRefContext.js"; export interface TypeReferenceProps { /** A refkey to a declared symbol. */ @@ -12,6 +13,11 @@ export interface TypeReferenceProps { /** * A type reference like Foo[T, P] or int. + * + * @remarks + * This component automatically wraps its content in a type reference context, + * so any symbols referenced via refkey will be imported as type-only + * (inside a `if TYPE_CHECKING:` block) unless also used as values elsewhere. */ export function TypeReference(props: TypeReferenceProps) { const type = props.refkey ? props.refkey : props.name; @@ -21,13 +27,15 @@ export function TypeReference(props: TypeReferenceProps) { : undefined; return ( - - + + + + + {type} + {typeArgs} + - {type} - {typeArgs} - - - + + ); } diff --git a/packages/python/src/components/VariableDeclaration.tsx b/packages/python/src/components/VariableDeclaration.tsx index 0e352ae32..c68234a62 100644 --- a/packages/python/src/components/VariableDeclaration.tsx +++ b/packages/python/src/components/VariableDeclaration.tsx @@ -10,6 +10,7 @@ import { createPythonSymbol } from "../symbol-creation.js"; import { Atom } from "./Atom.jsx"; import { BaseDeclarationProps } from "./Declaration.jsx"; import { SimpleCommentBlock } from "./PyDoc.jsx"; +import { TypeRefContext } from "./TypeRefContext.jsx"; export interface VariableDeclarationProps extends BaseDeclarationProps { /** @@ -93,7 +94,10 @@ export function VariableDeclaration(props: VariableDeclarationProps) { if (!props.type || props.callStatementVar) return undefined; return ( <> - : {props.type} + :{" "} + + {props.type} + ); }); diff --git a/packages/python/src/components/index.ts b/packages/python/src/components/index.ts index 32f69840f..710a19202 100644 --- a/packages/python/src/components/index.ts +++ b/packages/python/src/components/index.ts @@ -13,7 +13,6 @@ export type { CommonFunctionProps } from "./FunctionBase.js"; export * from "./FunctionCallExpression.js"; export * from "./FunctionDeclaration.js"; export * from "./FutureStatement.js"; -export * from "./ImportStatement.js"; export * from "./LexicalScope.js"; export * from "./MemberExpression.js"; export * from "./MemberScope.jsx"; @@ -27,6 +26,7 @@ export * from "./SourceFile.js"; export * from "./StatementList.js"; export * from "./StaticMethodDeclaration.js"; export * from "./TypeArguments.js"; +export * from "./TypeRefContext.js"; export * from "./TypeReference.js"; export * from "./UnionTypeExpression.js"; export * from "./VariableDeclaration.js"; diff --git a/packages/python/src/context/index.ts b/packages/python/src/context/index.ts new file mode 100644 index 000000000..36b1ed77a --- /dev/null +++ b/packages/python/src/context/index.ts @@ -0,0 +1 @@ +export * from "./type-ref-context.js"; diff --git a/packages/python/src/context/type-ref-context.tsx b/packages/python/src/context/type-ref-context.tsx new file mode 100644 index 000000000..394449446 --- /dev/null +++ b/packages/python/src/context/type-ref-context.tsx @@ -0,0 +1,16 @@ +import { ComponentContext, createContext, useContext } from "@alloy-js/core"; + +/** + * Context for tracking whether we are in a type annotation position. + * Used to determine if imports should be guarded with TYPE_CHECKING. + * + * @internal Use the TypeRefContext component instead. + */ +export const TypeRefContextDef: ComponentContext = createContext(); + +/** + * @returns 'true' if in a type context, 'false' if in a value context. + */ +export function isTypeRefContext(): boolean { + return useContext(TypeRefContextDef) === true; +} diff --git a/packages/python/src/symbols/python-module-scope.ts b/packages/python/src/symbols/python-module-scope.ts index fea16b3a3..870ea9c21 100644 --- a/packages/python/src/symbols/python-module-scope.ts +++ b/packages/python/src/symbols/python-module-scope.ts @@ -2,6 +2,30 @@ import { createSymbol, reactive, shallowReactive } from "@alloy-js/core"; import { PythonLexicalScope } from "./python-lexical-scope.js"; import { PythonOutputSymbol } from "./python-output-symbol.js"; +// Internal typing module for TYPE_CHECKING imports +let _typingModuleScope: PythonModuleScope | undefined; +let _typeCheckingSymbol: PythonOutputSymbol | undefined; + +/** + * Get the internal typing module scope and TYPE_CHECKING symbol. + * Used by addTypeImport() to add TYPE_CHECKING imports without + * going through the binder's refkey resolution. + */ +function getTypingModuleInternal(): { + scope: PythonModuleScope; + TYPE_CHECKING: PythonOutputSymbol; +} { + if (!_typingModuleScope) { + _typingModuleScope = new PythonModuleScope("typing", undefined); + _typeCheckingSymbol = new PythonOutputSymbol( + "TYPE_CHECKING", + _typingModuleScope.symbols, + {}, + ); + } + return { scope: _typingModuleScope, TYPE_CHECKING: _typeCheckingSymbol! }; +} + export class ImportedSymbol { local: PythonOutputSymbol; target: PythonOutputSymbol; @@ -22,6 +46,14 @@ export interface ImportRecordProps { export class ImportRecords extends Map {} +export interface AddImportOptions { + /** + * If true, this import is only used in type annotation contexts. + * Such imports will be guarded with `if TYPE_CHECKING:`. + */ + type?: boolean; +} + export class PythonModuleScope extends PythonLexicalScope { #importedSymbols: Map = shallowReactive(new Map()); @@ -34,9 +66,17 @@ export class PythonModuleScope extends PythonLexicalScope { return this.#importedModules; } - addImport(targetSymbol: PythonOutputSymbol, targetModule: PythonModuleScope) { + addImport( + targetSymbol: PythonOutputSymbol, + targetModule: PythonModuleScope, + options?: AddImportOptions, + ) { const existing = this.importedSymbols.get(targetSymbol); if (existing) { + // If existing is type-only but now used as value, upgrade it + if (!options?.type && existing.isTypeOnly) { + existing.markAsValue(); + } return existing; } @@ -50,7 +90,11 @@ export class PythonModuleScope extends PythonLexicalScope { PythonOutputSymbol, targetSymbol.name, this.symbols, - { binder: this.binder, aliasTarget: targetSymbol }, + { + binder: this.binder, + aliasTarget: targetSymbol, + typeOnly: options?.type, + }, ); this.importedSymbols.set(targetSymbol, localSymbol); @@ -62,6 +106,15 @@ export class PythonModuleScope extends PythonLexicalScope { return localSymbol; } + /** + * Add TYPE_CHECKING import from the typing module. + * Returns the local symbol for use in the if block opener. + */ + addTypeImport(): PythonOutputSymbol { + const typing = getTypingModuleInternal(); + return this.addImport(typing.TYPE_CHECKING, typing.scope); + } + override get debugInfo(): Record { return { ...super.debugInfo, diff --git a/packages/python/src/symbols/python-output-symbol.ts b/packages/python/src/symbols/python-output-symbol.ts index 7678b4f13..4c7a6f694 100644 --- a/packages/python/src/symbols/python-output-symbol.ts +++ b/packages/python/src/symbols/python-output-symbol.ts @@ -1,13 +1,19 @@ import { + createSymbol, Namekey, OutputSpace, OutputSymbol, OutputSymbolOptions, - createSymbol, + track, + TrackOpTypes, + trigger, + TriggerOpTypes, } from "@alloy-js/core"; export interface PythonOutputSymbolOptions extends OutputSymbolOptions { module?: string; + /** Whether this symbol is only used in type annotation contexts */ + typeOnly?: boolean; } export interface CreatePythonSymbolFunctionOptions @@ -28,6 +34,7 @@ export class PythonOutputSymbol extends OutputSymbol { ) { super(name, spaces, options); this.#module = options.module ?? undefined; + this.#typeOnly = options.typeOnly ?? false; } // The module in which the symbol is defined @@ -37,6 +44,29 @@ export class PythonOutputSymbol extends OutputSymbol { return this.#module; } + #typeOnly: boolean; + + /** + * Returns true if this symbol is only used in type annotation contexts. + * Such symbols can be imported inside a TYPE_CHECKING block. + */ + get isTypeOnly() { + track(this, TrackOpTypes.GET, "typeOnly"); + return this.#typeOnly; + } + + /** + * Mark this symbol as also being used as a value (not just a type). + */ + markAsValue() { + if (!this.#typeOnly) { + return; + } + const oldValue = this.#typeOnly; + this.#typeOnly = false; + trigger(this, TriggerOpTypes.SET, "typeOnly", false, oldValue); + } + get staticMembers() { return this.memberSpaceFor("static")!; } @@ -69,6 +99,7 @@ export class PythonOutputSymbol extends OutputSymbol { aliasTarget: this.aliasTarget, module: this.module, metadata: this.metadata, + typeOnly: this.isTypeOnly, }); this.initializeCopy(copy); diff --git a/packages/python/src/symbols/reference.tsx b/packages/python/src/symbols/reference.tsx index c5a99ced1..ca2182c83 100644 --- a/packages/python/src/symbols/reference.tsx +++ b/packages/python/src/symbols/reference.tsx @@ -13,8 +13,17 @@ import { PythonModuleScope } from "./python-module-scope.js"; import { PythonOutputSymbol } from "./python-output-symbol.js"; import { PythonOutputScope } from "./scopes.js"; +export interface RefOptions { + /** + * If true, this reference is only used in a type annotation context. + * The import will be guarded with `if TYPE_CHECKING:`. + */ + type?: boolean; +} + export function ref( refkey: Refkey, + options?: RefOptions, ): () => [Children, PythonOutputSymbol | undefined] { const sourceFile = useContext(PythonSourceFileContext); const resolveResult = resolve( @@ -41,6 +50,7 @@ export function ref( sourceFile!.scope.addImport( lexicalDeclaration, pathDown[0] as PythonModuleScope, + { type: options?.type }, ), ); } diff --git a/packages/python/test/dataclassdeclarations.test.tsx b/packages/python/test/dataclassdeclarations.test.tsx index 03fbb23f5..d698e3b14 100644 --- a/packages/python/test/dataclassdeclarations.test.tsx +++ b/packages/python/test/dataclassdeclarations.test.tsx @@ -73,7 +73,10 @@ describe("DataclassDeclaration", () => { expect(res).toRenderTo( d` from dataclasses import dataclass - from dataclasses import KW_ONLY + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from dataclasses import KW_ONLY @dataclass @@ -624,7 +627,10 @@ describe("DataclassDeclaration", () => { name="user" type={userRefkey} initializer={ - + } /> diff --git a/packages/python/test/externals.test.tsx b/packages/python/test/externals.test.tsx index b59af6140..1f8f285f6 100644 --- a/packages/python/test/externals.test.tsx +++ b/packages/python/test/externals.test.tsx @@ -92,7 +92,10 @@ it("uses import from external library in multiple functions", () => { const expected = d` from requests import get from requests import post - from requests.models import Response + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from requests.models import Response def get_user(user_id: int) -> Response: @@ -169,7 +172,10 @@ it("uses import from external library in multiple class methods", () => { const expected = d` from requests import get from requests import post - from requests.models import Response + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from requests.models import Response class UserClient: diff --git a/packages/python/test/functiondeclaration.test.tsx b/packages/python/test/functiondeclaration.test.tsx index 54e59664c..de06d69fb 100644 --- a/packages/python/test/functiondeclaration.test.tsx +++ b/packages/python/test/functiondeclaration.test.tsx @@ -450,9 +450,12 @@ describe("Function Declaration", () => { `, "usage.py": ` - from mod1 import Foo - from mod2 import A - from mod2 import B + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from mod1 import Foo + from mod2 import A + from mod2 import B async def foo(x: A, y: B, *args, **kwargs) -> Foo: diff --git a/packages/python/test/imports.test.tsx b/packages/python/test/imports.test.tsx index c2e8e23e9..1304e3685 100644 --- a/packages/python/test/imports.test.tsx +++ b/packages/python/test/imports.test.tsx @@ -1,6 +1,9 @@ import { refkey } from "@alloy-js/core"; import { describe, expect, it } from "vitest"; -import { ImportStatement } from "../src/components/ImportStatement.jsx"; +import { + ImportStatement, + ImportStatements, +} from "../src/components/ImportStatement.jsx"; import * as py from "../src/index.js"; import { createPythonSymbol } from "../src/symbol-creation.js"; import { ImportedSymbol, ImportRecords } from "../src/symbols/index.js"; @@ -56,7 +59,7 @@ describe("ImportStatements", () => { [sysModuleScope, { symbols: new Set() }], ]); - const result = toSourceText([]); + const result = toSourceText([]); const expected = ` from math import pi from math import sqrt @@ -85,10 +88,7 @@ describe("ImportStatements", () => { ]); const result = toSourceText([ - , + , ]); const expected = ` from math import pi, sqrt diff --git a/packages/python/test/references.test.tsx b/packages/python/test/references.test.tsx index 7e3e1560e..5a2abde19 100644 --- a/packages/python/test/references.test.tsx +++ b/packages/python/test/references.test.tsx @@ -15,7 +15,7 @@ describe("Reference", () => { name="current_user" type={rk1} initializer={ - + } /> , diff --git a/packages/python/test/sourcefiles.test.tsx b/packages/python/test/sourcefiles.test.tsx index 4b752111a..1bd80ab4c 100644 --- a/packages/python/test/sourcefiles.test.tsx +++ b/packages/python/test/sourcefiles.test.tsx @@ -312,7 +312,7 @@ it("only futureImports before definition", () => { const content = ( } + futureImports={[]} > pass @@ -333,7 +333,7 @@ it("only futureImports before non-definition", () => { const content = ( } + futureImports={[]} > @@ -393,7 +393,7 @@ it("doc + futureImports before definition", () => { } + futureImports={[]} > pass @@ -423,7 +423,7 @@ it("doc + futureImports before non-definition", () => { } + futureImports={[]} > @@ -498,7 +498,7 @@ it("futureImports + imports before definition", () => { const content = ( } + futureImports={[]} > @@ -525,7 +525,7 @@ it("futureImports + imports before non-definition", () => { const content = ( } + futureImports={[]} > { } + futureImports={[]} > @@ -588,7 +588,7 @@ it("doc + futureImports + imports before non-definition", () => { } + futureImports={[]} > { const content = ( } + futureImports={[]} /> ); @@ -661,7 +661,7 @@ it("doc + futureImports in file (no children)", () => { } + futureImports={[]} /> ); @@ -743,7 +743,7 @@ it("headerComment + futureImports before definition", () => { } + futureImports={[]} > pass @@ -797,7 +797,7 @@ it("headerComment + doc + futureImports before definition", () => { path="test.py" headerComment="Copyright 2024 My Company" doc={moduleDoc} - futureImports={} + futureImports={[]} > pass @@ -829,7 +829,7 @@ it("headerComment + doc + futureImports + imports before definition", () => { path="test.py" headerComment="Copyright 2024 My Company" doc={moduleDoc} - futureImports={} + futureImports={[]} > diff --git a/packages/python/test/type-checking-imports.test.tsx b/packages/python/test/type-checking-imports.test.tsx new file mode 100644 index 000000000..8b743fa69 --- /dev/null +++ b/packages/python/test/type-checking-imports.test.tsx @@ -0,0 +1,363 @@ +import { refkey } from "@alloy-js/core"; +import { describe, it } from "vitest"; +import { createModule } from "../src/create-module.js"; +import * as py from "../src/index.js"; +import { assertFileContents, toSourceTextMultiple } from "./utils.jsx"; + +describe("TYPE_CHECKING imports", () => { + it("imports type-only references inside TYPE_CHECKING block", () => { + const userClassRef = refkey(); + + const result = toSourceTextMultiple([ + + + , + + + pass + + , + ]); + + assertFileContents(result, { + "models.py": ` + class User: + pass + + `, + "service.py": ` + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from models import User + + + def process_user(user: User) -> None: + pass + + `, + }); + }); + + it("imports value references outside TYPE_CHECKING block", () => { + const userClassRef = refkey(); + + const result = toSourceTextMultiple([ + + + , + + + } + /> + + , + ]); + + assertFileContents(result, { + "models.py": ` + class User: + pass + + `, + "service.py": ` + from models import User + + + def create_user() -> None: + user = User() + + `, + }); + }); + + it("upgrades type-only import to regular import when also used as value", () => { + const userClassRef = refkey(); + + const result = toSourceTextMultiple([ + + + , + + + + } + /> + <>return user + + + , + ]); + + // Since User is used both as a type (parameter type, return type) and + // as a value (ClassInstantiation), it should be a regular import + assertFileContents(result, { + "models.py": ` + class User: + pass + + `, + "service.py": ` + from models import User + + + def create_user(existing: User) -> User: + user = User() + return user + + `, + }); + }); + + it("handles mixed type-only and regular imports from same module", () => { + const userClassRef = refkey(); + const helperFuncRef = refkey(); + + const result = toSourceTextMultiple([ + + + + pass + + , + + + + + , + ]); + + // helper is used as a value (function call), User is only used as type + assertFileContents(result, { + "models.py": ` + class User: + pass + + + def helper(): + pass + + `, + "service.py": ` + from models import helper + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from models import User + + + def process(user: User) -> None: + helper() + + `, + }); + }); + + it("handles return type as type-only import", () => { + const resultTypeRef = refkey(); + + const result = toSourceTextMultiple([ + + + , + + + pass + + , + ]); + + assertFileContents(result, { + "types.py": ` + class Result: + pass + + `, + "main.py": ` + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from types import Result + + + def get_result() -> Result: + pass + + `, + }); + }); + + it("handles variable type annotation as type-only import", () => { + const configTypeRef = refkey(); + + const result = toSourceTextMultiple([ + + + , + + + , + ]); + + assertFileContents(result, { + "types.py": ` + class Config: + pass + + `, + "main.py": ` + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from types import Config + + config: Config + `, + }); + }); + + it("handles TypeReference component as type-only import", () => { + const myTypeRef = refkey(); + + const result = toSourceTextMultiple([ + + + , + + } + omitNone + /> + , + ]); + + assertFileContents(result, { + "types.py": ` + class MyType: + pass + + `, + "main.py": ` + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from types import MyType + + value: MyType + `, + }); + }); + + it("handles class bases as regular import (runtime requirement)", () => { + const baseClassRef = refkey(); + + const result = toSourceTextMultiple([ + + + , + + + , + ]); + + // Class bases require runtime access, so they should NOT be + // inside a TYPE_CHECKING block + assertFileContents(result, { + "base.py": ` + class BaseClass: + pass + + `, + "derived.py": ` + from base import BaseClass + + + class DerivedClass(BaseClass): + pass + + `, + }); + }); + + it("renders regular imports before TYPE_CHECKING block", () => { + // Create a typing module with multiple exports + const typingModule = createModule({ + name: "typing", + descriptor: { + ".": ["TYPE_CHECKING", "cast"], + }, + }); + + // Create a third-party module + const requestsModule = createModule({ + name: "requests", + descriptor: { + ".": ["get"], + }, + }); + + const userClassRef = refkey(); + + const result = toSourceTextMultiple( + [ + + + , + + + + {/* Use cast as a value (function call) to make it a regular import */} + <>response = {requestsModule["."].get}("https://example.com") + <>return {typingModule["."].cast}(str, user) + + + , + ], + { externals: [typingModule, requestsModule] }, + ); + + // Regular imports first (sorted alphabetically), then TYPE_CHECKING block + assertFileContents(result, { + "models.py": ` + class User: + pass + + `, + "service.py": ` + from requests import get + from typing import cast + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from models import User + + + def get_users(user: User) -> str: + response = get("https://example.com") + return cast(str, user) + + `, + }); + }); +}); diff --git a/packages/python/test/uniontypeexpression.test.tsx b/packages/python/test/uniontypeexpression.test.tsx index ca75fd869..3a23f8616 100644 --- a/packages/python/test/uniontypeexpression.test.tsx +++ b/packages/python/test/uniontypeexpression.test.tsx @@ -139,7 +139,10 @@ describe("UnionTypeExpression", () => { `, "use.py": ` - from defs import Bar + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from defs import Bar v: Bar[T] = None `, diff --git a/packages/python/test/variables.test.tsx b/packages/python/test/variables.test.tsx index 89b94bc6c..6ad9517de 100644 --- a/packages/python/test/variables.test.tsx +++ b/packages/python/test/variables.test.tsx @@ -183,7 +183,10 @@ describe("Python Variable", () => { `, "usage.py": ` - from classes import MyClass + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from classes import MyClass my_var: MyClass = None `, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d04fe6d05..4027d2fb9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,12 +447,6 @@ importers: specifier: 'catalog:' version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.9)(esbuild@0.25.8)(jiti@2.6.1)(tsx@4.20.3)(yaml@2.8.0) - packages/dev-tools: - dependencies: - '@radix-ui/react-context-menu': - specifier: ^2.2.16 - version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - packages/devtools: dependencies: '@radix-ui/react-context-menu':