Skip to content

Commit

Permalink
Support adding an environment variable alias to cli options
Browse files Browse the repository at this point in the history
  • Loading branch information
pnezis committed Jul 12, 2024
1 parent a057b12 commit 7053a70
Show file tree
Hide file tree
Showing 8 changed files with 201 additions and 1 deletion.
3 changes: 3 additions & 0 deletions cli_options/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

* Support getting option value from an environment variable, through the `:env` option. Used
only if not provided by the user in the CLI arguments.

* Support grouping options in the docs by section. You can now specify the `:doc_section`
to any option, and pass a `:sections` option in the `CliOptions.docs/2` function for the
headers and extended docs of each section.
Expand Down
55 changes: 55 additions & 0 deletions cli_options/lib/cli_options.ex
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,61 @@ defmodule CliOptions do
CliOptions.parse(["--file", "foo.ex", "--file", "xyz.ex"], [file: [type: :string]])
```
## Environment variable aliases
You can optionally define an environment variable alias for an option through the
`:env` schema option. If set the environment variable will be used **only if** the
argument is not present in the `args`.
```cli
schema = [
mode: [
type: :string,
env: "CLI_OPTIONS_MODE"
]
]
# assume the environment variable is set
System.put_env("CLI_OPTIONS_MODE", "parallel")
# if the argument is provided by the user the environment variable is ignored
CliOptions.parse(["--mode", "sequential"], schema)
>>>
# the environment variable will be used if not set
CliOptions.parse([], schema)
>>>
System.delete_env("CLI_OPTIONS_MODE")
```
> #### Boolean flags and environment variables {: .warning}
>
> Notice that if the option is `:boolean` and an `:env` alias is set, then the
> environment variable will be used only if it has a _truthy_ value. A value is
> considered truthy if it is one of `1`, `true` (the match is case insensitive).
> In any other case the environment variable is ignored.
>
> ```cli
> schema = [
> enable: [type: :boolean, env: "CLI_OPTIONS_ENABLE"]
> ]
>
> System.put_env("CLI_OPTIONS_ENABLE", "1")
> CliOptions.parse([], schema)
> >>>
>
> System.put_env("CLI_OPTIONS_ENABLE", "TrUE")
> CliOptions.parse([], schema)
> >>>
>
> System.put_env("CLI_OPTIONS_ENABLE", "other")
> CliOptions.parse([], schema)
> >>>
>
> System.delete_env("CLI_OPTIONS_ENABLE")
> ```
## Return separator
The separator `--` implies options should no longer be processed. Every argument
Expand Down
12 changes: 12 additions & 0 deletions cli_options/lib/cli_options/docs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ defmodule CliOptions.Docs do
[
schema[:doc],
maybe_allowed(schema),
maybe_env(schema),
maybe_default(schema),
maybe_aliases(schema)
]
Expand All @@ -177,6 +178,17 @@ defmodule CliOptions.Docs do
end
end

defp maybe_env(schema) do
case schema[:env] do
nil ->
nil

env ->
env = String.upcase(env)
"[env: #{env}=]"
end
end

defp maybe_default(schema) do
case schema[:default] do
nil -> ""
Expand Down
34 changes: 34 additions & 0 deletions cli_options/lib/cli_options/parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ defmodule CliOptions.Parser do
{remaining_argv, extra} = split_extra(argv)

with {:ok, opts, args} <- parse(remaining_argv, schema, [], []),
{:ok, opts} <- maybe_append_env_values(opts, schema),
{:ok, opts} <- CliOptions.Schema.validate(opts, schema) do
{:ok, {opts, args, extra}}
end
Expand Down Expand Up @@ -167,4 +168,37 @@ defmodule CliOptions.Parser do
{:error, "an option alias must be one character long, got: #{inspect(option_alias)}"}
end
end

defp maybe_append_env_values(opts, schema) do
args_from_env =
schema.schema
|> Enum.reject(fn {_key, opts} -> is_nil(opts[:env]) end)
|> Enum.reject(fn {key, _opts} -> Keyword.has_key?(opts, key) end)
|> Enum.map(fn {_key, opts} -> maybe_read_env(opts) end)
|> List.flatten()

with {:ok, env_opts, []} <- parse(args_from_env, schema, [], []) do
{:ok, Keyword.merge(opts, env_opts)}
end
end

defp maybe_read_env(opts) do
env = System.get_env(String.upcase(opts[:env]))

cond do
is_nil(env) ->
[]

opts[:type] == :boolean and truthy?(env) ->
["--" <> opts[:long]]

opts[:type] == :boolean ->
[]

true ->
["--" <> opts[:long], env]
end
end

defp truthy?(value), do: String.downcase(value) in ["1", "true"]
end
11 changes: 11 additions & 0 deletions cli_options/lib/cli_options/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,17 @@ defmodule CliOptions.Schema do
Defines a message to indicate that the option is deprecated. The message will
be displayed as a warning when passing the item.
"""
],
env: [
type: :string,
doc: """
An environment variable to get this option from, if it is missing from the command
line arguments. If the option is provided by the user the environment variable
is ignored.
For boolean options, the flag is considered set if the environment variable has
a truthy value (`1`, `true`) and ignored in any other case.
"""
]
]

Expand Down
16 changes: 16 additions & 0 deletions cli_options/test/cli_options/docs_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -166,5 +166,21 @@ defmodule CliOptions.DocsTest do

assert CliOptions.docs(schema) == expected
end

test "with env set" do
schema = [
var: [doc: "a var", aliases: ["var1", "var2"], short: "v", default: "foo", env: "my_env"],
another: [doc: "another var", type: :integer, env: "ANOTHER_ENV"]
]

expected =
"""
* `-v, --var` (`string`) - a var [env: MY_ENV=] [default: `foo`] [aliases: `--var1`, `--var2`]
* `--another` (`integer`) - another var [env: ANOTHER_ENV=]
"""
|> String.trim()

assert CliOptions.docs(schema) == expected
end
end
end
2 changes: 1 addition & 1 deletion cli_options/test/cli_options/schema_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defmodule CliOptions.SchemaTest do
message =
"invalid schema for :foo, unknown options [:missing, :other], valid options are: " <>
"[:type, :default, :long, :short, :aliases, :short_aliases, :doc, :doc_section, :required, :multiple, " <>
":allowed, :deprecated]"
":allowed, :deprecated, :env]"

assert_raise ArgumentError, message, fn ->
CliOptions.Schema.new!(schema)
Expand Down
69 changes: 69 additions & 0 deletions cli_options/test/cli_options_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -341,4 +341,73 @@ defmodule CliOptionsTest do
end) == ""
end
end

describe ":env set" do
@env_schema [
name: [type: :string, env: "TEST_NAME", required: true],
age: [type: :integer, env: "TEST_AGE"],
enable: [type: :boolean, env: "TEST_ENABLE"]
]

setup do
on_exit(fn ->
System.delete_env("TEST_NAME")
System.delete_env("TEST_AGE")
System.delete_env("TEST_ENABLE")
end)
end

test "env vars are ignored if user provides the cli args" do
System.put_env("TEST_NAME", "foo")
System.put_env("TEST_AGE", "13")

assert {opts, [], []} = CliOptions.parse!(["--name", "bar", "--age", "20"], @env_schema)
assert opts == [name: "bar", age: 20, enable: false]
end

test "env vars are used if not set in options" do
System.put_env("TEST_NAME", "foo")
System.put_env("TEST_AGE", "13")

assert {opts, [], []} = CliOptions.parse!(["--enable"], @env_schema)
assert opts == [name: "foo", age: 13, enable: true]
end

test "env vars types are validated" do
System.put_env("TEST_NAME", "foo")
System.put_env("TEST_AGE", "invalid")

assert CliOptions.parse(["--enable"], @env_schema) ==
{:error, ":age expected an integer argument, got: invalid"}
end

test "env vars truthy values" do
for value <- ["1", "true", "TRUE", "tRuE"] do
System.put_env("TEST_ENABLE", value)

assert {opts, [], []} = CliOptions.parse!(["--name", "foo"], @env_schema)
assert opts[:enable]
end

for value <- ["0", "false", "other"] do
System.put_env("TEST_ENABLE", value)

assert {opts, [], []} = CliOptions.parse!(["--name", "foo"], @env_schema)
refute opts[:enable]
end
end

test "env vars casing definition does not matter" do
System.put_env("TEST_NAME", "foo")

assert CliOptions.parse!([], name: [type: :string, env: "TEST_NAME"]) ==
{[name: "foo"], [], []}

assert CliOptions.parse!([], name: [type: :string, env: "test_name"]) ==
{[name: "foo"], [], []}

assert CliOptions.parse!([], name: [type: :string, env: "Test_NAMe"]) ==
{[name: "foo"], [], []}
end
end
end

0 comments on commit 7053a70

Please sign in to comment.