Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* [Python] Fix regression `[<Erase>]` on class types not preventing them from being emitted to Python (by @dbrattli)
* [Python] Fix regression `%A` format specifier to output booleans as lowercase `true`/`false` (by @dbrattli)

### Added

* [Python] Add Pythonic import path syntax for relative imports (`.module`, `..parent`, `...grandparent`) (by @dbrattli)

### Changed

* [Python] `[<Py.Decorate>]` now emits decorator strings verbatim and adds `importFrom` parameter for explicit import control (by @dbrattli)
Expand Down
22 changes: 22 additions & 0 deletions src/Fable.Cli/Pipeline.fs
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,28 @@ module Python =
)
|> String.concat "."

// Convert Python-style relative module paths to file paths for resolution.
// Paths starting with dots are treated as relative imports (Pythonic convention):
// e.g., ".native_code" -> "./native_code.py", ".py.native_code" -> "./py/native_code.py"
// "..native_code" -> "../native_code.py", "...module" -> "../../module.py"
// Paths without "." prefix are treated as absolute/package imports and pass through as-is.
let path =
if path.Contains('/') || path.EndsWith(".py") then
path
elif path.StartsWith(".") then
let trimmed = path.TrimStart('.')
let dotCount = path.Length - trimmed.Length
// Build the relative path prefix: "." -> "./", ".." -> "../", "..." -> "../../", etc.
let prefix =
if dotCount = 1 then
"./"
else
String.replicate (dotCount - 1) "../"

prefix + trimmed.Replace(".", "/") + ".py"
else
path

if path.Contains('/') then
// If inside fable-library then use relative path
if isFableLibrary then
Expand Down
4 changes: 4 additions & 0 deletions src/Fable.Compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* [Python] Fix regression `%A` format specifier to output booleans as lowercase `true`/`false` (by @dbrattli)

### Added

* [Python] Add Pythonic import path syntax for relative imports (`.module`, `..parent`, `...grandparent`) (by @dbrattli)

### Changed

* [Python] `[<Py.Decorate>]` now emits decorator strings verbatim and adds `importFrom` parameter for explicit import control (by @dbrattli)
Expand Down
18 changes: 15 additions & 3 deletions src/Fable.Core/Fable.Core.PyInterop.fs
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,27 @@ let pyInstanceof (x: obj) (cons: obj) : bool = nativeOnly

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

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

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

/// Imports a file only for its side effects
Expand Down
15 changes: 14 additions & 1 deletion src/Fable.Transforms/Python/Fable2Python.Transforms.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3848,7 +3848,20 @@ let transformImports (_com: IPythonCompiler) (imports: Import list) : Statement
let getIdentForImport (ctx: Context) (moduleName: string) (name: string option) =
// printfn "getIdentForImport: %A" (moduleName, name)
match name with
| None -> Path.GetFileNameWithoutExtension(moduleName)
| None ->
// Handle Python-style relative imports like ".native_code", "..native_code", "...module" etc.
// Path.GetFileNameWithoutExtension(".native_code") returns "" which is wrong
let moduleName = moduleName.TrimStart('.')

let lastPart =
match moduleName.LastIndexOf('.') with
| -1 -> moduleName
| idx -> moduleName.Substring(idx + 1)

if lastPart.Length > 0 then
lastPart
else
Path.GetFileNameWithoutExtension(moduleName)
| Some name -> name |> Naming.toPythonNaming
|> getUniqueNameInRootScope ctx
|> Identifier
Expand Down
11 changes: 6 additions & 5 deletions tests/Python/TestPyInterop.fs
Original file line number Diff line number Diff line change
Expand Up @@ -283,26 +283,27 @@ type NativeCode =

[<Fact>]
let ``test importAll`` () =
let nativeCode: NativeCode = importAll "./py/native_code.py"
let nativeCode: NativeCode = importAll ".py.native_code"
3 |> nativeCode.add5 |> equal 8

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

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

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

let add5': int -> int = import "add5" "./py/native_code.py"
let add5': int -> int = import "add5" ".py.native_code"
add5' 12 |> equal 17

let multiply3 (x: int): int = importMember "./py/more_native_code.py"
let multiply3 (x: int): int = importMember ".py.more_native_code"
multiply3 9 |> equal 27

[<ImportAll("./py/native_code.py")>]
[<ImportAll(".py.native_code")>]
let nativeCode: NativeCode = nativeOnly

[<Fact>]
Expand Down
Loading