Current section
Files
Jump to
Current section
Files
lib/tqdm.ex
defmodule Tqdm do
@moduledoc """
Tqdm easily adds a CLI progress bar to any enumerable.
Just wrap Lists, Maps, Streams, or anything else that implements Enumerable
with `Tqdm.tqdm`:
for _ <- Tqdm.tqdm(1..1000) do
:timer.sleep(10)
end
# or
1..1000
|> Tqdm.tqdm()
|> Enum.map(fn _ -> :timer.sleep(10) end)
# or even...
1..1000
|> Stream.map(fn -> :timer.sleep(10) end)
|> Tqdm.tqdm()
|> Stream.run()
# |###-------| 392/1000 39.0% [elapsed: 00:00:04.627479 \
left: 00:00:07, 84.71 iters/sec]
"""
@type option ::
{:description, String.t} |
{:total, non_neg_integer} |
{:clear, boolean} |
{:device, IO.device} |
{:min_interval, non_neg_integer} |
{:min_iterations, non_neg_integer} |
{:total_segments, non_neg_integer}
@type options :: [option]
@doc """
Wrap the given `enumerable` and print a CLI progress bar.
`options` may be provided:
* `:description` - a short string that is displayed on the progress bar.
For example, if the string `"Processing values"` is provided for this
option:
# Processing values: |###-------| 349/1000 35.0% [elapsed: \
00:00:06.501472 left: 00:00:12, 53.68 iters/sec]
* `:total` - by default, `Tdqm` will use `Enum.count` to count how many
elements are in the given `enumerable`. For large amounts of data, or
streams, this may not be appropriate. You can provide your own total with
this option. You may provide an estimate, and if the actual count
exceeds this value, the progress bar will change to an indeterminate mode:
# 296 [elapsed: 00:00:03.500038, 84.57 iters/sec]
You can also force the indeterminate mode by passing `0`.
* `:clear` - by default, `Tqdm` will clear the progress bar after the
enumeration is complete. If you pass `false` for this option, the progress
bar will persist, instead.
* `:device` - by default, `Tqdm` writes to `:stderr`. You can provide any
`IO.device` to this option to use it instead of the default.
* `:min_interval` - by default, `Tqdm` will only print progress updates
every 100ms. You can increase or decrease this value using this option.
* `:min_iterations` - by default, `Tqdm` will check if the `:min_interval`
has passed for every iteration. Passing a value for this option will skip
this check until at least `:min_iterations` iterations have passed.
* `:total_segments` - by default, `Tqdm` will split its progress bar into 10
segments. You can customize this by passing a different value for this
option.
"""
@spec tqdm(Enumerable.t, options) :: Enumerable.t
def tqdm(enumerable, options \\ []) do
start_fun = fn ->
now = :erlang.monotonic_time()
get_total = fn -> Enum.count(enumerable) end
%{
n: 0,
last_print_n: 0,
start_time: now,
last_print_time: now,
last_printed_length: 0,
prefix: options |> Keyword.get(:description, "") |> prefix(),
total: Keyword.get_lazy(options, :total, get_total),
clear: Keyword.get(options, :clear, true),
device: Keyword.get(options, :device, :stderr),
min_interval:
options
|> Keyword.get(:min_interval, 100)
|> :erlang.convert_time_unit(:milli_seconds, :native),
min_iterations: Keyword.get(options, :min_iterations, 1),
total_segments: Keyword.get(options, :total_segments, 10)
}
end
Stream.transform(enumerable, start_fun, &do_tqdm/2, &do_tqdm_after/1)
end
defp prefix(""), do: ""
defp prefix(description), do: description <> ": "
defp do_tqdm(element, %{n: 0} = state) do
{[element], %{print_status(state, :erlang.monotonic_time()) | n: 1}}
end
defp do_tqdm(
element,
%{n: n, last_print_n: last_print_n, min_iterations: min_iterations} = state
) when n - last_print_n < min_iterations,
do: {[element], %{state | n: n + 1}}
defp do_tqdm(element, state) do
now = :erlang.monotonic_time()
time_diff =
now - state.last_print_time
state =
if time_diff >= state.min_interval do
Map.merge(print_status(state, now), %{
last_print_n: state.n,
last_print_time: :erlang.monotonic_time()
})
else
state
end
{[element], %{state | n: state.n + 1}}
end
defp do_tqdm_after(state) do
state = print_status(state, :erlang.monotonic_time())
finish =
if state.clear do
prefix_length = String.length(state.prefix)
total_bar_chars = prefix_length + state.last_printed_length
"\r" <> String.duplicate(" ", total_bar_chars) <> "\r"
else
"\n"
end
IO.write(state.device, finish)
end
defp print_status(state, now) do
status = format_status(state, now)
status_length = String.length(status)
num_padding_chars = max(state.last_printed_length - status_length, 0)
padding = String.duplicate(" ", num_padding_chars)
IO.write(state.device, "\r#{state.prefix}#{status}#{padding}")
%{state | last_printed_length: status_length}
end
defp format_status(state, now) do
elapsed =
:erlang.convert_time_unit(now - state.start_time, :native, :micro_seconds)
elapsed_str = format_interval(elapsed, false)
rate = format_rate(elapsed, state.n)
format_status(state, elapsed, rate, elapsed_str)
end
defp format_status(state, elapsed, rate, elapsed_str) do
n = state.n
total = state.total
total_segments = state.total_segments
if n <= total and total != 0 do
progress = n / total
num_segments = trunc(progress * total_segments)
bar = format_bar(num_segments, total_segments)
percentage = "#{Float.round(progress * 100)}%"
left = format_left(n, elapsed, total)
"|#{bar}| #{n}/#{total} #{percentage} " <>
"[elapsed: #{elapsed_str} left: #{left}, #{rate} iters/sec]"
else
"#{n} [elapsed: #{elapsed_str}, #{rate} iters/sec]"
end
end
defp format_rate(elapsed, n) when elapsed > 0,
do: Float.round(n / (elapsed / 1_000_000), 2)
defp format_rate(_elapsed, _n),
do: "?"
defp format_bar(num_segments, total_segments) do
String.duplicate("#", num_segments) <>
String.duplicate("-", total_segments - num_segments)
end
defp format_left(n, elapsed, total) when n > 0,
do: format_interval(elapsed / n * (total - n), true)
defp format_left(_n, _elapsed, _total),
do: "?"
defp format_interval(elapsed, trunc_seconds) do
minutes = trunc(elapsed / 60_000_000)
hours = div(minutes, 60)
rem_minutes = minutes - hours * 60
micro_seconds = elapsed - minutes * 60_000_000
seconds = micro_seconds / 1_000_000
seconds = if trunc_seconds, do: trunc(seconds), else: seconds
hours_str = format_time_component(hours)
minutes_str = format_time_component(rem_minutes)
seconds_str = format_time_component(seconds)
"#{hours_str}:#{minutes_str}:#{seconds_str}"
end
defp format_time_component(time) when time < 10,
do: "0#{time}"
defp format_time_component(time),
do: to_string(time)
end