From 3319427566643fa38558912e333864f8ea9efb28 Mon Sep 17 00:00:00 2001 From: Alexander Yakushev Date: Mon, 15 Jun 2020 13:07:06 +0300 Subject: [PATCH] Allow setting nullary functions for :default fields in the configuration --- CHANGELOG.md | 10 +++++++- README.org | 30 +++++++++++++++++++++--- build.boot | 2 +- src/omniconf/core.clj | 46 ++++++++++++++++++++++++++++--------- test/omniconf/core_test.clj | 26 +++++++++++++++++++++ 5 files changed, 98 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89e5258..058132f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog -### master (unreleased) +### 0.4.2-SNAPSHOT (unreleased) + +- `:default` field for an option can now be a nullary function that is invoked + during the verification phase to generate the actual value based on other + config values. For now, this feature is opt-in to preserve the backwards + compatibility (for hypothetical cases where someone would use Omniconf + defaults to store functions). You need to call + `(cfg/enable-functions-as-defaults)` for this feature to work, but this will + change in the next minor version. ### 0.4.1 (2019-12-06) diff --git a/README.org b/README.org index e5b823a..653875e 100644 --- a/README.org +++ b/README.org @@ -1,6 +1,6 @@ * Omniconf - I [[CHANGELOG.md][https://img.shields.io/badge/-changelog-blue.svg]] I [[https://circleci.com/gh/grammarly/omniconf][https://circleci.com/gh/grammarly/omniconf/tree/master.png]] I [[https://clojars.org/com.grammarly/omniconf][14.4k downloads]] I + I [[CHANGELOG.md][https://img.shields.io/badge/-changelog-blue.svg]] I [[https://circleci.com/gh/grammarly/omniconf][https://circleci.com/gh/grammarly/omniconf/tree/master.png]] I [[https://clojars.org/com.grammarly/omniconf][16.4k downloads]] I Command-line arguments. Environment variables. Configuration files. Java properties. Almost every program requires some configuration which is usually @@ -178,6 +178,30 @@ value must be specified as a Clojure datatype, not as a string yet to be parsed. + The value for =:default= can be a nullary function used to generate the + actual default value. This function will be invoked during the verification + phase phase or on first direct access to the value, whichever comes first; + thus, default functions will have access to other config values provided by + the user. Note that you must invoke =(cfg/enable-functions-as-defaults)= + first for this feature to work. Example: + + #+BEGIN_SRC clojure + (cfg/enable-functions-as-defaults) + (cfg/define {:host {:type :string} + :port {:type :number} + :connstring {:type :string + :default #(str (cfg/get :host) ":" (cfg/get :port))}}) + (cfg/populate-from-map {:host "localhost", :port 8888}) + (cfg/get :connstring) ;; => "localhost:8888" + #+END_SRC + + Even if a config option has a functional default, its value can be + explicitly set from any configuration source to a normal value, and in that + case the default function won't be invoked. + + Make sure that you don't try to =cfg/get= an option with a function default + value before the values that function depends on are populated. + - =:required= --- if true, the value for this option must be provided, otherwise =verify= will fail. The value of this parameter can also be a nullary function: if the function returns true then the option value must @@ -332,7 +356,7 @@ doesn't expose an event-based API for this; but it's not too bad since you'd probably set the interval to 5-10 seconds, so the overhead of polling is not too big. Also, Omniconf would report setting only the values that actually - has changed. + have changed. #+BEGIN_SRC clojure ;; Poll values under /prod/myapp/ prefix (and all absolute :ssm-name values too) every 10 seconds. @@ -419,7 +443,7 @@ ** License - © Copyright 2016-2019 Grammarly, Inc. + © Copyright 2016-2020 Grammarly, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of diff --git a/build.boot b/build.boot index bcbd992..361eb0c 100644 --- a/build.boot +++ b/build.boot @@ -1,6 +1,6 @@ (task-options! pom {:project 'com.grammarly/omniconf - :version "0.4.1" + :version "0.4.2-SNAPSHOT" :description "Configuration library for Clojure that favors explicitness" :license {"Apache License, Version 2.0" "http://www.apache.org/licenses/LICENSE-2.0"} diff --git a/src/omniconf/core.clj b/src/omniconf/core.clj index ab4209c..b5c472f 100644 --- a/src/omniconf/core.clj +++ b/src/omniconf/core.clj @@ -30,11 +30,18 @@ "Function that is called to print debugging information and errors." (atom println)) +(def ^:private invoke-default-fns? (atom false)) + (defn set-logging-fn "Change `println` to a custom logging function that Omniconf will use." [fn] (reset! logging-fn fn)) +(defn enable-functions-as-defaults + "Allow invoking functions passed to :default field for the options." + [] + (reset! invoke-default-fns? true)) + (defn- running-in-repl? "Return true when this function is executed from within the REPL." [] @@ -123,11 +130,17 @@ [& ks] (let [ks (if (sequential? (first ks)) (first ks) ks) value (clj/get-in @config-values ks)] - (if (delay? value) - (let [calc-value (force value)] - (swap! config-values assoc-in ks calc-value) - calc-value) - value))) + (cond (delay? value) + (let [calc-value (force value)] + (swap! config-values assoc-in ks calc-value) + calc-value) + + (and (fn? value) (::delayed-default (meta value))) + (let [calc-value (value)] + (swap! config-values assoc-in ks calc-value) + calc-value) + + :else value))) (defn set "Set the `value` for the `ks` path in the current configuration. Path can be @@ -146,7 +159,9 @@ (special-action (get ks) value) value)] (swap! config-values assoc-in ks (if dt - (delay (dt new-value)) + (delay (dt (if (::delayed-default (meta new-value)) + (new-value) + new-value))) new-value)))) (defmacro with-options @@ -163,7 +178,10 @@ (let [walk (fn walk [prefix coll] (doseq [[kw-name spec] coll] (when-some [default (:default spec)] - (apply set (conj prefix kw-name default))) + (apply set (conj prefix kw-name + (if (and (fn? default) @invoke-default-fns?) + (with-meta default {::delayed-default true}) + default)))) (when-let [nested (:nested spec)] (walk (conj prefix kw-name) nested))))] (walk [] @config-scheme))) @@ -286,8 +304,10 @@ (cond (fn? required) "Conditionally required. " required "Required. " :else "") - (when default - (if secret "" default)))))) + (cond (nil? default) nil + secret "" + (and (fn? default) @invoke-default-fns?) "" + :else default))))) (defn populate-from-cmd "Fill configuration from command-line arguments." @@ -437,8 +457,12 @@ Make sure that com.grammarly/omniconf.ssm dependency is present on classpath.")) (swap! config-scheme dissoc :help) ;; Not needed anymore. (try-log (doseq [[kw-name spec] (flatten-and-transpose-scheme :kw @config-scheme)] - (let [value (get-in @config-values kw-name)] - ;; Not using `cfg/get` above to avoid forcing delays too early. + (let [value (get-in @config-values kw-name) + ;; Not using `cfg/get` above to avoid forcing delays too early. But + ;; forcing functional defaults. + value (if (and (fn? value) (::delayed-default (meta value))) + (get kw-name) + value)] (when-let [r (:required spec)] (when (and (if (fn? r) (r) r) (nil? value)) diff --git a/test/omniconf/core_test.clj b/test/omniconf/core_test.clj index 4679b50..98c8376 100644 --- a/test/omniconf/core_test.clj +++ b/test/omniconf/core_test.clj @@ -3,6 +3,8 @@ [clojure.java.io :as io] [clojure.test :refer :all])) +(cfg/enable-functions-as-defaults) + (cfg/define {:help {:description "prints this help message" :help-name "my-script" @@ -66,6 +68,13 @@ :more {:nested {:one {:type :string :default "one"} :two {:type :string}}}}} + :nested-default-fn {:nested {:width {:type :number + :default 10} + :height {:type :number + :default 20} + :area {:type :number + :default #(* (cfg/get :nested-default-fn :width) + (cfg/get :nested-default-fn :height))}}} :delayed-nested {:nested {:delayed {:default "foo" :delayed-transform #(str % "bar")}}}}) @@ -225,4 +234,21 @@ (testing "parsing sanity-check" (is (thrown? Exception (cfg/populate-from-cmd ["--nested-option" "foo"]))) (is (thrown? Exception (cfg/populate-from-cmd ["--integer-option" "garbage"])))) + + (testing "default functions" + (is (= {:area 200, :height 20, :width 10} (cfg/get :nested-default-fn))) + + (reset! @#'cfg/config-values (sorted-map)) + (#'cfg/fill-default-values) + (cfg/populate-from-file "test/omniconf/test-config.edn") + (cfg/populate-from-map {:nested-default-fn {:width 100 :height 200}}) + (cfg/verify) + (is (= {:area 20000, :height 200, :width 100} (cfg/get :nested-default-fn))) + + (reset! @#'cfg/config-values (sorted-map)) + (#'cfg/fill-default-values) + (cfg/populate-from-file "test/omniconf/test-config.edn") + (cfg/populate-from-map {:nested-default-fn {:width 100 :height 200 :area 42}}) + (cfg/verify) + (is (= {:area 42, :height 200, :width 100} (cfg/get :nested-default-fn)))) )