diff --git a/cli_options/CHANGELOG.md b/cli_options/CHANGELOG.md index aaab082..09ebfac 100644 --- a/cli_options/CHANGELOG.md +++ b/cli_options/CHANGELOG.md @@ -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. diff --git a/cli_options/lib/cli_options.ex b/cli_options/lib/cli_options.ex index 3b63811..ad2d979 100644 --- a/cli_options/lib/cli_options.ex +++ b/cli_options/lib/cli_options.ex @@ -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 diff --git a/cli_options/lib/cli_options/docs.ex b/cli_options/lib/cli_options/docs.ex index 77a7c8a..18b9279 100644 --- a/cli_options/lib/cli_options/docs.ex +++ b/cli_options/lib/cli_options/docs.ex @@ -163,6 +163,7 @@ defmodule CliOptions.Docs do [ schema[:doc], maybe_allowed(schema), + maybe_env(schema), maybe_default(schema), maybe_aliases(schema) ] @@ -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 -> "" diff --git a/cli_options/lib/cli_options/parser.ex b/cli_options/lib/cli_options/parser.ex index feeb2c5..d65617d 100644 --- a/cli_options/lib/cli_options/parser.ex +++ b/cli_options/lib/cli_options/parser.ex @@ -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 @@ -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 diff --git a/cli_options/lib/cli_options/schema.ex b/cli_options/lib/cli_options/schema.ex index 5db4f4c..8508ec1 100644 --- a/cli_options/lib/cli_options/schema.ex +++ b/cli_options/lib/cli_options/schema.ex @@ -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. + """ ] ] diff --git a/cli_options/test/cli_options/docs_test.exs b/cli_options/test/cli_options/docs_test.exs index 5dec3a6..1209aa9 100644 --- a/cli_options/test/cli_options/docs_test.exs +++ b/cli_options/test/cli_options/docs_test.exs @@ -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 diff --git a/cli_options/test/cli_options/schema_test.exs b/cli_options/test/cli_options/schema_test.exs index e5c18ab..6ea48a1 100644 --- a/cli_options/test/cli_options/schema_test.exs +++ b/cli_options/test/cli_options/schema_test.exs @@ -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) diff --git a/cli_options/test/cli_options_test.exs b/cli_options/test/cli_options_test.exs index c28cc5a..4866ae2 100644 --- a/cli_options/test/cli_options_test.exs +++ b/cli_options/test/cli_options_test.exs @@ -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