Skip to content

Commit 261c887

Browse files
committed
[Python] Add Pythonic import path syntax for relative imports
1 parent 33c9095 commit 261c887

File tree

4 files changed

+57
-9
lines changed

4 files changed

+57
-9
lines changed

src/Fable.Cli/Pipeline.fs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,28 @@ module Python =
276276
)
277277
|> String.concat "."
278278

279+
// Convert Python-style relative module paths to file paths for resolution.
280+
// Paths starting with dots are treated as relative imports (Pythonic convention):
281+
// e.g., ".native_code" -> "./native_code.py", ".py.native_code" -> "./py/native_code.py"
282+
// "..native_code" -> "../native_code.py", "...module" -> "../../module.py"
283+
// Paths without "." prefix are treated as absolute/package imports and pass through as-is.
284+
let path =
285+
if path.Contains('/') || path.EndsWith(".py") then
286+
path
287+
elif path.StartsWith(".") then
288+
let trimmed = path.TrimStart('.')
289+
let dotCount = path.Length - trimmed.Length
290+
// Build the relative path prefix: "." -> "./", ".." -> "../", "..." -> "../../", etc.
291+
let prefix =
292+
if dotCount = 1 then
293+
"./"
294+
else
295+
String.replicate (dotCount - 1) "../"
296+
297+
prefix + trimmed.Replace(".", "/") + ".py"
298+
else
299+
path
300+
279301
if path.Contains('/') then
280302
// If inside fable-library then use relative path
281303
if isFableLibrary then

src/Fable.Core/Fable.Core.PyInterop.fs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,27 @@ let pyInstanceof (x: obj) (cons: obj) : bool = nativeOnly
5757

5858
/// Works like `ImportAttribute` (same semantics as ES6 imports).
5959
/// You can use "*" or "default" selectors.
60+
/// Path can be:
61+
/// - Relative (Pythonic): ".my_module", "..parent", "...grandparent" (resolved relative to current file)
62+
/// - Absolute/Package: "pydantic" or "collections.abc" (used as-is)
63+
/// - File path (legacy): "./my_module.py" (still supported for backwards compatibility)
6064
let import<'T> (selector: string) (path: string) : 'T = nativeOnly
6165

62-
/// F#: let myMember = importMember<string> "myModule"
66+
/// F#: let myMember = importMember<string> ".my_module"
6367
/// Py: from my_module import my_member
68+
/// Path can be:
69+
/// - Relative (Pythonic): ".my_module", "..parent", "...grandparent" (resolved relative to current file)
70+
/// - Absolute/Package: "pydantic" or "collections.abc" (used as-is)
71+
/// - File path (legacy): "./my_module.py" (still supported for backwards compatibility)
6472
/// Note the import must be immediately assigned to a value in a let binding
6573
let importMember<'T> (path: string) : 'T = nativeOnly
6674

67-
/// F#: let myLib = importAll<obj> "myLib"
68-
/// Py: from my_lib import *
75+
/// F#: let myLib = importAll<obj> ".my_lib"
76+
/// Py: import my_lib as myLib
77+
/// Path can be:
78+
/// - Relative (Pythonic): ".my_module", "..parent", "...grandparent" (resolved relative to current file)
79+
/// - Absolute/Package: "pydantic" or "collections.abc" (used as-is)
80+
/// - File path (legacy): "./my_module.py" (still supported for backwards compatibility)
6981
let importAll<'T> (path: string) : 'T = nativeOnly
7082

7183
/// Imports a file only for its side effects

src/Fable.Transforms/Python/Fable2Python.Transforms.fs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3845,7 +3845,20 @@ let transformImports (_com: IPythonCompiler) (imports: Import list) : Statement
38453845
let getIdentForImport (ctx: Context) (moduleName: string) (name: string option) =
38463846
// printfn "getIdentForImport: %A" (moduleName, name)
38473847
match name with
3848-
| None -> Path.GetFileNameWithoutExtension(moduleName)
3848+
| None ->
3849+
// Handle Python-style relative imports like ".native_code", "..native_code", "...module" etc.
3850+
// Path.GetFileNameWithoutExtension(".native_code") returns "" which is wrong
3851+
let moduleName = moduleName.TrimStart('.')
3852+
3853+
let lastPart =
3854+
match moduleName.LastIndexOf('.') with
3855+
| -1 -> moduleName
3856+
| idx -> moduleName.Substring(idx + 1)
3857+
3858+
if lastPart.Length > 0 then
3859+
lastPart
3860+
else
3861+
Path.GetFileNameWithoutExtension(moduleName)
38493862
| Some name -> name |> Naming.toPythonNaming
38503863
|> getUniqueNameInRootScope ctx
38513864
|> Identifier

tests/Python/TestPyInterop.fs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -283,26 +283,27 @@ type NativeCode =
283283

284284
[<Fact>]
285285
let ``test importAll`` () =
286-
let nativeCode: NativeCode = importAll "./native_code.py"
286+
let nativeCode: NativeCode = importAll ".native_code"
287287
3 |> nativeCode.add5 |> equal 8
288288

289+
// Deliberately using legacy relative path syntax to ensure it still works
289290
let add5 (x: int): int = importMember "./native_code.py"
290291

291292
[<Fact>]
292293
let ``test importMember`` () =
293294
add5 -1 |> equal 4
294295

295296
// Cannot use the same name as Fable will mangle the identifier
296-
let add7: int -> int = importMember "./native_code.py"
297+
let add7: int -> int = importMember ".native_code"
297298
add7 12 |> equal 19
298299

299-
let add5': int -> int = import "add5" "./native_code.py"
300+
let add5': int -> int = import "add5" ".native_code"
300301
add5' 12 |> equal 17
301302

302-
let multiply3 (x: int): int = importMember "./more_native_code.py"
303+
let multiply3 (x: int): int = importMember ".more_native_code"
303304
multiply3 9 |> equal 27
304305

305-
[<ImportAll("./native_code.py")>]
306+
[<ImportAll(".native_code")>]
306307
let nativeCode: NativeCode = nativeOnly
307308

308309
[<Fact>]

0 commit comments

Comments
 (0)