Coder Social home page Coder Social logo

piotr-yuxuan / malli-cli Goto Github PK

View Code? Open in Web Editor NEW
50.0 4.0 3.0 275 KB

Configuration powertool with `metosin/malli`

Home Page: https://github.com/piotr-yuxuan/malli-cli

License: European Union Public License 1.2

Clojure 100.00%
cli cli-app command-line command-line-tool clojure malli args-parser configuration configuration-management environment-variable

malli-cli's Introduction

piotr-yuxuan/malli-cli

Configuration powertool with metosin/malli.

Build Status Clojars badge Clojars downloads cljdoc badge GitHub license GitHub issues bb compatible

What it offers

This library provides out-of-the-box:

  • Configuration sourcing from command line arguments, environment variables, or any other configuration management tool you want to integrate with;
  • Very expressive means to describe the configuration you expect;
  • An agnostic way to protect secrets in your config that turn them into opaque, non-printable values, while still being able to decode them and access the original value when need be;
  • Seamless and powerful CLI generation based on your config schema, returns a map representing the parsed, decoded arguments and environment variables you are interested in.

The returned map can be used as a config fragment, that you can later merge with the config value provided by any other system. As such it intends to play nicely with configuration tools, so the actual configuration value of your program is a map that is a graceful merge of several overlapping config fragment:

  1. Default configuration value;
  2. Environment variables when the program starts up;
  3. Value from some configuration management system;
  4. Command line arguments.

The expected shape of your configuration being described as a malli schema so you can parse and decode strings as well as validating any constraints. It's quite powerful.

Maturity and evolution

Break versioning is used, so no breaking changes will be introduced without incrementing the major version. Some bug fixes may be introduced and I will keep adding new features as I encounter new needs. As illustrated below, malli-cli should already cover most of your use cases with simplicity – or open an issue.

Naming

utility_name [-a][-b][-c option_argument]
             [-d|-e][-f[option_argument]][operand...]

The utility in the example is named utility_name. It is followed by options, option-arguments, and operands. The arguments that consist of - characters and single letters or digits, such as a, are known as "options" (or, historically, "flags"). Certain options are followed by an " option-argument", as shown with [ -c option_argument ]. The arguments following the last options and option-arguments are named "operands".

Simple example

Let's consider this config schema:

(require '[piotr-yuxuan.malli-cli :as malli-cli])
(require '[malli.core :as m])

(def Config
  (m/schema
    [:map {:closed true, :decode/args-transformer malli-cli/args-transformer}
     [:show-config? {:optional true}
      [boolean? {:description "Print actual configuration value and exit."
                 :arg-number 0}]]
     [:help {:optional true}
      [boolean? {:description "Display usage summary and exit."
                 :short-option "-h"
                 :arg-number 0}]]
     [:upload-api [string? {:description "Address of target upload-api instance. If not set from the command line, lookup env var $CORP_UPLOAD_API."
                            :short-option "-a"
                            ;; Cli option will be looked up, then env var, then default.
                            :env-var "CORP_UPLOAD_API"
                            :default "http://localhost:8080"}]]
     [:log-level [:enum {:description "Non-idempotent -v increases log level, --log-level sets it."
                         ;; Express how to decode a string into an enum value.
                         :decode/string keyword
                         :short-option "-v"
                         :short-option/arg-number 0
                         :short-option/update-fn malli-cli/non-idempotent-option
                         :default :error
                         ;; Used in summary to pretty-print the default value to a string.
                         :default->str name}
                  :off :fatal :error :warn :info :debug :trace :all]]
     [:proxy [:map
              [:host string?]
              ;; malli will parse and return an integer.
              [:port pos-int?]]]]))

Here is the command summary produced by (malli-cli/summary Config) for this config:

  Short  Long option    Default                  Description
         --show-config                           Print actual configuration value and exit.
  -h     --help                                  Display usage summary and exit.
  -a     --upload-api   "http://localhost:8080"  Address of target upload-api instance. If not set from the command line, lookup env var $CORP_UPLOAD_API.
  -v     --log-level    error                    Non-idempotent -v increases log level, --log-level sets it.
         --proxy-host
         --proxy-port

Let's try to call this program (code details below). You may invoke your Clojure main function with:

lein run \
  --help -vvv \
  -a "https://localhost:4000"

Let's suppose your configuration system provides this value:

{:proxy {:host "https://proxy.example.com"
         :port 3128}}

and the shell environment variable CORP_UPLOAD_API is set to https://localhost:7000. Then the resulting configuration passed to your app will be:

{:help true
 :upload-api "https://localhost:4000"
 :log-level :debug
 :proxy {;; Nested config maps are supported
         :host "http://proxy.example.com"
         ;; malli transform strings into appropriate types
         :port 3128}

Let's try another time with this command with same provided config and env vars:

lein run \
  --log-level=off
  --show-config

The program will exit after printing:

{:log-level :off,
 :show-config? true,
 :upload-api "http://localhost:7000"}

From a technical point of view, it leverages malli coercion and decoding capabilities so that you may define the shape of your configuration and default value in one place, then derive a command-line interface from it.

(require '[piotr-yuxuan.malli-cli :as malli-cli])
(require '[malli.core :as m])
(require '[clojure.pprint])
(require '[piotr-yuxuan.malli-cli.utils :refer [deep-merge]])

(defn load-config
  [args]
  (deep-merge
    ;; Value retrieved from any configuration system you want
    (:value (configure {:key service-name
                        :env (env)
                        :version (version)}))
    ;; Command-line arguments, env-vars, and default values.
    (m/decode Config args malli-cli/cli-transformer)))

(defn -main
  [& args]
  (let [config (load-config args)]
    (cond (not (m/validate Config config))
          (do (log/error "Invalid configuration value"
                         (m/explain Config config))
              (Thread/sleep 60000) ; Leave some time to retrieve the logs.
              (System/exit 1))

          (:show-config? config)
          (do (clojure.pprint/pprint config)
              (System/exit 0))

          (:help config)
          (do (println (simple-summary Config))
              (System/exit 0))

          :else
          (app/start config))))

Capabilities

See tests for minimal working code for each of these examples.

  • Long option flag and value --long-option VALUE may give
{:long-option "VALUE"}

;; Example schema:
[:map {:decode/args-transformer malli-cli/args-transformer}
  [:long-option string?]]
  • Grouped flag and value with --long-option=VALUE may give
{:long-option "VALUE"}

;; Example schema:
[:map {:decode/args-transformer malli-cli/args-transformer}
  [:long-option string?]]
  • Short option names with -s VALUE may give
{:some-option "VALUE"}

;; Example schema:
[:map {:decode/args-transformer malli-cli/args-transformer}
 [:some-option [string? {:short-option "-s"}]]]
  • Options that accept a variable number of arguments: -a -b val0 --c val1 val2
{:a true
 :b "val0"
 :c ["val1" "val2"]}

;; Example schema:
[:map {:decode/args-transformer malli-cli/args-transformer}
 [:a [boolean? {:arg-number 0}]]
 [:b string?]
 [:c [string? {:arg-number 2}]]]
  • Non-option arguments are supported directly amongst options, or after a double-dash so -a 1 ARG0 -b 2 -- ARG1 ARG2 may be equivalent to:
{:a 1
 :b 2
 :piotr-yuxuan.malli-cli/arguments ["ARG0" "ARG1" "ARG2"]}

;; Example schema:
[:map {:decode/args-transformer malli-cli/args-transformer}
 [:a [boolean? {:arg-number 0}]]
 [:b string?]]
  • Grouped short flags like -hal are expanded like, for example:
{:help true
 :all true
 :list true}

;; Example schema:
[:map {:decode/args-transformer malli-cli/args-transformer}
 [:help [boolean? {:short-option "-h" :arg-number 0}]]
 [:all [boolean? {:short-option "-a" :arg-number 0}]]
 [:list [boolean? {:short-option "-l" :arg-number 0}]]]
  • Non-idempotent options like -vvv are supported and may be rendered as:
{:verbosity 3}
;; or, depending on what you want:
{:log-level :debug}

;; Example schemas:
[:map {:decode/args-transformer malli-cli/args-transformer}
 [:log-level [:and
              keyword?
              [:enum {:short-option "-v"
                      :short-option/arg-number 0
                      :short-option/update-fn malli-cli/non-idempotent-option
                      :default :error}
               :off :fatal :error :warn :info :debug :trace :all]]]]

[:map {:decode/args-transformer malli-cli/args-transformer}
 [:verbosity [int? {:short-option "-v"
                    :short-option/arg-number 0
                    :short-option/update-fn (fn [options {:keys [in]} _cli-args]
                                              (update-in options in (fnil inc 0)))
                    :default 0}]]]
  • You may use nested maps in your config schema so that --proxy-host https://example.org/upload --proxy-port 3447 is expanded as:
{:proxy {:host "https://example.org/upload"
         :port 3447}}

;; Example schema:
[:map {:decode/args-transformer malli-cli/args-transformer}
 [:proxy [:map
          [:host string?]
          [:port pos-int?]]]]
  • Namespaced keyword are allowed, albeit the command-line option name stays simple --upload-parallelism 32 may give:
{:upload/parallelism 32}

;; Example schema:
[:map {:decode/args-transformer malli-cli/args-transformer}
 [:upload/parallelism pos-int?]]
  • You can provide your own code to update the result map with some complex behaviour, like for example --name Piotr:
{:vanity-name ">> Piotr <<"
 :original-name "Piotr"
 :first-letter \P}

;; Example schema:
[:map {:decode/args-transformer malli-cli/args-transformer}
 [:vanity-name [string? {:long-option "--name"
                         :update-fn (fn [options {:keys [in]} [username]]
                                      (-> options
                                          (assoc :vanity-name (format ">> %s <<" username))
                                          (assoc :original-name username)
                                          (assoc :first-letter (first username))))}]]
 [:original-name string?]
 [:first-letter char?]]
  • Build a simple summary string (see schema Config above):
  -h  --help        nil
  -a  --upload-api  "http://localhost:8080"  Address of target upload-api instance.
  -v  --log-level   :error
      --proxy-host  nil
      --proxy-port  nil
  • Error handling with unknown options:
;; Example schema:
[:map {:decode/args-transformer malli-cli/args-transformer}
 [:my-option string?]]

;; Example input:
["--unknown-long-option" "--other-option" "VALUE" "-s"}

;; Exemple output:
#:piotr-yuxuan.malli-cli{:unknown-option-errors ({:arg "-s"} {:arg "--other-option"} {:arg "--unknown-long-option"}),
                         :known-options ("--my-option"),
                         :arguments ["VALUE"],
                         :cli-args ["--unknown-long-option" "--other-option" "VALUE" "-s"]}
  • Environment variable USER set to piotr-yuxuan may give:
{:user "piotr-yuxuan"}

;; Example schema:
[:map {:decode/args-transformer malli-cli/args-transformer}
 [:user [string? {:env-var "USER"}]]]
  • Protect secret values and keep them out of logs when configuration is printed. Secrets are turned into strings by default, but you may provide custom secret-fn and plaintext-fn.
(m/encode
  [:map
   [:ui/service-name string?]
   [:database/username {:secret true} string?]
   [:database/password {:secret true} string?]]
  {:ui/service-name "Hello, world!"
   :database/username "Username must stay out of logs."
   :database/password "Password must stay out of logs."}
  malli-cli/secret-transformer)

=> {:ui/service-name "Hello, world!",
    :database/username "***",
    :database/password "***"}

Note that environment variables behave like default values with lower priority than command-line arguments. Env vars are resolved at decode time, not at schema compile time. This lack of purity is balanced by the environment being constant and set by the JVM at start-up time.

References

malli-cli's People

Contributors

burinc avatar dainiusjocas avatar piotr-yuxuan avatar rynkowsg avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

malli-cli's Issues

Danger zone for commands or subcommands

When the developer know that something is dangerous and would to fully collect the informed consent of the user:

  • In an interactive shell a prompt asks for confirmation;
  • In non-interactive shell some flag help the user be more explicit about his wishes.

Default options

Provide default options that may be overriden or just disabled by the dev user

  • --help, -h, -?
  • --now, --start-time, -t
  • -!, --about
  • -v, --verbose, log-level, -d, -x, -s, --silent, --quiet, -q
  • -V, --version – perhaps it should include the semantic version on the first line and other lines could contain build time and other data?
  • -u, --user the value of ${USER}, would it be any useful?
  • any other?
  • Path of the caller, and path of the current program called.

References:

Real-life examples for advanced features

The CLI of well-known, everyday commands like git, cp, curl or similar things should be written with malli-cli to feature capabilities and provide more batle-tested feedback. This may be ideally be in a mono-README à la malli with a table of contents so text search is very straightforward.

Who needs stdin support?

Besides keys :options and :arguments, how could the user get a key :stdin for some parsing, coercion, and validation of the standard input?

Operands as options

For example one should be able to express that thw two operands should be linked to configuration keys --source and --target.

Add commands and subcommands

  • Subcommand are just arguments
  • Some (sub)command may have different, overlapping requirements
  • Basically should automate the need for cond in -main, while enabling simple enhancement or à-la-carte replacement.
  • Help page
  • Nested subcommands should be able to have their own arguments.

Add optional interactive prompt for missing required options

  • Perhaps it should not exit after some time to make sure that the user notice that the program is blocked.
  • Otherwise, with a countdown it might just appear slow as it's waiting for user input.
  • Enum values could be shown for selection.
  • Coercion would be applied on each value.

Man page and autocomplete?

The developer probably wants to write himself the code to install these into the end user, but it would be nice to provide the developer with off-the-shelf function to derive data he needs from a schema.

Add env var support?

Besides keys :options and :arguments, how could the user get a key :environment-variables for some parsing, coercion, and validation of env vars?

Improve summary

  • It should be possible to (recursively) hide some options or nested map from the summary.
  • :description, as expected, should be the actual description as per malli convention. However it should be give part of the description and leave malli-cli generate standard description parts about enum possible (parsed) values, non-idempotency (including difference between long and short value), env and var lookup.
  • Include a synopsis in the summary.
  • Allow the user to provide a max width with a default to 80, or allow to disable it.
  • Add simple colouring and try to integrate with some shell conventions here.

`No matching field found: stripTrailing for class java.lang.String` for Java 1.8

Error info when running Oracle Java 1.8.0_321-b07:

{:clojure.main/message
 "Execution error (IllegalArgumentException) at piotr-yuxuan.malli-cli/summary$fn (malli_cli.clj:276).\nNo matching field found: stripTrailing for class java.lang.String\n",
 :clojure.main/triage
 {:clojure.error/class java.lang.IllegalArgumentException,
  :clojure.error/line 276,
  :clojure.error/cause
  "No matching field found: stripTrailing for class java.lang.String",
  :clojure.error/symbol piotr-yuxuan.malli-cli/summary$fn,
  :clojure.error/source "malli_cli.clj",
  :clojure.error/phase :execution},
 :clojure.main/trace
 {:via
  [{:type java.lang.IllegalArgumentException,
    :message
    "No matching field found: stripTrailing for class java.lang.String",
    :at
    [clojure.lang.Reflector getInstanceField "Reflector.java" 397]}],
  :trace
  [[clojure.lang.Reflector getInstanceField "Reflector.java" 397]
   [clojure.lang.Reflector
    invokeNoArgInstanceMember
    "Reflector.java"
    440]
   [piotr_yuxuan.malli_cli$summary$fn__4834 invoke "malli_cli.clj" 276]
   [clojure.core$map$fn__5885 invoke "core.clj" 2759]
   [clojure.lang.LazySeq sval "LazySeq.java" 42]
   [clojure.lang.LazySeq seq "LazySeq.java" 51]
   [clojure.lang.LazySeq first "LazySeq.java" 73]
   [clojure.lang.RT first "RT.java" 692]
   [clojure.core$first__5402 invokeStatic "core.clj" 55]
   [clojure.string$join invokeStatic "string.clj" 180]
   [clojure.string$join invoke "string.clj" 180]
   [piotr_yuxuan.malli_cli$summary invokeStatic "malli_cli.clj" 274]
   [piotr_yuxuan.malli_cli$summary invoke "malli_cli.clj" 257]
   [onedrive_filenames_correction$_main
    invokeStatic
    "onedrive_filenames_correction.clj"
    107]
   [onedrive_filenames_correction$_main
    doInvoke
    "onedrive_filenames_correction.clj"
    94]
   [clojure.lang.RestFn applyTo "RestFn.java" 137]
   [clojure.lang.Var applyTo "Var.java" 705]
   [clojure.core$apply invokeStatic "core.clj" 667]
   [clojure.main$main_opt invokeStatic "main.clj" 514]
   [clojure.main$main_opt invoke "main.clj" 510]
   [clojure.main$main invokeStatic "main.clj" 664]
   [clojure.main$main doInvoke "main.clj" 616]
   [clojure.lang.RestFn applyTo "RestFn.java" 137]
   [clojure.lang.Var applyTo "Var.java" 705]
   [clojure.main main "main.java" 40]],
  :cause
  "No matching field found: stripTrailing for class java.lang.String"}}

Since the stripTrailing was introduced in Java 11, it might be a good idea to avoid using it. We could use here Clojure's trimr.

Declarative array options

For example --language can be repeated several time to specify several language. A property of the schema should reflect this.

Nested maps

Short and long names for option aren't considered.

`:optional` schema property doesn't work properly

In your example running the validate and explain (with humanize), it seems that the optional keyword doesn't work.

(->> (m/decode Config [] cli/cli-transformer)
     (m/explain Config)
     me/humanize)
=> {:show-config? ["missing required key"], :help ["missing required key"], :proxy ["missing required key"]}

Your tests with a similar use-case pass because the optional options have a :default false entry.

Also the test suite is failing

$ lein test

lein test piotr-yuxuan.domain.gnu-test

lein test piotr-yuxuan.domain.posix-test

lein test piotr-yuxuan.malli-cli-test

lein test :only piotr-yuxuan.malli-cli-test/args-transformer-test

FAIL in (args-transformer-test) (malli_cli_test.clj:241)
expected: (= (m/decode MyCliSchema cli-args (mt/transformer malli-cli/cli-transformer)) {:log-level :all, :help false, :upload-api "http://localhost:8080", :database "http://localhost:8888", :async-parallelism 64, :create-market-dataset false})
  actual: (not (= {:log-level :all, :upload-api "http://localhost:8080", :database "http://localhost:8888", :async-parallelism 64} {:log-level :all, :help false, :upload-api "http://localhost:8080", :database "http://localhost:8888", :async-parallelism 64, :create-market-dataset false}))

Not sure if it is malli issue or here but I'm really hoping I can use this lib to no longer have to use tools.cli or omniconf.

Thanks in advance

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.