Packages

A simple logging utility built for CLIs

Current section

Files

Jump to
loggy lib loggy.ex
Raw

lib/loggy.ex

defmodule Loggy do
use Agent
# Note: this text should match what's in the first section of README.md
@moduledoc """
Loggy is a simple logging tool designed for Elixir CLIs, as the default logger
does not support colours in escripts. Loggy does **not** integrate with Logger.
Instead, it's tailored around CLI usage.
Loggy supports configuration through direct passthrough of parsed arguments from
OptionParser. `verbose`, `debug`, and `color`/`colour` are supported. `verbose`
supports both a binary flag and multiple levels.
There are two keyword structures that Loggy relies on for what to print and
when. These are `config` and `opts`.
`config` is determined by your user and should be passed straight from
OptionsParser. It determines whether to use colour, debugging, and verbosity
level to use. You shouldn't change `config` further than what the user does.
`opts` are the arguments passed to the log function. These are level,
verbosity level required to print, whether the error should be fatal, etc.
You can override change `opts[level]` to change default behaviour for
a certain level, and `opts[:all]` to change all.
TODO: full runtime examples
"""
@default_config [
user_opts: [
verbose: 0,
debug: false,
color: true
],
fn_params: [
debug: [
level: :debug,
debug: true,
format: &Loggy.Format.debug/1
],
info: [
level: :info,
format: &Loggy.Format.info/1
],
warn: [
device: :stderr,
level: :warn,
format: &Loggy.Format.warn/1
],
error: [
device: :stderr,
level: :error,
format: &Loggy.Format.error/1
],
all: [
debug: false,
device: :stdio,
verbose: 0,
fatal: false
]
]
]
@doc """
Initialise Loggy and update it with the given options. See `configure/1`.
Note that this will be done automatically if `applications` has not been
made to exclude it in your project's `mix.exs`
## Examples
{:ok, pid} = Loggy.start(:normal, user_opts: [verbose: 3, debug: true])
"""
@spec start(any, user_opts: keyword, fn_params: keyword) :: {:ok, Loggy}
def start(_, state) do
{:ok, pid} =
Agent.start_link(
fn ->
[
user_opts: @default_config[:user_opts],
fn_params: @default_config[:fn_params]
]
end,
name: __MODULE__
)
set_user_opts(Keyword.get(state, :user_opts, []))
set_fn_params(Keyword.get(state, :fn_params, []))
{:ok, pid}
end
@spec get() :: [user_opts: keyword, fn_params: keyword]
defp get() do
Agent.get(__MODULE__, & &1)
end
@spec get_user_opts :: keyword
def get_user_opts do
Keyword.get(get(), :user_opts, [])
end
@spec get_fn_params :: keyword
def get_fn_params do
Keyword.get(get(), :fn_params, [])
end
# Merge all keywords in given list
@spec cascade_merge([keyword]) :: any
defp cascade_merge([last]) do
last
end
@spec cascade_merge([keyword, ...]) :: keyword
defp cascade_merge([a | rest]) do
Keyword.merge(a, cascade_merge(rest), &deep_merge/3)
end
# Joining function of cascade_merge, passed to Keyword.merge
@spec deep_merge(any, keyword | any, keyword | any) :: keyword | any
defp deep_merge(_key, left, right) do
if Keyword.keyword?(left) && Keyword.keyword?(right) do
# We merge recursively
Keyword.merge(left, right, &deep_merge/3)
else
# Not both keywords, so right overrides left
# Will cause issues if structures mismatch, i.e. left is keyword and right is not.
right
end
end
@spec set_user_opts(keyword) :: :ok
def set_user_opts(new) do
Agent.update(__MODULE__, fn state ->
cascade_merge([state, [user_opts: new]])
end)
end
@spec set_fn_params(keyword) :: :ok
def set_fn_params(new) do
Agent.update(__MODULE__, fn state ->
cascade_merge([state, [fn_params: new]])
end)
end
@doc """
Wrapper to write debug message, if debugging is enabled. See `log/3`
## Examples
iex> Loggy.set_user_opts(debug: true)
iex> Loggy.debug("Hello!")
#=> 🐛 Hello!
:ok
iex> Loggy.set_user_opts(debug: false)
iex> Loggy.debug("Hello!")
:debug_skip
"""
@spec debug(String.t(), keyword) :: :ok | :debug_skip | :verbosity_skip
def debug(str, opts \\ []) when is_list(opts) do
log(str, Keyword.put(opts, :level, :debug))
end
@doc """
Wrapper to write info message. See `log/3`
## Examples
iex> Loggy.info("Hey hey hey!")
#=> â„šī¸ Hey hey hey!
:ok
"""
@spec info(String.t(), keyword) :: :ok | :verbosity_skip
def info(str, opts \\ []) when is_list(opts) do
log(str, Keyword.put(opts, :level, :info))
end
@doc """
Wrapper function to write warning message. See `log/3`
## Examples
iex> Loggy.warn("Please do not the.")
#=> âš ī¸ Please do not the.
:ok
"""
@spec warn(String.t(), keyword) :: :ok | :verbosity_skip
def warn(str, opts \\ []) when is_list(opts) do
log(str, Keyword.put(opts, :level, :warn))
end
@doc """
Wrapper to write warning, by default to stderr. See `log/3`
Unlike `error!/2`, is not fatal and doesn't halt the program.
## Examples
iex> Loggy.error("You really did it this time.")
#=> đŸšĢ You really did it this time.
:ok
"""
@spec error(String.t(), keyword) :: :ok | :verbosity_skip
def error(str, opts \\ []) when is_list(opts) do
log(str, Keyword.put(opts, :level, :error))
end
@doc """
Wrapper to write error, by default to stderr. See `log/3`
Unlike `error/2`, also exits with `System.stop(1)`. Note that
this exits from the calling process, not the Loggy agent.
As such, it will probably shut down your CLI. Bear this in mind
if your program has to perform any clean-up tasks after crashing.
Overrides verbosity to assure that the error is always printed
before exiting.
## Examples
iex> Loggy.error("This won't do.", verbose: 1)
:verbosity_skip
Loggy.error!("And that's a wrap!", verbose: 1)
#=> đŸšĢ And that's a wrap!
# [The program exits]
"""
@spec error!(String.t(), keyword) :: no_return
def error!(str, opts \\ []) do
opts =
Keyword.merge(
opts,
verbose: 0,
fatal: true,
level: :error
)
error(str, opts)
end
@doc """
Main logging function of loggy. You'll most likely want to use the `debug/2`,
`info/2`, `warn/2`, and `error/2` functions though, but manually passing the level
might be helpful e.g. in order to promote warnings to errors if enough occur.
## Opts
Valid opts are...
* `level`: one of `:debug`, `:info`, `:warn`, and `:error`
* `verbose`: integer or boolean. Internally converted to integer. Note that
it's the required verbosity of a logging statement. In other words, a call
with `verbosity: 2` will only print out if `verbosity >= 2` in the Loggy
config. Usually, this would be achieved by passing the `--verbose` flag twice,
or `-vv` for short.
* `fatal`: if `true`, will exit the program with `System.stop/1` and an
exit value of 1.
TODO: conditional warn/error! example...
"""
@spec log(String.t(), keyword) :: :ok | :verbosity_skip | :debug_skip
def log(str, opts) when is_binary(str) and is_list(opts) do
user_opts = get_user_opts()
level = opts[:level]
opts = cascade_merge([get_fn_params()[:all], get_fn_params()[level], opts]) |> verbose_as_int
cond do
user_opts[:verbose] < opts[:verbose] ->
:verbosity_skip
level == :debug && !user_opts[:debug] ->
:debug_skip
true ->
output = opts[:format].(str)
device = opts[:device]
IO.puts(device, output)
if opts[:fatal] do
System.stop(1)
else
:ok
end
end
end
# Utility function for converting verbosity boolean to verbosity level.
# Implicitly handles nil (returns false, thus 0)
@spec to_int(integer) :: integer
@spec to_int(atom) :: integer
defp to_int(val) when is_integer(val) do
val
end
defp to_int(val) do
if val, do: 1, else: 0
end
# Assure that :verbose is an integer
# Used for both agent config and function call opts
@spec verbose_as_int(keyword) :: keyword
defp verbose_as_int(config) do
verbose = config |> Keyword.get(:verbose, 0) |> to_int
Keyword.put(config, :verbose, verbose)
end
end