diff --git a/Project.toml b/Project.toml index 409337c9..4739c376 100644 --- a/Project.toml +++ b/Project.toml @@ -2,6 +2,9 @@ name = "IJulia" uuid = "7073ff75-c697-5162-941a-fcdaad2a7d2a" version = "1.32.0" +[apps] +ijulia = {} + [deps] Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" Conda = "8f4d0f93-b110-5947-807f-2305c1781a2d" diff --git a/README.md b/README.md index 35df1678..50124eb5 100644 --- a/README.md +++ b/README.md @@ -40,15 +40,24 @@ Note that `IJulia` should generally be installed in Julia's global package envir install a custom kernel that specifies a particular environment. Alternatively, you can have IJulia create and manage its own Python/Jupyter installation. -To do this, type the following in Julia, at the `julia>` prompt: +**With Julia 1.12+**, you can launch IJulia directly from the command line. First install the app entry point: +```julia +pkg> app add IJulia +``` +Then run: +```bash +ijulia --help +``` + +**With Julia 1.10-1.11**, type the following in Julia, at the `julia>` prompt: ```julia using IJulia notebook() ``` to launch the IJulia notebook in your browser. -The first time you run `notebook()`, it will prompt you +The first time you run `notebook()` or `ijulia`, it will prompt you for whether it should install Jupyter. Hit enter to have it use the [Conda.jl](https://github.com/Luthaf/Conda.jl) package to install a minimal Python+Jupyter distribution (via diff --git a/docs/src/manual/running.md b/docs/src/manual/running.md index cdfa8291..a893d36d 100644 --- a/docs/src/manual/running.md +++ b/docs/src/manual/running.md @@ -1,6 +1,47 @@ # Running IJulia +## Command-Line Launcher + +Starting with Julia 1.12, you can launch IJulia directly from the command line using the `ijulia` command. First, you need to install the app entry point (see [Julia app documentation](https://pkgdocs.julialang.org/v1/apps/)) by running in the Julia REPL: + +```julia +pkg> app add IJulia +``` + +You may need to add `~/.julia/bin` to your PATH if it's not already there. + +Then you can use the `ijulia` command from your terminal: + +```bash +ijulia +``` + +This provides a convenient way to launch Jupyter without starting a Julia REPL session first. The launcher supports the following options: + +- `ijulia` or `ijulia notebook` - Launch Jupyter Notebook (default) +- `ijulia lab` - Launch JupyterLab +- `--dir=PATH` - Launch in the specified directory (default: home directory) +- `--port=N` - Open on the specified port number +- `--detached` - Run in detached mode (continues after Julia exits) +- `--verbose` - Enable verbose output from Jupyter +- `--help, -h` - Show help message + +Any additional arguments are passed directly to the jupyter command. + +**Examples:** +```bash +# Launch notebook in a specific directory +ijulia --dir=/path/to/project + +# Launch JupyterLab on a specific port +ijulia lab --port=8888 --detached + +# Pass additional arguments to Jupyter +ijulia --no-browser +``` + + ## Running the IJulia Notebook If you are comfortable managing your own Python/Jupyter installation, you can just run `jupyter notebook` yourself in a terminal. To simplify installation, however, you can alternatively type the following in Julia, at the `julia>` prompt: diff --git a/src/IJulia.jl b/src/IJulia.jl index 233b7935..35c98f26 100644 --- a/src/IJulia.jl +++ b/src/IJulia.jl @@ -584,4 +584,90 @@ include("inline.jl") include("kernel.jl") include("precompile.jl") +####################################################################### +# App entry point for `ijulia` command + +@static if VERSION >= v"1.11" +function (@main)(ARGS; notebook_cmd::Function = notebook, jupyterlab_cmd::Function = jupyterlab) + # Show help if help flag + if !isempty(ARGS) && ARGS[1] in ("--help", "-h", "help") + println(""" + IJulia Launcher + + Usage: ijulia [command] [OPTIONS] [JUPYTER_ARGS...] + + Commands: + notebook Launch Jupyter notebook (default) + lab Launch JupyterLab + + Options: + --dir=PATH Launch in the specified directory (default: home directory) + --port=N Open on the specified port number + --detached Run in detached mode (continues after Julia exits) + --verbose Enable verbose output from Jupyter + --help, -h Show this help message + + Any additional arguments are passed directly to the jupyter command. + + Examples: + ijulia --dir=/path/to/project --port=8888 + ijulia notebook --dir=/path/to/project --port=8888 + ijulia lab --detached --verbose + ijulia --no-browser + """) + return 0 + end + + # Parse subcommand (default to "lab") + subcommand = "lab" + args_start = 1 + if !isempty(ARGS) + first_arg = ARGS[1] + if first_arg in ("notebook", "lab") + subcommand = first_arg + args_start = 2 + elseif !startswith(first_arg, "--") + # First arg looks like a subcommand but isn't valid + @error "Unknown subcommand: $first_arg. Use 'notebook' or 'lab'." + return 1 + end + end + + # Parse options + dir = homedir() + port = nothing + detached = false + verbose = false + extra_args = String[] + + for arg in ARGS[args_start:end] + if startswith(arg, "--dir=") + dir = arg[7:end] + elseif startswith(arg, "--port=") + port = parse(Int, arg[8:end]) + elseif arg == "--detached" + detached = true + elseif arg == "--verbose" + verbose = true + else + push!(extra_args, arg) + end + end + + # Launch the appropriate command + launch_func = subcommand == "notebook" ? notebook_cmd : jupyterlab_cmd + try + launch_func(Cmd(extra_args); dir, detached, port, verbose) + return 0 + catch e + if e isa InterruptException + return 0 + else + @error "Failed to launch $subcommand" exception=(e, catch_backtrace()) + return 1 + end + end +end +end # if VERSION + end # IJulia diff --git a/test/main.jl b/test/main.jl new file mode 100644 index 00000000..79fe8c2f --- /dev/null +++ b/test/main.jl @@ -0,0 +1,102 @@ +using Test +import IJulia + +@testset "@main app entry point" begin + # Test help output + @testset "help" begin + # Capture stdout using a pipe + old_stdout = stdout + rd, wr = redirect_stdout() + + ret = IJulia.main(["--help"]) + + # Restore stdout and read the captured output + redirect_stdout(old_stdout) + close(wr) + help_text = read(rd, String) + close(rd) + + @test ret == 0 + @test occursin("IJulia Launcher", help_text) + @test occursin("notebook", help_text) + @test occursin("lab", help_text) + @test occursin("--dir=PATH", help_text) + @test occursin("--port=N", help_text) + @test occursin("--detached", help_text) + @test occursin("--verbose", help_text) + end + + # Test invalid subcommand + @testset "invalid subcommand" begin + ret = @test_logs (:error, r"Unknown subcommand.*Use 'notebook' or 'lab'") IJulia.main(["invalid"]) + @test ret == 1 + end + + # Test argument parsing without actually launching + @testset "argument parsing" begin + # Test default subcommand (lab) + called_with = nothing + mock_jupyterlab = function(args=``; dir=homedir(), detached=false, port=nothing, verbose=false) + called_with = (; args, dir, detached, port, verbose) + return nothing + end + + ret = IJulia.main(["--dir=/test", "--port=9999", "--detached", "--verbose", "--no-browser"]; jupyterlab_cmd=mock_jupyterlab) + @test ret == 0 + @test !isnothing(called_with) + @test called_with.dir == "/test" + @test called_with.port == 9999 + @test called_with.detached == true + @test called_with.verbose == true + @test "--no-browser" in called_with.args + + # Test explicit notebook subcommand + called_with = nothing + mock_notebook = function(args=``; dir=homedir(), detached=false, port=nothing, verbose=false) + called_with = (; args, dir, detached, port, verbose) + return nothing + end + + ret = IJulia.main(["notebook", "--dir=/test", "--port=9999", "--detached", "--verbose", "--no-browser"]; notebook_cmd=mock_notebook) + @test ret == 0 + @test !isnothing(called_with) + @test called_with.dir == "/test" + @test called_with.port == 9999 + @test called_with.detached == true + @test called_with.verbose == true + @test "--no-browser" in called_with.args + + # Test lab subcommand + called_with = nothing + mock_jupyterlab = function(args=``; dir=homedir(), detached=false, port=nothing, verbose=false) + called_with = (; args, dir, detached, port, verbose) + return nothing + end + + ret = IJulia.main(["lab", "--dir=/lab", "--verbose"]; jupyterlab_cmd=mock_jupyterlab) + @test ret == 0 + @test !isnothing(called_with) + @test called_with.dir == "/lab" + @test called_with.verbose == true + end + + # Test InterruptException handling + @testset "interrupt handling" begin + mock_notebook = function(args=``; kwargs...) + throw(InterruptException()) + end + + ret = IJulia.main(["notebook"]; notebook_cmd=mock_notebook) + @test ret == 0 # InterruptException should return 0 + end + + # Test error handling + @testset "error handling" begin + mock_notebook = function(args=``; kwargs...) + error("Test error") + end + + ret = @test_logs (:error, r"Failed to launch") IJulia.main(["notebook"]; notebook_cmd=mock_notebook) + @test ret == 1 # Errors should return 1 + end +end diff --git a/test/runtests.jl b/test/runtests.jl index b33dff19..a2ec85e7 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -7,6 +7,10 @@ const TEST_FILES = [ "inline.jl", "completion.jl", "jsonx.jl" ] +if VERSION >= v"1.11" + push!(TEST_FILES, "main.jl") +end + for file in TEST_FILES println(file) include(file)