diff --git a/Makefile b/Makefile index 68eaea5e..ab4d4c40 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,7 @@ GROUP_ID ?= com.yetanalytics ARTIFACT_ID ?= datasim -VERSION ?= 0.3.0 -MAIN_NS ?= com.yetanalytics.datasim.main +VERSION ?= 0.4.0 clean: rm -rf target @@ -11,7 +10,7 @@ clean: target/bundle/datasim_cli.jar: mkdir -p target/bundle rm -f pom.xml - clojure -X:depstar uberjar :no-pom false :sync-pom true :aliases '[:cli]' :aot true :group-id $(GROUP_ID) :artifact-id $(ARTIFACT_ID)-cli :version '"$(VERSION)"' :jar target/bundle/datasim_cli.jar :main-class com.yetanalytics.datasim.main + clojure -X:depstar uberjar :no-pom false :sync-pom true :aliases '[:cli]' :aot true :group-id $(GROUP_ID) :artifact-id $(ARTIFACT_ID)-cli :version '"$(VERSION)"' :jar target/bundle/datasim_cli.jar :main-class com.yetanalytics.datasim.cli rm -f pom.xml target/bundle/datasim_server.jar: # no AOT for this one @@ -35,7 +34,7 @@ target/bundle: target/bundle/bin target/bundle/datasim_cli.jar target/bundle/dat bundle: target/bundle - +# Tests test-unit: clojure -Adev:cli:run-tests @@ -44,16 +43,16 @@ test-unit-onyx: clojure -Adev:cli:onyx:run-onyx-tests test-cli: - clojure -A:cli:run -p dev-resources/profiles/cmi5/fixed.json -a dev-resources/personae/simple.json -m dev-resources/models/simple.json -o dev-resources/parameters/simple.json validate-input dev-resources/input/simple.json + clojure -A:cli:run validate-input -p dev-resources/profiles/cmi5/fixed.json -a dev-resources/personae/simple.json -m dev-resources/models/simple.json -o dev-resources/parameters/simple.json -v dev-resources/input/simple.json test-cli-comprehensive: - clojure -A:cli:run -i dev-resources/input/simple.json validate-input dev-resources/input/simple.json + clojure -A:cli:run validate-input -i dev-resources/input/simple.json -v dev-resources/input/simple.json test-cli-output: - clojure -A:cli:run -i dev-resources/input/simple.json generate + clojure -A:cli:run generate -i dev-resources/input/simple.json test-bundle-output: bundle - cd target/bundle; bin/run.sh -i ../../dev-resources/input/simple.json generate + cd target/bundle; bin/run.sh generate -i ../../dev-resources/input/simple.json validate-template: AWS_PAGER="" aws cloudformation validate-template --template-body file://template/0_vpc.yml diff --git a/README.md b/README.md index e487e46a..2fd829fd 100644 --- a/README.md +++ b/README.md @@ -189,55 +189,80 @@ In the form of a CLI application, DATASIM takes the inputs listed above as JSON For the CLI the first step is to build the project so that it can be run on a JVM. +``` make bundle +``` Now that we have this, navigate to target/bundle and run +``` bin/run.sh +``` + +With no commands or `--help` it will give you the list of subcommands: + +| Subcommand | Description +| --- | --- +| `validate-input` | Validate the input and create an input JSON file. +| `generate` | Generate statements from input and print to stdout. +| `generate-post` | Generate statements from input and POST them to an LRS. + +The `validate-input` subcommand is used to validate and combine input files. These are its arguments: + +| Argument | Description +| --- | --- +| `-p, --profile URI` | The location of an xAPI profile, can be used multiple times. +| `-a, --actor-personae URI` | The location of an Actor Personae document indicating the actors in the sim. +| `-m, --models URI` | The location of an Persona Model document, to describe alignments and overrides for the personae. +| `-o, -parameters URI` | The location of simulation parameters document. Uses the current time and timezone as defaults if they are not present. (The "o" stands for "options.") +| `-i, --input URI` | The location of a JSON file containing a combined simulation input spec. +| `-v, --validated-input URI` | The location of the validated input to be produced. + +The `generate` subcommand is used to generate statements from an input and print them to standard output. The inputs can be a combined `--input` location or a combination of `-p`, `-a`, `-m`, and `-o` inputs. The additional arguments are as follows: +| Argument | Description +| --- | --- +| `--seed SEED` | An integer seed to override the one in the input spec. Use -1 for a random seed. +| `--actor AGENT_ID` | Pass an agent id in the format 'mbox::mailto:[email]' to select actor(s) +| `--gen-profile IRI` | Only generate based on primary patterns in the given profile. May be given multiple times to include multiple profiles. +| `--gen-pattern IRI` | Only generate based on the given primary pattern. May be given multiple times to include multiple patterns. + +The `generate-post` subcommand is used to generate statements from an input and POST them to an LRS. In addition to the `generate` arguments, this subcommands has these additional arguments: +| Argument | Description | Default +| --- | --- | --- +| `-E, --endpoint URI` | The xAPI endpoint of an LRS to POST to, ex: `https://lrs.example.org/xapi` | N/A +| `-U, --username URI` | The Basic Auth username for the LRS. | N/A +| `-P, --password URI` | The Basic Auth password for the LRS. | N/A +| `-B, --batch-size SIZE` | The batch size, i.e. how many statements to send at a time, for POSTing. | `25` +| `-C, --concurrency CONC` | The max concurrency of the LRS POST pipeline. | `4` +| `-L, --post-limit LIMIT` | The total number of statements that will be sent to the LRS before termination. Overrides sim params. Set to -1 for no limit. | `999` +| `-A, --[no-]async` | Async operation. Use `--no-async` if statements must be sent to server in timestamp order. | `true` + +The following is an example of a simple run. We first create a combined input file using `validate-input`: +``` +bin/run.sh validate-input \ + -p dev-resources/profile/cmi5/fixed.json \ + -a dev-resources/personae/simple.json \ + -m dev-resources/models/simple.json \ + -o dev-resources/parameters/simple.json \ + -v dev-resources/input/simple.json +``` -With no commands or `--help` it will give you the list of parameters: - - -p, --profile URI The location of an xAPI profile, can be used multiple times. - -a, --actor-personae URI The location of an Actor Personae document indicating the actors in the sim, can be used multiple times. - -m, --models URI The location of an Personae Model Document. - -o, --parameters URI {...} The location of a Sim Parameters Document. - -i, --input URI The location of a JSON file containing a combined simulation input spec. - --seed SEED An integer seed to override the one in the input spec. Use -1 for random. - --actor AGENT_ID Pass an agent id in the format mbox::malto:bob@example.org to select actor(s) - -E, --endpoint URI The xAPI endpoint of an LRS to POST to, ex: https://lrs.example.org/xapi - -U, --username URI The basic auth username for the LRS you wish to post to - -P, --password URI The basic auth password for the LRS you wish to post to - -B, --batch-size SIZE 25 The batch size for POSTing to an LRS - -C, --concurrency CONC 4 The max concurrency of the LRS POST pipeline - -L, --post-limit LIMIT 999 The total number of statements that will be sent to the LRS before termination. Overrides sim params. Set to -1 for no limit. - -A, --[no-]async Async operation. Use --no-async if statements must be sent to server in timestamp order. - --gen-profile IRI Only generate based on primary patterns in the given profile. May be given multiple times to include multiple profiles. - --gen-pattern IRI Only generate based on the given primary pattern. May be given multiple times to include multiple patterns. - -h, --help Show this list. - -For a simple run, we will first create the simulation specification by combining the inputs, validating them, and outputting to a simulation input file like so: - - bin/run.sh -p [profile json file] \ - -a [actors json filename] \ - -m [models json filename] \ - -o [sim params json filename] \ - validate-input [desired output filename] - -Once we have that simulation specification, we can run the sim just from that like so: - - bin/run.sh -i dev-resources/input/simple.json generate - -###### CLI LRS POST - -If we have an endpoint and credentials for an LRS we can direcly POST the statements to it: - - bin/run.sh -i dev-resources/input/simple.json \ - -E [LRS xAPI endpoint ex. https://lrs.example.org/xapi] \ - -U [basic auth username] \ - -P [basic auth password] \ - -B [batch size] \ - -L [limit statements posted, -1 is no limit] \ - generate post +Once we have that sim specification, we can run the simulation using the `generate`: +``` +bin/run.sh generate -i dev-resources/input/simple.json +``` + +If we have an endpoint and credentials for an LRS we can directly POST the simulated statements using `generate-post`: + +``` +bin/run.sh generate-post \ + -i dev-resources/input/simple.json \ + -E http://localhost:8080/xapi \ + -U username \ + -P password \ + -B 20 \ + -L 1000 \ +``` As statements are successfully sent to the LRS their IDs will be sent to stdout. diff --git a/deps.edn b/deps.edn index 6993b00a..404c5371 100644 --- a/deps.edn +++ b/deps.edn @@ -18,7 +18,8 @@ :aliases {:cli {:extra-paths ["src/cli"] :extra-deps {org.clojure/tools.cli {:mvn/version "1.0.219"}}} - :run {:main-opts ["-m" "com.yetanalytics.datasim.main"]} + ;; TODO: More CLI-specific name for :run alias + :run {:main-opts ["-m" "com.yetanalytics.datasim.cli"]} :dev {:extra-paths ["dev-resources" "src/dev"] :extra-deps {incanter/incanter-core {:mvn/version "1.9.3"} incanter/incanter-charts {:mvn/version "1.9.3"} diff --git a/src/cli/com/yetanalytics/datasim/cli.clj b/src/cli/com/yetanalytics/datasim/cli.clj new file mode 100644 index 00000000..714141b0 --- /dev/null +++ b/src/cli/com/yetanalytics/datasim/cli.clj @@ -0,0 +1,38 @@ +(ns com.yetanalytics.datasim.cli + (:require [clojure.tools.cli :as cli] + [com.yetanalytics.datasim.cli.input :as cli-input] + [com.yetanalytics.datasim.cli.generate :as cli-gen] + [com.yetanalytics.datasim.cli.util :as u]) + (:gen-class)) + +(def top-level-options + [["-h" "--help" "Display the top-level help guide."]]) + +(def top-level-summary + (str "Usage: 'datasim ' or 'datasim [-h|--help]'.\n" + "\n" + "where the subcommand can be one of the following:\n" + " validate-input: Validate the input and create an input JSON file.\n" + " generate: Generate statements from input and print to stdout.\n" + " generate-post: Generate statements from input and POST them to an LRS.\n" + "\n" + "Run 'datasim --help' for more info on each subcommand.")) + +(defn -main [& args] + (let [{:keys [options arguments summary errors]} + (cli/parse-opts args top-level-options + :in-order true + :summary-fn (fn [_] top-level-summary)) + [subcommand & rest-args] + arguments] + (cond + (:help options) + (println summary) + (not subcommand) + (print "No subcommand entered.\n\n" summary) + :else + (case subcommand + "validate-input" (cli-input/validate-input rest-args) + "generate" (cli-gen/generate rest-args) + "generate-post" (cli-gen/generate-post rest-args) + (u/bail! errors))))) diff --git a/src/cli/com/yetanalytics/datasim/cli/generate.clj b/src/cli/com/yetanalytics/datasim/cli/generate.clj new file mode 100644 index 00000000..317f833f --- /dev/null +++ b/src/cli/com/yetanalytics/datasim/cli/generate.clj @@ -0,0 +1,201 @@ +(ns com.yetanalytics.datasim.cli.generate + "CLI options and functions for statement generation (including + statement POSTing)." + (:require [clojure.core.async :as a] + [com.yetanalytics.datasim :as ds] + [com.yetanalytics.datasim.cli.util :as u] + [com.yetanalytics.datasim.cli.input :as cli-input] + [com.yetanalytics.datasim.client :as client] + [com.yetanalytics.datasim.util.io :as dio])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; CLI Input Options +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def seed-desc + "An integer seed to override the one in the input spec. Use -1 for random.") + +(def select-agent-desc + "Pass an agent IFI in the format 'mbox::malito:[email]' to select actor(s)") + +(def gen-profiles-desc + "Only generate based on primary patterns in the given profile. May be given multiple times to include multiple profiles.") + +(def gen-patterns-desc + "Only generate based on the given primary pattern. May be given multiple times to include multiple patterns.") + +(def generate-options + [[nil "--seed SEED" "Input seed" + :id :override-seed + :parse-fn parse-long + :validate [int? "Seed is not an integer."] + :desc seed-desc] + [nil "--actor AGENT_IFI" "Selected Actor IFIs" + :id :select-agents + :multi true + :update-fn (fnil conj #{}) + :desc select-agent-desc] + [nil "--gen-profile IRI" "Select Profile IRIs" + :id :genProfiles + :assoc-fn u/conj-param-input + :desc gen-profiles-desc] + [nil "--gen-pattern IRI" "Select Pattern IRIs" + :id :genPatterns + :assoc-fn u/conj-param-input + :desc gen-patterns-desc]]) + +(def endpoint-desc + "The xAPI endpoint of an LRS to POST to, ex: https://lrs.example.org/xapi") + +(def endpoint-missing + "[-E|--endpoint] argument is required for POST.") + +(def username-desc + "The Basic Auth username for the LRS.") + +(def password-desc + "The Basic Auth password for the LRS.") + +(def batch-size-desc + "The batch size, i.e. how many statements to send at a time, for POSTing.") + +(def concurrency-desc + "The max concurrency of the LRS POST pipeline.") + +(def post-limit-desc + "The total number of statements that will be sent to the LRS before termination. Overrides sim params. Set to -1 for no limit.") + +(def post-options + [["-E" "--endpoint URI" "LRS Endpoint for POST" + :id :endpoint + :desc endpoint-desc + :missing endpoint-missing] + ["-U" "--username URI" "LRS username" + :id :username + :desc username-desc] + ["-P" "--password URI" "LRS password" + :id :password + :desc password-desc] + ["-B" "--batch-size SIZE" "LRS POST batch size" + :id :batch-size + :default 25 + :parse-fn parse-long + :validate [int? "Batch size is not an integer."] + :desc batch-size-desc] + ["-C" "--concurrency CONC" "LRS POST concurrency" + :id :concurrency + :default 4 + :parse-fn parse-long + :validate [int? "Concurrency is not an integer."] + :desc concurrency-desc] + ["-L" "--post-limit LIMIT" "LRS POST total statement limit" + :id :post-limit + :default 999 + :parse-fn parse-long + :validate [int? "POST statement limit is not an integer."] + :desc post-limit-desc] + ["-A" "--[no-]async" "Async operation. Use --no-async if statements must be sent to server in timestamp order." + :id :async + :default true]]) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Generate Functions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- print-sim! + [input {:keys [select-agents]}] + (doseq [statement (ds/generate-seq input :select-agents select-agents)] + (dio/write-json-stdout statement :key-fn? false))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Generate POST Functions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- post-async! + [input post-options post-limit select-agents concurrency] + (let [gen-input (cond-> input + ;; when async, we just use the post + ;; limit as the max + (not= post-limit -1) + (assoc-in [:parameters :max] post-limit)) + sim-chan (ds/generate-seq-async + gen-input + :select-agents select-agents) + result-chan (client/post-statements-async + post-options + sim-chan + :concurrency concurrency)] + (loop [] + (when-let [[tag ret] (a/> (ds/generate-seq + input + :select-agents select-agents) + (not= post-limit -1) + (take post-limit)) + {:keys [fail]} (client/post-statements post-options statements)] + (when (not-empty fail) + (u/bail! (for [{:keys [status error]} fail] + (client/post-error-message status error)))))) + +(defn- post-sim! + [input options] + (let [{:keys [endpoint + username + password + batch-size + concurrency + post-limit + select-agents + async]} + options + post-options + {:endpoint endpoint + :batch-size batch-size + :username username + :password password}] + (if async + (post-async! input post-options post-limit select-agents concurrency) + (post-sync! input post-options post-limit select-agents)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Subcommands +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn generate + "Generate statements based on simulation `args` and print them to stdout." + [args] + (u/exec-subcommand + (concat cli-input/input-options + generate-options + [["-h" "--help"]]) + (fn [options] + (let [input (cli-input/sim-input options)] + (cli-input/assert-valid-input input) + (print-sim! input options))) + args)) + +(defn generate-post + "Generate statements based on simulation `args` and POST them to an LRS + (whose endpoint and other properties are also in `args`)." + [args] + (u/exec-subcommand + (concat cli-input/input-options + generate-options + post-options + [["-h" "--help"]]) + (fn [options] + (let [input (cli-input/sim-input options)] + (cli-input/assert-valid-input input) + (post-sim! input options))) + args)) diff --git a/src/cli/com/yetanalytics/datasim/cli/input.clj b/src/cli/com/yetanalytics/datasim/cli/input.clj new file mode 100644 index 00000000..2ab64dcf --- /dev/null +++ b/src/cli/com/yetanalytics/datasim/cli/input.clj @@ -0,0 +1,151 @@ +(ns com.yetanalytics.datasim.cli.input + "CLI options and functions for sim inputs (including input validation)." + (:require [com.yetanalytics.datasim.cli.util :as u] + [com.yetanalytics.datasim.input :as input] + [com.yetanalytics.datasim.util.random :as random] + [com.yetanalytics.datasim.util.errors :as errors])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; CLI Input Options +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def profiles-desc + "The location of an xAPI profile, can be used multiple times.") + +(def personae-array-desc + "The location of an Actor Personae document indicating the actors in the sim.") + +(def models-desc + "The location of an Persona Model document, to describe alignments and overrides for the personae.") + +(def parameters-desc + "The location of simulation parameters document. Uses the current time and timezone as defaults if they are not present.") + +(def input-desc + "The location of a JSON file containing a combined simulation input spec.") + +(def validated-input-desc + "The location of the validated input to be produced. If not provided, the validated input will be printed to stdout instead.") + +;; NOTE: For the `validate-input` subcommand, we skip tools.cli validation and +;; do more in-depth validation involving combined inputs. +(def validate-input-options + [["-p" "--profile URI" "xAPI Profile Location" + :id :profiles + :desc profiles-desc + :parse-fn (partial input/from-location :profile :json) + :assoc-fn u/conj-input] + ["-a" "--actor-personae URI" "Actor Personae Location" + :id :personae-array + :desc personae-array-desc + :parse-fn (partial input/from-location :personae :json) + :assoc-fn u/conj-input] + ["-m" "--models URI" "Persona Model Location" + :id :models + :desc models-desc + :parse-fn (partial input/from-location :models :json)] + ["-o" "--parameters URI" "Parameters Location" + :id :parameters + :desc parameters-desc + :parse-fn (partial input/from-location :parameters :json)] + ["-i" "--input URI" "Pre-validated input location" + :id :input + :desc input-desc + :parse-fn (partial input/from-location :input :json)] + ["-v" "--validated-input URI" "Validated combined input location" + :id :validated-input + :desc validated-input-desc]]) + +(def input-options + [["-p" "--profile URI" "xAPI Profile Location" + :id :profiles + :desc profiles-desc + :parse-fn (partial input/from-location :profile :json) + :validate [(partial input/validate-throw :profile) + "Failed to validate profile."] + :assoc-fn u/conj-input] + ["-a" "--actor-personae URI" "Actor Personae Location" + :id :personae-array + :desc personae-array-desc + :parse-fn (partial input/from-location :personae :json) + :validate [(partial input/validate-throw :personae) + "Failed to validate personae."] + :assoc-fn u/conj-input] + ["-m" "--models URI" "Persona Model Location" + :id :models + :desc models-desc + :parse-fn (partial input/from-location :models :json) + :validate [(partial input/validate-throw :models) + "Failed to validate Models."]] + ["-o" "--parameters URI" "Parameters Location" + :id :parameters + :desc parameters-desc + :parse-fn (partial input/from-location :parameters :json) + :validate [(partial input/validate-throw :parameters) + "Failed to validate Parameters."]] + ["-i" "--input URI" "Pre-validated input location" + :id :input + :desc input-desc + :parse-fn (partial input/from-location :input :json) + :validate [(partial input/validate-throw :input) + "Failed to validate input."]]]) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Helper Functions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn sim-input + "Given `options`, return a map of just the simulation options + (either just `:input`, or a combo `:profiles`, `:personae-array`, + `:parameters`, and `:models`)." + [options] + (let [sim-options (select-keys options [:input + :profiles + :personae-array + :parameters + :models]) + {:keys [override-seed]} options] + (cond-> (or (:input sim-options) + (dissoc sim-options :input)) + override-seed + (assoc-in [:parameters :seed] + (if (= -1 override-seed) + (random/rand-unbound-int (random/rng)) + override-seed))))) + +(defn assert-valid-input + "Perform validation on `input` and fail w/ early termination if + it is not valid. + + When this is called, we should have valid individual inputs. However, there + may be cross-validation that needs to happen, so we compose the + comprehensive spec from the options and check that." + [input] + (when-let [errors (not-empty (input/validate :input input))] + (u/bail! (errors/map-coll->strs errors)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Subcommand +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- write-input! + ([input] + (input/to-out input :json)) + ([input location] + (input/to-file input :json location) + (println (format "Input specification written to %s" location)))) + +(defn validate-input + "Combine and validate the arguments given in `args` and write them + to `location` (if `location` is provided)." + [args] + (u/exec-subcommand + (conj validate-input-options + ["-h" "--help"]) + (fn [{:keys [validated-input] :as options}] + (let [input (sim-input options)] + (assert-valid-input input) + (if validated-input + (write-input! input validated-input) + (write-input! input)))) + args)) diff --git a/src/cli/com/yetanalytics/datasim/cli/util.clj b/src/cli/com/yetanalytics/datasim/cli/util.clj new file mode 100644 index 00000000..5c60b463 --- /dev/null +++ b/src/cli/com/yetanalytics/datasim/cli/util.clj @@ -0,0 +1,36 @@ +(ns com.yetanalytics.datasim.cli.util + (:require [clojure.tools.cli :as cli] + [com.yetanalytics.datasim.util.io :as dio])) + +(defn conj-input + "Conj the input (either a Profile or a personae) to return a + vector of inputs, e.g. `-p profile-1 -p profile-2` becomes + `[profile-1 profile-2]`." + [opt-map id v] + (update opt-map id (fnil conj []) v)) + +(defn conj-param-input + "Add a parameter named by id." + [opt-map id v] + (update-in opt-map [:parameters id] (fnil conj []) v)) + +(defn bail! + "Print error messages to standard error and exit." + [errors & {:keys [status] + :or {status 1}}] + (dio/println-err-coll errors) + (System/exit status)) + +(defn exec-subcommand + "Execute `exec-fn` for a subcommand with arguments `args`, where the + valid options are `cli-options`." + [cli-options exec-fn args] + (let [{:keys [options summary errors]} + (cli/parse-opts args cli-options) + {:keys [help]} + options + errors* (not-empty errors)] + (cond + help (println summary) + errors* (bail! errors*) + :else (exec-fn options)))) diff --git a/src/cli/com/yetanalytics/datasim/main.clj b/src/cli/com/yetanalytics/datasim/main.clj deleted file mode 100644 index 3abd8a83..00000000 --- a/src/cli/com/yetanalytics/datasim/main.clj +++ /dev/null @@ -1,274 +0,0 @@ -(ns com.yetanalytics.datasim.main - (:require [clojure.core.async :as a] - [clojure.tools.cli :as cli] - [com.yetanalytics.datasim :as ds] - [com.yetanalytics.datasim.client :as client] - [com.yetanalytics.datasim.input :as input] - [com.yetanalytics.datasim.input.parameters :as params] - [com.yetanalytics.datasim.util.random :as random] - [com.yetanalytics.datasim.util.errors :as errors] - [com.yetanalytics.datasim.util.io :as dio]) - (:gen-class)) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; CLI Input -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- conj-input - "Conj the input (either a Profile or a personae) to return a - vector of inputs, e.g. `-p profile-1 -p profile-2` becomes - `[profile-1 profile-2]`." - [opt-map id v] - (update opt-map id (fnil conj []) v)) - -(defn- conj-param-input - "Add a parameter named by id." - [opt-map id v] - (update-in opt-map [:parameters id] (fnil conj []) v)) - -(defn cli-options - "Generate CLI options, skipping validation if `validate?` is false" - [validate?] - [["-p" "--profile URI" "xAPI Profile Location" - :id :profiles - :desc "The location of an xAPI profile, can be used multiple times." - :parse-fn (partial input/from-location :profile :json) - :validate (if validate? - [(partial input/validate-throw :profile) - "Failed to validate profile."] - []) - :assoc-fn conj-input] - ["-a" "--actor-personae URI" "Actor Personae Location" - :id :personae-array - :desc "The location of an Actor Personae document indicating the actors in the sim." - :parse-fn (partial input/from-location :personae :json) - :validate (if validate? - [(partial input/validate-throw :personae) - "Failed to validate personae."] - []) - :assoc-fn conj-input] - ["-m" "--models URI" "Persona Model Location" - :id :models - :desc "The location of an Persona Model document, to describe alignments and overrides for the personae." - :parse-fn (partial input/from-location :models :json) - :validate (if validate? - [(partial input/validate-throw :models) - "Failed to validate Models."] - [])] - ["-o" "--parameters URI" "Sim Parameters Location" - :id :parameters - :desc "The location of a Sim Parameters Document." - :parse-fn (partial input/from-location :parameters :json) - :validate (if validate? - [(partial input/validate-throw :parameters) - "Failed to validate Parameters."] - []) - :default (params/apply-defaults)] - - ["-i" "--input URI" "Combined Simulation input" - :id :input - :desc "The location of a JSON file containing a combined simulation input spec." - :parse-fn (partial input/from-location :input :json) - :validate (if validate? - [(partial input/validate-throw :input) - "Failed to validate input."] - [])] - [nil "--seed SEED" "Override input seed" - :id :override-seed - :parse-fn parse-long - :validate [int? "Seed is not an integer."] - :desc "An integer seed to override the one in the input spec. Use -1 for random."] - [nil "--actor AGENT_ID" "Select actor(s) by agent ID" - :id :select-agents - :multi true - :update-fn (fnil conj #{}) - :desc "Pass an agent id in the format mbox::malto:bob@example.org to select actor(s)"] - ;; POST options - ["-E" "--endpoint URI" "LRS Endpoint for POST" - :id :endpoint - :desc "The xAPI endpoint of an LRS to POST to, ex: https://lrs.example.org/xapi"] - ["-U" "--username URI" "LRS Basic auth username" - :id :username - :desc "The basic auth username for the LRS you wish to post to"] - ["-P" "--password URI" "LRS Basic auth password" - :id :password - :desc "The basic auth password for the LRS you wish to post to"] - ["-B" "--batch-size SIZE" "LRS POST batch size" - :id :batch-size - :default 25 - :parse-fn parse-long - :validate [int? "Batch size is not an integer."] - :desc "The batch size for POSTing to an LRS"] - ["-C" "--concurrency CONC" "LRS POST concurrency" - :id :concurrency - :default 4 - :parse-fn parse-long - :validate [int? "Concurrency is not an integer."] - :desc "The max concurrency of the LRS POST pipeline"] - ["-L" "--post-limit LIMIT" "LRS POST total statement limit" - :id :post-limit - :default 999 - :parse-fn parse-long - :validate [int? "POST statement limit is not an integer."] - :desc "The total number of statements that will be sent to the LRS before termination. Overrides sim params. Set to -1 for no limit."] - ["-A" "--[no-]async" "Async operation. Use --no-async if statements must be sent to server in timestamp order." - :id :async - :default true] - [nil "--gen-profile IRI" "Only generate based on primary patterns in the given profile. May be given multiple times to include multiple profiles." - :id :genProfiles - :assoc-fn conj-param-input] - [nil "--gen-pattern IRI" "Only generate based on the given primary pattern. May be given multiple times to include multiple patterns." - :id :genPatterns - :assoc-fn conj-param-input] - ;; Help - ["-h" "--help"]]) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; CLI Run -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn bail! - "Print error messages to std error and exit." - [errors & {:keys [status] - :or {status 1}}] - (dio/println-err-coll errors) - (System/exit status)) - -(defn- sim-input [options] - (let [sim-options (select-keys options [:input - :profiles - :personae-array - :parameters - :models]) - {:keys [override-seed]} options] - (cond-> (or (:input sim-options) - (dissoc sim-options :input)) - override-seed - (assoc-in [:parameters :seed] - (if (= -1 override-seed) - (random/rand-unbound-int (random/rng)) - override-seed))))) - -;; When this is called, we should have valid individual inputs. However, there -;; may be cross-validation that needs to happen, so we compose the -;; comprehensive spec from the options and check that. -(defn- assert-valid-input [input] - (when-let [errors (not-empty (input/validate :input input))] - (bail! (errors/map-coll->strs errors)))) - -;; POST to LRS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- post-async! - [input post-options post-limit select-agents concurrency] - (let [gen-input (cond-> input - ;; when async, we just use the post - ;; limit as the max - (not= post-limit -1) - (assoc-in [:parameters :max] post-limit)) - sim-chan (ds/generate-seq-async - gen-input - :select-agents select-agents) - result-chan (client/post-statements-async - post-options - sim-chan - :concurrency concurrency)] - (loop [] - (when-let [[tag ret] (a/> (ds/generate-seq - input - :select-agents select-agents) - (not= post-limit -1) - (take post-limit)) - {:keys [fail]} (client/post-statements post-options statements)] - (when (not-empty fail) - (bail! (for [{:keys [status error]} fail] - (client/post-error-message status error)))))) - -(defn- post-sim! - [input options] - (let [{:keys [endpoint - username - password - batch-size - concurrency - post-limit - select-agents - async]} - options] - ;; Endpoint is required when posting - (when-not endpoint - (bail! ["-E / --endpoint REQUIRED for post."])) - ;; Endpoint present - OK - (let [post-options {:endpoint endpoint - :batch-size batch-size - :username username - :password password}] - (if async - (post-async! input post-options post-limit select-agents concurrency) - (post-sync! input post-options post-limit select-agents))))) - -;; Print sim to stdout ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- print-sim! - "Generate statement seqs and writes them to stdout." - [input {:keys [select-agents]}] - (doseq [statement (ds/generate-seq input :select-agents select-agents)] - (dio/write-json-stdout statement :key-fn? false))) - -;; Write Input mode ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- write-input! - ([input] - (input/to-out input :json)) - ([input location] - (input/to-file input :json location) - (println (format "Input specification written to %s" location)))) - -;; Main function ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn -main [& args] - (let [;; If the verb is "validate-input", we - ;; skip tools.cli validation and do a - ;; more in-depth one. - cli-opts - (cli-options (not= "validate-input" (last args))) - {:keys [options arguments summary errors]} - (cli/parse-opts args cli-opts) - [?command & rest-args] - arguments] - (cond - ;; Invalid CLI input - (seq errors) - (bail! errors) - ;; Help - (or (empty? args) (:help options)) - (println summary) - :else - (let [input (sim-input options)] - (assert-valid-input input) - (case ?command - ;; Where the CLI will actually perform generation - "generate" - (if (= "post" (first rest-args)) - (post-sim! input options) - (print-sim! input options)) - ;; If they just want to validate and we're this far, we're done. - ;; Just return the input spec as JSON. - "validate-input" - (if-some [location (first rest-args)] - (write-input! input location) - (write-input! input)) - ;; No command - (do (println "No command entered.") - (println summary))))))) diff --git a/src/test/com/yetanalytics/datasim/xapi/statement_test.clj b/src/test/com/yetanalytics/datasim/xapi/statement_test.clj index fd04f977..d8ce9dc5 100644 --- a/src/test/com/yetanalytics/datasim/xapi/statement_test.clj +++ b/src/test/com/yetanalytics/datasim/xapi/statement_test.clj @@ -1209,7 +1209,7 @@ statements (gen-weighted-statements {:verbs weights}) verb-ids (map #(get-in % ["verb" "id"]) statements) verb-freqs (frequencies verb-ids)] - ;; See `datasim.math.random-test` for details on how the expected means + ;; See `datasim.util.random-test` for details on how the expected means ;; (800 and 200, respectively) are computed. (is (= 793 (get verb-freqs "https://w3id.org/xapi/adl/verbs/abandoned"))) (is (= 207 (get verb-freqs "https://w3id.org/xapi/adl/verbs/satisfied"))) diff --git a/src/test/com/yetanalytics/datasim_test.clj b/src/test/com/yetanalytics/datasim_test.clj index db95fbc0..82b602eb 100644 --- a/src/test/com/yetanalytics/datasim_test.clj +++ b/src/test/com/yetanalytics/datasim_test.clj @@ -319,7 +319,7 @@ objects (map get-object result) obj-count (count objects) obj-freq (frequencies objects) - ;; See `datasim.math.random` for math details + ;; See `datasim.util.random` for math details mean-1* (- 1 (/ 0.3 (* 2 0.7))) mean-2* (- 1 mean-1*) mean-1 (* obj-count mean-1*)