From f90fd72c092f00f1d091a8d7f8f2a8958dd0c4a6 Mon Sep 17 00:00:00 2001 From: Keno Fischer Date: Sun, 8 Oct 2023 19:10:30 -0400 Subject: [PATCH] Main entrypoint take 3 - revenge of the macro (#51435) As they say, if at first you don't succeed, try again, then try again, add an extra layer of indirection and take a little bit of spice from every other idea and you've got yourself a wedding cake. Or something like that, I don't know - at times it felt like this cake was getting a bit burnt. Where was I? Ah yes. This is the third edition of the main saga (#50974, #51417). In this version, the spelling that we'd expect for the main use case is: ``` function (@main)(ARGS) println("Hello World") end ``` This syntax was originally proposed by `@vtjnash`. However, the semantics here are slightly different. `@main` simply expands to `main`, so the above is equivalent to: ``` function main(ARGS) println("Hello World") end @main ``` So `@main` is simply a marker that the `main` binding has special behavior. This way, all the niceceties of import/export, etc. can still be used as in the original `Main.main` proposal, but there is an explicit opt-in and feature detect macro to avoid executing this when people do not expect. Additionally, there is a smooth upgrade path if we decide to automatically enable `Main.main` in Julia 2.0. --- NEWS.md | 7 ++ base/client.jl | 125 ++++++++++++++++++++--- base/exports.jl | 4 +- doc/src/manual/command-line-interface.md | 67 ++++++++++++ src/jlapi.c | 8 +- test/cmdlineargs.jl | 12 +++ 6 files changed, 205 insertions(+), 18 deletions(-) diff --git a/NEWS.md b/NEWS.md index cd68e8c5882b5..46ba7c4b85118 100644 --- a/NEWS.md +++ b/NEWS.md @@ -21,6 +21,13 @@ Compiler/Runtime improvements Command-line option changes --------------------------- +* The entry point for Julia has been standardized to `Main.main(ARGS)`. This must be explicitly opted into using the `@main` macro +(see the docstring for futher details). When opted-in, and julia is invoked to run a script or expression +(i.e. using `julia script.jl` or `julia -e expr`), julia will subsequently run the `Main.main` function automatically. +This is intended to unify script and compilation workflows, where code loading may happen +in the compiler and execution of `Main.main` may happen in the resulting executable. For interactive use, there is no semantic +difference between defining a `main` function and executing the code directly at the end of the script. ([50974]) + Multi-threading changes ----------------------- diff --git a/base/client.jl b/base/client.jl index 35abb26c7ff43..79541f2c106b1 100644 --- a/base/client.jl +++ b/base/client.jl @@ -228,11 +228,8 @@ incomplete_tag(exc::Meta.ParseError) = incomplete_tag(exc.detail) cmd_suppresses_program(cmd) = cmd in ('e', 'E') function exec_options(opts) - quiet = (opts.quiet != 0) startup = (opts.startupfile != 2) - history_file = (opts.historyfile != 0) - color_set = (opts.color != 0) # --color!=auto - global have_color = color_set ? (opts.color == 1) : nothing # --color=on + global have_color = (opts.color != 0) ? (opts.color == 1) : nothing # --color=on global is_interactive = (opts.isinteractive != 0) # pre-process command line argument list @@ -323,15 +320,8 @@ function exec_options(opts) end end end - if repl || is_interactive::Bool - b = opts.banner - auto = b == -1 - banner = b == 0 || (auto && !interactiveinput) ? :no : - b == 1 || (auto && interactiveinput) ? :yes : - :short # b == 2 - run_main_repl(interactiveinput, quiet, banner, history_file, color_set) - end - nothing + + return repl end function _global_julia_startup_file() @@ -536,6 +526,13 @@ definition of `eval`, which evaluates expressions in that module. """ MainInclude.eval +function should_use_main_entrypoint() + isdefined(Main, :main) || return false + M_binding_owner = Base.binding_module(Main, :main) + (isdefined(M_binding_owner, Symbol("#__main_is_entrypoint__#")) && M_binding_owner.var"#__main_is_entrypoint__#") || return false + return true +end + """ include([mapexpr::Function,] path::AbstractString) @@ -565,13 +562,111 @@ function _start() append!(ARGS, Core.ARGS) # clear any postoutput hooks that were saved in the sysimage empty!(Base.postoutput_hooks) + local ret = 0 try - exec_options(JLOptions()) + repl_was_requested = exec_options(JLOptions()) + if should_use_main_entrypoint() && !is_interactive + if Core.Compiler.generating_output() + precompile(Main.main, (typeof(ARGS),)) + else + ret = invokelatest(Main.main, ARGS) + end + elseif (repl_was_requested || is_interactive) + # Run the Base `main`, which will either load the REPL stdlib + # or run the fallback REPL + ret = repl_main(ARGS) + end + ret === nothing && (ret = 0) + ret = Cint(ret) catch + ret = Cint(1) invokelatest(display_error, scrub_repl_backtrace(current_exceptions())) - exit(1) end if is_interactive && get(stdout, :color, false) print(color_normal) end + return ret +end + +function repl_main(_) + opts = Base.JLOptions() + interactiveinput = isa(stdin, Base.TTY) + b = opts.banner + auto = b == -1 + banner = b == 0 || (auto && !interactiveinput) ? :no : + b == 1 || (auto && interactiveinput) ? :yes : + :short # b == 2 + + quiet = (opts.quiet != 0) + history_file = (opts.historyfile != 0) + color_set = (opts.color != 0) # --color!=auto + return run_main_repl(interactiveinput, quiet, banner, history_file, color_set) +end + +""" + @main + +This macro is used to mark that the binding `main` in the current module is considered an +entrypoint. The precise semantics of the entrypoint depend on the CLI driver. + +In the `julia` driver, if `Main.main` is marked as an entrypoint, it will be automatically called upon +the completion of script execution. + +The `@main` macro may be used standalone or as part of the function definition, though in the latter +case, parenthese are required. In particular, the following are equivalent: + +``` +function (@main)(ARGS) + println("Hello World") +end +``` + +``` +function main(ARGS) +end +@main +``` + +## Detailed semantics + +The entrypoint semantics attach to the owner of the binding owner. In particular, if a marked entrypoint is +imported into `Main`, it will be treated as an entrypoint in `Main`: + +``` +module MyApp + export main + (@main)(ARGS) = println("Hello World") +end +using .MyApp +# `julia` Will execute MyApp.main at the conclusion of script execution +``` + +Note that in particular, the semantics do not attach to the method +or the name: +``` +module MyApp + (@main)(ARGS) = println("Hello World") +end +const main = MyApp.main +# `julia` Will *NOT* execute MyApp.main unless there is a separate `@main` annotation in `Main` + +!!! compat "Julia 1.11" + This macro is new in Julia 1.11. At present, the precise semantics of `@main` are still subject to change. +``` +""" +macro main(args...) + if !isempty(args) + error("USAGE: `@main` is expected to be used as `(@main)` without macro arguments.") + end + if isdefined(__module__, :main) + if Base.binding_module(__module__, :main) !== __module__ + error("USAGE: Symbol `main` is already a resolved import in module $(__module__). `@main` must be used in the defining module.") + end + end + Core.eval(__module__, quote + # Force the binding to resolve to this module + global main + global var"#__main_is_entrypoint__#"::Bool = true + end) + esc(:main) end diff --git a/base/exports.jl b/base/exports.jl index 1f0b5c1b63e06..5eff50fc4b2e0 100644 --- a/base/exports.jl +++ b/base/exports.jl @@ -1067,7 +1067,9 @@ export @goto, @view, @views, - @static + @static, + + @main # TODO: use normal syntax once JuliaSyntax.jl becomes available at this point in bootstrapping eval(Expr(:public, diff --git a/doc/src/manual/command-line-interface.md b/doc/src/manual/command-line-interface.md index acd655a069733..7427cfc4a950a 100644 --- a/doc/src/manual/command-line-interface.md +++ b/doc/src/manual/command-line-interface.md @@ -39,6 +39,73 @@ $ julia --color=yes -O -- script.jl arg1 arg2.. See also [Scripting](@ref man-scripting) for more information on writing Julia scripts. +## The `Main.main` entry point + +As of Julia, 1.11, Base export a special macro `@main`. This macro simply expands to the symbol `main`, +but at the conclusion of executing a script or expression, `julia` will attempt to execute the function +`Main.main(ARGS)` if such a function has been defined and this behavior was opted into +using the `@main macro`. This feature is intended to aid in the unification +of compiled and interactive workflows. In compiled workflows, loading the code that defines the `main` +function may be spatially and temporally separated from the invocation. However, for interactive workflows, +the behavior is equivalent to explicitly calling `exit(main(ARGS))` at the end of the evaluated script or +expression. + +!!! compat "Julia 1.11" + The special entry point `Main.main` was added in Julia 1.11. For compatibility with prior julia versions, + add an explicit `@isdefined(var"@main") ? (@main) : exit(main(ARGS))` at the end of your scripts. + +To see this feature in action, consider the following definition, which will execute the print function despite there being no explicit call to `main`: + +``` +$ julia -e '(@main)(ARGS) = println("Hello World!")' +Hello World! +$ +``` + +Only the `main` binding in the `Main`, module has this special behavior and only if +the macro `@main` was used within the defining module. + +For example, using `hello` instead of `main` will result not result in the `hello` function executing: + +``` +$ julia -e 'hello(ARGS) = println("Hello World!")' +$ +``` + +and neither will a plain definition of `main`: +``` +$ julia -e 'main(ARGS) = println("Hello World!")' +$ +``` + +However, the opt-in need not occur at definition time: +$ julia -e 'main(ARGS) = println("Hello World!"); @main' +Hello World! +$ + +The `main` binding may be imported from a package. A hello package defined as + +``` +module Hello + +export main +(@main)(ARGS) = println("Hello from the package!") + +end +``` + +may be used as: + +``` +$ julia -e 'using Hello' +Hello from the package! +$ julia -e 'import Hello' # N.B.: Execution depends on the binding not whether the package is loaded +$ +``` + +However, note that the current best practice recommendation is to not mix application and reusable library +code in the same package. Helper applications may be distributed as separate pacakges or as scripts with +separate `main` entry points in a package's `bin` folder. ## Parallel mode diff --git a/src/jlapi.c b/src/jlapi.c index 0dffaac627288..29be3b9e6179c 100644 --- a/src/jlapi.c +++ b/src/jlapi.c @@ -576,16 +576,20 @@ static NOINLINE int true_main(int argc, char *argv[]) if (start_client) { jl_task_t *ct = jl_current_task; + int ret = 1; JL_TRY { size_t last_age = ct->world_age; ct->world_age = jl_get_world_counter(); - jl_apply(&start_client, 1); + jl_value_t *r = jl_apply(&start_client, 1); + if (jl_typeof(r) != (jl_value_t*)jl_int32_type) + jl_type_error("typeassert", (jl_value_t*)jl_int32_type, r); + ret = jl_unbox_int32(r); ct->world_age = last_age; } JL_CATCH { jl_no_exc_handler(jl_current_exception(), ct); } - return 0; + return ret; } // run program if specified, otherwise enter REPL diff --git a/test/cmdlineargs.jl b/test/cmdlineargs.jl index b51d95669975c..2e3598d0a1597 100644 --- a/test/cmdlineargs.jl +++ b/test/cmdlineargs.jl @@ -982,3 +982,15 @@ end #heap-size-hint, we reserve 250 MB for non GC memory (llvm, etc.) @test readchomp(`$(Base.julia_cmd()) --startup-file=no --heap-size-hint=500M -e "println(@ccall jl_gc_get_max_memory()::UInt64)"`) == "$((500-250)*1024*1024)" end + +## `Main.main` entrypoint + +# Basic usage +@test readchomp(`$(Base.julia_cmd()) -e '(@main)(ARGS) = println("hello")'`) == "hello" + +# Test ARGS with -e +@test readchomp(`$(Base.julia_cmd()) -e '(@main)(ARGS) = println(ARGS)' a b`) == repr(["a", "b"]) + +# Test import from module +@test readchomp(`$(Base.julia_cmd()) -e 'module Hello; export main; (@main)(ARGS) = println("hello"); end; using .Hello'`) == "hello" +@test readchomp(`$(Base.julia_cmd()) -e 'module Hello; export main; (@main)(ARGS) = println("hello"); end; import .Hello'`) == ""