From 2ccc1bcf8378b75aa6a3c951bc909e15edc7a580 Mon Sep 17 00:00:00 2001 From: JamesWrigley Date: Sat, 21 Mar 2026 00:46:17 +0100 Subject: [PATCH] Implement a pyimport string macro This helps usability by automatically converting Python import statements into their Julia equivalents. --- docs/src/pythoncall-reference.md | 1 + src/API/exports.jl | 1 + src/API/macros.jl | 1 + src/Core/Core.jl | 1 + src/Core/builtins.jl | 140 +++++++++++++++++++++++++++++++ test/Core.jl | 73 ++++++++++++++++ 6 files changed, 217 insertions(+) diff --git a/docs/src/pythoncall-reference.md b/docs/src/pythoncall-reference.md index f09d5f12..04c32378 100644 --- a/docs/src/pythoncall-reference.md +++ b/docs/src/pythoncall-reference.md @@ -55,6 +55,7 @@ pyhasitem pyhash pyhelp pyimport +@pyimport_str pyin pyis pyisinstance diff --git a/src/API/exports.jl b/src/API/exports.jl index 3d476182..cd07b174 100644 --- a/src/API/exports.jl +++ b/src/API/exports.jl @@ -50,6 +50,7 @@ export pyilshift export pyimatmul export pyimod export pyimport +export @pyimport_str export pyimul export pyin export pyindex diff --git a/src/API/macros.jl b/src/API/macros.jl index c74d191c..3e59ce0a 100644 --- a/src/API/macros.jl +++ b/src/API/macros.jl @@ -2,6 +2,7 @@ macro pyconst end macro pyeval end macro pyexec end +macro pyimport_str end # Convert macro pyconvert end diff --git a/src/Core/Core.jl b/src/Core/Core.jl index 09278762..5eed5d9d 100644 --- a/src/Core/Core.jl +++ b/src/Core/Core.jl @@ -34,6 +34,7 @@ import ..PythonCall: @pyconst, @pyeval, @pyexec, + @pyimport_str, getptr, ispy, Py, diff --git a/src/Core/builtins.jl b/src/Core/builtins.jl index 7e772662..39f65c5c 100644 --- a/src/Core/builtins.jl +++ b/src/Core/builtins.jl @@ -1454,6 +1454,8 @@ end Import a module `m`, or an attribute `k`, or a tuple of attributes. If several arguments are given, return the results of importing each one in a tuple. + +See also: [`@pyimport_str`](@ref). """ pyimport(m) = pynew(errcheck(@autopy m C.PyImport_Import(m_))) pyimport((m, k)::Pair) = (m_ = pyimport(m); k_ = pygetattr(m_, k); pydel!(m_); k_) @@ -1461,6 +1463,144 @@ pyimport((m, ks)::Pair{<:Any,<:Tuple}) = (m_ = pyimport(m); ks_ = map(k -> pygetattr(m_, k), ks); pydel!(m_); ks_) pyimport(m1, m2, ms...) = map(pyimport, (m1, m2, ms...)) +""" + pyimport"import numpy" + pyimport"import numpy as np" + pyimport"from numpy import array" + pyimport"from numpy import array as arr" + pyimport"from numpy import array, zeros" + pyimport"from numpy import array as arr, zeros as z" + pyimport"import numpy, scipy" + +String macro that parses Python import syntax and generates equivalent Julia +code using [`pyimport()`](@ref). Each form generates `const` bindings in the +caller's scope. + +Multiple lines are supported: +```julia +pyimport\"\"\" +import numpy as np +from scipy import linalg, optimize +from os.path import join as pathjoin +\"\"\" + +# Converted to: +const np = pyimport("numpy") +const linalg = pyimport("scipy" => "linalg") +const optimize = pyimport("scipy" => "optimize") +const pathjoin = pyimport("os.path" => "join") +``` + +But multiline or grouped import statements are not supported: +```julia +# These will throw an error +pyimport\"\"\" +from sys import (path, + version) +from sys import path, \ + version +\"\"\" +``` + +Relative imports are also not currently supported. +""" +macro pyimport_str(s) + esc(_pyimport_parse(s)) +end + +function _pyimport_parse(s::AbstractString) + lines = filter(!isempty, strip.(split(s, "\n"))) + isempty(lines) && throw(ArgumentError("pyimport: empty import string")) + + # Check for line continuations + for line in lines + if contains(line, '\\') || contains(line, '(') + throw(ArgumentError("pyimport: line continuation with '\\' or '(' is not supported: $line")) + end + end + + if length(lines) == 1 + _pyimport_parse_line(lines[1]) + else + Expr(:block, [_pyimport_parse_line(line) for line in lines]...) + end +end + +function _pyimport_parse_line(s::AbstractString) + if startswith(s, "from ") + _pyimport_parse_from(s) + elseif startswith(s, "import ") + _pyimport_parse_import(s) + else + throw(ArgumentError("pyimport: expected 'import ...' or 'from ... import ...', got: $s")) + end +end + +function _pyimport_parse_import(s::AbstractString) + rest = strip(chopprefix(s, "import")) + if isempty(rest) + throw(ArgumentError("pyimport: missing module name after 'import'")) + end + + parts = split(rest, ","; keepempty=false) + exprs = Expr[] + for part in parts + part = strip(part) + + # Check if there's an `as` clause + m = match(r"^(\S+)\s+as\s+(\S+)$", part) + if !isnothing(m) + # `import numpy.linalg as la` binds la to the linalg submodule + modname = m[1] + alias = Symbol(m[2]) + push!(exprs, :(const $alias = pyimport($modname))) + else + modname = part + # `import numpy.linalg` binds numpy (top-level package) + # but first imports the submodule to ensure it's loaded + dotparts = split(modname, ".") + alias = Symbol(dotparts[1]) + if length(dotparts) == 1 + push!(exprs, :(const $alias = pyimport($modname))) + else + toplevel = dotparts[1] + push!(exprs, :(const $alias = (pyimport($modname); pyimport($toplevel)))) + end + end + end + + length(exprs) == 1 ? exprs[1] : Expr(:block, exprs...) +end + +function _pyimport_parse_from(s::AbstractString) + m = match(r"^from\s+(\S+)\s+import\s+(.+)$", s) + if isnothing(m) + throw(ArgumentError("pyimport: invalid from-import syntax: $s")) + end + + modname = m[1] + rest = strip(m[2]) + if rest == "*" + throw(ArgumentError("pyimport: wildcard import 'from $modname import *' is not supported")) + end + + parts = split(rest, ","; keepempty=false) + exprs = Expr[] + for part in parts + part = strip(part) + m2 = match(r"^(\S+)\s+as\s+(\S+)$", part) + name, alias = if !isnothing(m2) + m2[1], Symbol(m2[2]) + else + part, Symbol(part) + end + + push!(exprs, :(const $alias = pyimport($modname => $name))) + end + + length(exprs) == 1 ? exprs[1] : Expr(:block, exprs...) +end + ### builtins not covered elsewhere """ diff --git a/test/Core.jl b/test/Core.jl index 8f2e428a..8eea018c 100644 --- a/test/Core.jl +++ b/test/Core.jl @@ -428,6 +428,79 @@ end @test pyis(verpath[2], path) end +@testset "pyimport_str" begin + parse = PythonCall.Core._pyimport_parse + + # Import a module + @test parse("import sys") == :(const sys = pyimport("sys")) + + # Import module as an alias + @test parse("import sys as system") == :(const system = pyimport("sys")) + + # Importing a submodule binds the top-level package + ex = parse("import os.path") |> Base.remove_linenums! + @test ex == :(const os = begin + pyimport("os.path") + pyimport("os") + end) |> Base.remove_linenums! + + # Importing a submodule as an alias binds the submodule + @test parse("import os.path as osp") == :(const osp = pyimport("os.path")) + + # import multiple modules + ex = parse("import sys, os") |> Base.remove_linenums! + @test ex == quote + const sys = pyimport("sys") + const os = pyimport("os") + end |> Base.remove_linenums! + + # from module import name + @test parse("from sys import path") == :(const path = pyimport("sys" => "path")) + + # from module import name as alias + @test parse("from sys import path as p") == :(const p = pyimport("sys" => "path")) + + # from module import multiple names + ex = parse("from sys import path, version") |> Base.remove_linenums! + @test ex == quote + const path = pyimport("sys" => "path") + const version = pyimport("sys" => "version") + end |> Base.remove_linenums! + + # from module import multiple names with aliases + ex = parse("from sys import path as p, version as v") |> Base.remove_linenums! + @test ex == quote + const p = pyimport("sys" => "path") + const v = pyimport("sys" => "version") + end |> Base.remove_linenums! + + # from dotted module import name + @test parse("from os.path import join") == :(const join = pyimport("os.path" => "join")) + + # Multiple lines, with extra whitespace + ex = parse("import sys \n from os import getcwd ") |> Base.remove_linenums! + @test ex == quote + const sys = pyimport("sys") + const getcwd = pyimport("os" => "getcwd") + end |> Base.remove_linenums! + + # Error cases + @test_throws ArgumentError parse("") + @test_throws ArgumentError parse("not an import") + @test_throws ArgumentError parse("from os import *") + @test_throws ArgumentError parse("import") + + # Line continuations are not supported + @test_throws ArgumentError parse("from os import \\\n path, getcwd") + @test_throws ArgumentError parse("from os import (\n path, getcwd\n)") + + # smoke test: actually run the macro + m = Module() + @eval m using PythonCall + @eval m pyimport"import sys" + @test pyeq(Bool, m.sys.__name__, "sys") +end + @testitem "consts" begin @test pybuiltins.None isa Py @test pystr(String, pybuiltins.None) == "None"