Skip to content

Commit 84271d5

Browse files
Add support for extra imports (#342)
There are certain scenarios in Python where we need to add imports at the top of the file without them necessarily being triggered by a symbol being referenced. One example is `from __future__ import annotations`. To allow that, we are adding the `futureImports` prop, which already existed and wasn't being used. Also, we are making the module docstring to be rendered at the top, as well as properly formatting the header and headerComment. Finally, we are fixing some spacing-related situations to better adhere to PEP8. With those changes, this is how a rendered file would look: ``` <module docstring> <manually inserted imports> <imports generated by the import engine> <children> ```
1 parent b8f551b commit 84271d5

16 files changed

+1011
-66
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: feature
3+
packages:
4+
- "@alloy-js/python"
5+
---
6+
7+
Add support for extra imports
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { type Children } from "@alloy-js/core";
2+
3+
export interface FutureStatementProps {
4+
/**
5+
* The name of the feature to import from __future__.
6+
*/
7+
feature: string;
8+
}
9+
10+
/**
11+
* A future statement that imports features from __future__.
12+
*
13+
* Future statements are directives to the compiler that a particular module
14+
* should be compiled using syntax or semantics from a future Python release.
15+
* They must appear near the top of the module, after the module docstring (if any).
16+
*
17+
* Use this in the `futureImports` prop of SourceFile to ensure proper placement.
18+
*
19+
* @example
20+
* ```tsx
21+
* <SourceFile path="models.py" futureImports={<FutureStatement feature="annotations" />}>
22+
* <ClassDeclaration name="User">
23+
* <PropertyDeclaration name="manager" type="User" />
24+
* </ClassDeclaration>
25+
* </SourceFile>
26+
* ```
27+
* renders to
28+
* ```py
29+
* from __future__ import annotations
30+
*
31+
*
32+
* class User:
33+
* manager: User
34+
* ```
35+
*/
36+
export function FutureStatement(props: FutureStatementProps): Children {
37+
return <>from __future__ import {props.feature}</>;
38+
}

packages/python/src/components/SourceFile.tsx

Lines changed: 128 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {
2+
childrenArray,
23
ComponentContext,
34
SourceFile as CoreSourceFile,
45
createNamedContext,
6+
isComponentCreator,
57
List,
68
Scope,
79
Show,
@@ -12,8 +14,51 @@ import {
1214
import { join } from "pathe";
1315
import { PythonModuleScope } from "../symbols/index.js";
1416
import { ImportStatements } from "./ImportStatement.js";
17+
import { SimpleCommentBlock } from "./PyDoc.js";
1518
import { Reference } from "./Reference.js";
1619

20+
// Non top-level definitions
21+
const NON_DEFINITION_NAMES = new Set([
22+
"VariableDeclaration",
23+
"MemberExpression",
24+
"FunctionCallExpression",
25+
"ClassInstantiation",
26+
"Reference",
27+
]);
28+
29+
// Wrapper components that we should look inside to find the actual first child
30+
const WRAPPER_COMPONENT_NAMES = new Set(["StatementList"]);
31+
32+
/**
33+
* Checks if the first child is a top-level definition (function or class).
34+
* PEP 8 requires 2 blank lines before top-level function and class definitions,
35+
* but not before other statements like variable assignments.
36+
*
37+
* Returns true only if there are children and the first child is a definition.
38+
*/
39+
function firstChildIsDefinition(children: Children | undefined): boolean {
40+
if (!children) return false;
41+
const arr = childrenArray(() => children);
42+
if (arr.length === 0) return false;
43+
const first = arr[0];
44+
45+
// Non-component children (strings, numbers, refkeys, etc.) are not definitions
46+
if (!isComponentCreator(first)) {
47+
return false;
48+
}
49+
50+
const name = first.component.name;
51+
if (NON_DEFINITION_NAMES.has(name)) {
52+
return false;
53+
}
54+
// Look inside wrapper components
55+
if (WRAPPER_COMPONENT_NAMES.has(name) && first.props?.children) {
56+
return firstChildIsDefinition(first.props.children as Children);
57+
}
58+
// If we get here, it's likely a definition (FunctionDeclaration, ClassDeclaration, etc.)
59+
return true;
60+
}
61+
1762
export interface PythonSourceFileContext {
1863
scope: PythonModuleScope;
1964
/** The module name for this file, e.g. 'test' for test.py */
@@ -37,17 +82,23 @@ export interface SourceFileProps {
3782
*/
3883
children?: Children;
3984
/**
40-
* Header comment to add to the file, which will be rendered at the top of the file.
85+
* Content to render at the very top of the file, before everything else.
86+
* Use this for shebang lines, encoding declarations, or license headers.
4187
*/
4288
header?: Children;
4389
/**
44-
* Comment to add to the header, which will be rendered as a comment in the file.
90+
* Comment to add at the top of the file, rendered as a Python comment block.
91+
* This is a convenience prop for adding copyright notices or other comments.
4592
*/
4693
headerComment?: string;
4794
/**
4895
* Documentation for this module, which will be rendered as a module-level docstring.
4996
*/
5097
doc?: Children;
98+
/**
99+
* __future__ imports to render after the docstring but before regular imports.
100+
*/
101+
futureImports?: Children;
51102
}
52103

53104
/**
@@ -100,21 +151,90 @@ export function SourceFile(props: SourceFileProps) {
100151
module: path,
101152
};
102153

154+
// Check if there are any children
155+
const hasChildren =
156+
props.children !== undefined &&
157+
childrenArray(() => props.children).length > 0;
158+
159+
// PEP 8 requires 2 blank lines before top-level function/class definitions
160+
const needsExtraSpacing = firstChildIsDefinition(props.children);
161+
162+
// Check if there's any preamble content (header, doc, imports, etc.)
163+
const hasPreamble =
164+
props.header !== undefined ||
165+
props.headerComment !== undefined ||
166+
props.doc !== undefined ||
167+
props.futureImports !== undefined;
168+
103169
return (
104-
<CoreSourceFile path={props.path} filetype="py" reference={Reference}>
105-
<Show when={scope.importedModules.size > 0}>
106-
<ImportStatements records={scope.importedModules} />
107-
<hbr />
170+
<CoreSourceFile
171+
path={props.path}
172+
filetype="py"
173+
reference={Reference}
174+
header={props.header}
175+
>
176+
{/* Extra blank line after header when followed by doc/futureImports/children (not headerComment) */}
177+
<Show
178+
when={
179+
props.header !== undefined &&
180+
props.headerComment === undefined &&
181+
(props.doc !== undefined ||
182+
props.futureImports !== undefined ||
183+
hasChildren)
184+
}
185+
>
108186
<hbr />
109187
</Show>
188+
<Show when={props.headerComment !== undefined}>
189+
<SimpleCommentBlock>{props.headerComment}</SimpleCommentBlock>
190+
{/* When followed by doc: just newline (no blank line) */}
191+
<Show when={props.doc !== undefined}>
192+
<hbr />
193+
</Show>
194+
{/* When followed by futureImports or children directly (no doc): blank line */}
195+
<Show
196+
when={
197+
props.doc === undefined &&
198+
(props.futureImports !== undefined || hasChildren)
199+
}
200+
>
201+
<hbr />
202+
<hbr />
203+
</Show>
204+
</Show>
110205
<Show when={props.doc !== undefined}>
111206
{props.doc}
112-
<hbr />
207+
<Show when={props.futureImports !== undefined || hasChildren}>
208+
<hbr />
209+
</Show>
210+
</Show>
211+
<Show when={props.futureImports !== undefined}>
212+
{props.futureImports}
213+
<Show when={hasChildren}>
214+
<hbr />
215+
<hbr />
216+
</Show>
217+
</Show>
218+
<Show when={scope.importedModules.size > 0}>
219+
<ImportStatements records={scope.importedModules} />
220+
<Show when={hasChildren}>
221+
<hbr />
222+
<hbr />
223+
</Show>
224+
</Show>
225+
{/* Extra blank line before top-level definitions */}
226+
<Show
227+
when={
228+
needsExtraSpacing && (hasPreamble || scope.importedModules.size > 0)
229+
}
230+
>
113231
<hbr />
114232
</Show>
115233
<PythonSourceFileContext.Provider value={sfContext}>
116234
<Scope value={scope}>
117-
<List doubleHardline>{props.children}</List>
235+
<Show when={hasChildren}>
236+
<List doubleHardline>{props.children}</List>
237+
</Show>
118238
</Scope>
119239
</PythonSourceFileContext.Provider>
120240
</CoreSourceFile>

packages/python/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export * from "./EnumMember.js";
1212
export type { CommonFunctionProps } from "./FunctionBase.js";
1313
export * from "./FunctionCallExpression.js";
1414
export * from "./FunctionDeclaration.js";
15+
export * from "./FutureStatement.js";
1516
export * from "./ImportStatement.js";
1617
export * from "./LexicalScope.js";
1718
export * from "./MemberExpression.js";

packages/python/test/classdeclarations.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ describe("Python Class", () => {
108108
const mod2Expected = d`
109109
from mod1 import A
110110
111+
111112
class B(A):
112113
pass
113114
@@ -116,6 +117,7 @@ describe("Python Class", () => {
116117
const mod3Expected = d`
117118
from folder.mod2 import B
118119
120+
119121
class C(B):
120122
pass
121123

packages/python/test/dataclassdeclarations.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe("DataclassDeclaration", () => {
2727
d`
2828
from dataclasses import dataclass
2929
30+
3031
@dataclass
3132
class User:
3233
"""
@@ -74,6 +75,7 @@ describe("DataclassDeclaration", () => {
7475
from dataclasses import dataclass
7576
from dataclasses import KW_ONLY
7677
78+
7779
@dataclass
7880
class User:
7981
id: int
@@ -106,6 +108,7 @@ describe("DataclassDeclaration", () => {
106108
d`
107109
from dataclasses import dataclass
108110
111+
109112
@dataclass(frozen=True, slots=True, kw_only=True)
110113
class User:
111114
id: int
@@ -141,6 +144,7 @@ describe("DataclassDeclaration", () => {
141144
d`
142145
from dataclasses import dataclass
143146
147+
144148
@dataclass(init=True, repr=False, eq=True, order=False, unsafe_hash=True, frozen=True, match_args=False, kw_only=True, slots=True, weakref_slot=False)
145149
class User:
146150
pass
@@ -178,6 +182,7 @@ describe("DataclassDeclaration", () => {
178182
d`
179183
from dataclasses import dataclass
180184
185+
181186
@dataclass(slots=True, weakref_slot=True)
182187
class User:
183188
pass
@@ -213,6 +218,7 @@ describe("DataclassDeclaration", () => {
213218
d`
214219
from dataclasses import dataclass
215220
221+
216222
@dataclass(order=True)
217223
class User:
218224
pass
@@ -398,6 +404,7 @@ describe("DataclassDeclaration", () => {
398404
d`
399405
from dataclasses import dataclass
400406
407+
401408
@dataclass(kw_only=True)
402409
class User:
403410
id: int
@@ -421,6 +428,7 @@ describe("DataclassDeclaration", () => {
421428
d`
422429
from dataclasses import dataclass
423430
431+
424432
@dataclass
425433
class User(Base):
426434
pass
@@ -503,6 +511,7 @@ describe("DataclassDeclaration", () => {
503511
d`
504512
from dataclasses import dataclass
505513
514+
506515
@dataclass(unsafe_hash=True)
507516
class User:
508517
pass
@@ -556,6 +565,7 @@ describe("DataclassDeclaration", () => {
556565
d`
557566
from dataclasses import dataclass
558567
568+
559569
@dataclass(frozen=True)
560570
class User:
561571
pass
@@ -578,6 +588,7 @@ describe("DataclassDeclaration", () => {
578588
d`
579589
from dataclasses import dataclass
580590
591+
581592
@dataclass(slots=True)
582593
class User:
583594
pass
@@ -627,6 +638,7 @@ describe("DataclassDeclaration", () => {
627638
"models.py": `
628639
from dataclasses import dataclass
629640
641+
630642
@dataclass
631643
class User:
632644
id: int
@@ -636,6 +648,7 @@ describe("DataclassDeclaration", () => {
636648
"services.py": `
637649
from models import User
638650
651+
639652
def get_user() -> User:
640653
user: User = User(1, "Alice")
641654
return user

0 commit comments

Comments
 (0)