Skip to content

Commit

Permalink
add timeout to prompt
Browse files Browse the repository at this point in the history
  • Loading branch information
IanButterworth committed Oct 31, 2024
1 parent da74ef1 commit 4a215d4
Show file tree
Hide file tree
Showing 2 changed files with 141 additions and 10 deletions.
119 changes: 109 additions & 10 deletions base/util.jl
Original file line number Diff line number Diff line change
Expand Up @@ -383,36 +383,135 @@ end
getpass(prompt::AbstractString; with_suffix::Bool=true) = getpass(stdin, stdout, prompt; with_suffix)

"""
prompt(message; default="") -> Union{String, Nothing}
prompt(message; default="", timeout=Real) -> Union{String, Nothing}
Displays the `message` then waits for user input. Input is terminated when a newline (\\n)
is encountered or EOF (^D) character is entered on a blank line. If a `default` is provided
then the user can enter just a newline character to select the `default`.
See also `Base.winprompt` (for Windows) and `Base.getpass` for secure entry of passwords.
For TTY input streams, a `timeout` in seconds greater than 0 can be provided, after which
the default will be returned.
# Examples
For instance, the user enters a value and hits `return`:
```julia
julia> Base.prompt("Proceed? y/n"; default="n", timeout=5)
Proceed? y/n [n] timeout 5s: y
"y"
```
```julia-repl
julia> your_name = Base.prompt("Enter your name");
Enter your name: Logan
The user hits `return` alone to select the default:
```julia
julia> Base.prompt("Proceed? y/n"; default="n", timeout=5)
Proceed? y/n [n] timeout 5s:
"n"
```
julia> your_name
"Logan"
The user doesn't input and hit `return` before the timeout, so default returns:
```julia
julia> Base.prompt("Proceed? y/n"; default="n", timeout=5)
Proceed? y/n [n] timeout 5s: timed out
"n"
```
See also `Base.winprompt` (for Windows) and `Base.getpass` for secure entry of passwords.
```
"""
function prompt(input::IO, output::IO, message::AbstractString; default::AbstractString="")
function prompt(input::IO, output::IO, message::AbstractString; default::AbstractString="", timeout::Real = 0)
# timeout is ignored for non-TTY input
msg = !isempty(default) ? "$message [$default]: " : "$message: "
print(output, msg)
uinput = readline(input, keep=true)
isempty(uinput) && return nothing # Encountered an EOF
uinput = chomp(uinput)
isempty(uinput) ? default : uinput
end
function prompt(input::TTY, output::IO, message::AbstractString; default::AbstractString="", timeout::Real = 0)
in_stat_before = input.status
timed_out = false
start_msg = isempty(default) ? "$message" : "$message [$default]"
timeout_default_timer = if timeout > 0
msg = string(start_msg, " timeout ", timeout, "s: ")
Timer(timeout) do t
lock(input.cond)
timed_out = true
input.status = StatusEOF
notify(input.cond)
unlock(input.cond)
end
else
msg = string(start_msg, ": ")
nothing
end
print(output, msg)
timeout_message_timer = nothing
t_start = time()
if timeout > 0
timeout_message_timer = Timer(0; interval=1) do t
if isopen(timeout_default_timer)
print(output, "\e[$(textwidth(msg))D\e[0J") # clear previous message
t = ceil(Int, timeout - (time() - t_start))
msg = string(start_msg, " timeout ", t, "s: ")
print(output, msg)
end
end
end
# wait on input but don't consume so we can stop timeout timers while maintaining terminal edit behavior
# FIXME: with this the first char entered isn't editable in the terminal
uinput = ""
_return = false
_interrupt = false
with_raw_tty(input) do
wait_readnb(input, 1)
try
c = read(input, Char)
print(output, "\e[$(textwidth(msg))D\e[0J") # clear previous message
msg = string(start_msg, ": ")
print(output, msg)
if c == '\r'
_return = true
elseif c == '\x03'
_interrupt = true
else
print(output, c)
uinput = string(c)
end
catch e
e isa EOFError || rethrow()
end
end
timeout_default_timer isa Timer && close(timeout_default_timer)
timeout_message_timer isa Timer && close(timeout_message_timer)
if !_interrupt
if _return
println(output)
return default
end
try
uinput *= readline(input, keep=true)
catch e
e isa InterruptException || rethrow()
_interrupt = true
end
end
if _interrupt
println(output)
error("Prompt interrupted")
end
if timed_out
print(output, "\e[$(textwidth(msg))D\e[0J") # clear previous message
println(output, string(start_msg, " timed out:"))
input.status = in_stat_before
return default
else
isempty(uinput) && return nothing # Encountered an EOF
uinput = chomp(uinput)
return isempty(uinput) ? default : uinput
end
end

# allow new prompt methods to be defined if stdin has been
# redirected to some custom stream, e.g. in IJulia.
prompt(message::AbstractString; default::AbstractString="") = prompt(stdin, stdout, message, default=default)
prompt(message::AbstractString; kwargs...) = prompt(stdin, stdout, message; kwargs...)

# Windows authentication prompt
if Sys.iswindows()
Expand Down
32 changes: 32 additions & 0 deletions test/misc.jl
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,38 @@ let buf = IOBuffer()
@test Base.prompt(IOBuffer("blah\n"), buf, "baz", default="foobar") == "blah"
end

# stdin is unavailable on the workers. Run test on master.
ret = Core.eval(Main, quote
remotecall_fetch(1) do
let buf = IOBuffer()
original_stdin = stdin
(rd, wr) = redirect_stdin()
vals = String[]
push!(vals, Base.prompt(rd, buf, "baz", default="foobar", timeout = 1))
push!(vals, String(take!(buf)))
push!(vals, Base.prompt(rd, buf, "baz", default="foobar", timeout = 2))
push!(vals, String(take!(buf)))
write(wr, "foo\n")
push!(vals, Base.prompt(rd, buf, "baz", default="foobar", timeout = 1))
push!(vals, String(take!(buf)))
write(wr, "\n")
push!(vals, Base.prompt(rd, buf, "baz", default="foobar", timeout = 1))
push!(vals, String(take!(buf)))
redirect_stdin(original_stdin)
vals
end
end
end)

@test ret[1] == "foobar"
@test ret[2] == "baz [foobar] timeout 1 second: timed out\n"
@test ret[3] == "foobar"
@test ret[4] == "baz [foobar] timeout 2 seconds: timed out\n"
@test ret[5] == "foo"
@test ret[6] == "baz [foobar] timeout 1 second: "
@test ret[7] == "foobar"
@test ret[8] == "baz [foobar] timeout 1 second: "

# these tests are not in a test block so that they will compile separately
@static if Sys.iswindows()
SetLastError(code) = ccall(:SetLastError, stdcall, Cvoid, (UInt32,), code)
Expand Down

0 comments on commit 4a215d4

Please sign in to comment.