|
| 1 | +; Copyright (c) Rich Hickey. All rights reserved. |
| 2 | +; The use and distribution terms for this software are covered by the |
| 3 | +; Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php) |
| 4 | +; which can be found in the file epl-v10.html at the root of this distribution. |
| 5 | +; By using this software in any fashion, you are agreeing to be bound by |
| 6 | +; the terms of this license. |
| 7 | +; You must not remove this notice, or any other, from this software. |
| 8 | + |
| 9 | +(ns clojure.java.process |
| 10 | + "A process invocation API wrapping the Java process API. |
| 11 | +
|
| 12 | + The primary function here is 'start' which starts a process and handles the |
| 13 | + streams as directed. It returns a map that contains keys to access the streams |
| 14 | + (if available) and the Java Process object. It is also deref-able to wait for |
| 15 | + process exit. |
| 16 | +
|
| 17 | + Helper functions are available to 'capture' the output of the process stdout |
| 18 | + and to wait for an 'ok?' non-error exit. The 'exec' function handles the common |
| 19 | + case of `start'ing a process, waiting for process exit, capture and return |
| 20 | + stdout." |
| 21 | + (:require |
| 22 | + [clojure.java.io :as jio] |
| 23 | + [clojure.string :as str]) |
| 24 | + (:import |
| 25 | + [java.io StringWriter File] |
| 26 | + [java.lang ProcessBuilder ProcessBuilder$Redirect Process] |
| 27 | + [java.util List] |
| 28 | + [clojure.lang IDeref IBlockingDeref])) |
| 29 | + |
| 30 | +(set! *warn-on-reflection* true) |
| 31 | + |
| 32 | +;; this is built into Java 9, backfilled here for Java 8 |
| 33 | +(def ^:private ^File null-file |
| 34 | + (delay |
| 35 | + (jio/file |
| 36 | + (if (.startsWith (System/getProperty "os.name") "Windows") |
| 37 | + "NUL" |
| 38 | + "/dev/null")))) |
| 39 | + |
| 40 | +(defn to-file |
| 41 | + "Coerce f to a file per clojure.java.io/file and return a ProcessBuilder.Redirect writing to the file. |
| 42 | + Set ':append' in opts to append. This can be passed to 'start' in :out or :err." |
| 43 | + {:added "1.12"} |
| 44 | + ^ProcessBuilder$Redirect [file & {:keys [append] :as opts}] |
| 45 | + (let [f (jio/file file)] |
| 46 | + (if append |
| 47 | + (ProcessBuilder$Redirect/appendTo f) |
| 48 | + (ProcessBuilder$Redirect/to f)))) |
| 49 | + |
| 50 | +(defn from-file |
| 51 | + "Coerce f to a file per clojure.java.io/file and return a ProcessBuilder.Redirect reading from the file. |
| 52 | + This can be passed to 'start' in :in." |
| 53 | + {:added "1.12"} |
| 54 | + ^ProcessBuilder$Redirect [file] |
| 55 | + (ProcessBuilder$Redirect/from (jio/file file))) |
| 56 | + |
| 57 | +(defn start |
| 58 | + "Starts an external command as args and optional leading opts map: |
| 59 | +
|
| 60 | + :in - a ProcessBuilder.Redirect (default = :pipe) or :inherit |
| 61 | + :out - a ProcessBuilder.Redirect (default = :pipe) or :inherit :discard |
| 62 | + :err - a ProcessBuilder.Redirect (default = :pipe) or :inherit :discard :stdout |
| 63 | + :dir - directory to run the command from, default=\".\" |
| 64 | + :env - {env-var value} of environment variables (all strings) |
| 65 | +
|
| 66 | + Returns an ILookup containing the java.lang.Process in :process and the |
| 67 | + streams :in :out :err. The map is also an IDeref that waits for process exit |
| 68 | + and returns the exit code." |
| 69 | + {:added "1.12"} |
| 70 | + [& opts+args] |
| 71 | + (let [[opts command] (if (map? (first opts+args)) |
| 72 | + [(first opts+args) (rest opts+args)] |
| 73 | + [{} opts+args]) |
| 74 | + {:keys [in out err dir env] |
| 75 | + :or {in :pipe, out :pipe, err :pipe, dir "."}} opts |
| 76 | + pb (ProcessBuilder. ^List command) |
| 77 | + to-redirect (fn to-redirect |
| 78 | + [x] |
| 79 | + (case x |
| 80 | + :pipe ProcessBuilder$Redirect/PIPE |
| 81 | + :inherit ProcessBuilder$Redirect/INHERIT |
| 82 | + :discard (ProcessBuilder$Redirect/to @null-file) |
| 83 | + ;; in Java 9+, just use ProcessBuilder$Redirect/DISCARD |
| 84 | + x))] |
| 85 | + (.directory pb (jio/file (or dir "."))) |
| 86 | + (when in (.redirectInput pb ^ProcessBuilder$Redirect (to-redirect in))) |
| 87 | + (when out (.redirectOutput pb ^ProcessBuilder$Redirect (to-redirect out))) |
| 88 | + (cond |
| 89 | + (= err :stdout) (.redirectErrorStream pb) |
| 90 | + err (.redirectError pb ^ProcessBuilder$Redirect (to-redirect err))) |
| 91 | + (when env |
| 92 | + (let [pb-env (.environment pb)] |
| 93 | + (run! (fn [[k v]] (.put pb-env k v)) env))) |
| 94 | + (let [proc (.start pb) |
| 95 | + m {:process proc |
| 96 | + :in (.getOutputStream proc) |
| 97 | + :out (.getInputStream proc) |
| 98 | + :err (.getErrorStream proc)}] |
| 99 | + (reify |
| 100 | + clojure.lang.ILookup |
| 101 | + (valAt [_ key] (get m key)) |
| 102 | + (valAt [_ key not-found] (get m key not-found)) |
| 103 | + |
| 104 | + IDeref |
| 105 | + (deref [_] (.waitFor proc)) |
| 106 | + |
| 107 | + IBlockingDeref |
| 108 | + (deref [_ timeout unit] (.waitFor proc timeout unit)))))) |
| 109 | + |
| 110 | +(defn ok? |
| 111 | + "Given the map returned from 'start', wait for the process to exit |
| 112 | + and then return true on success" |
| 113 | + {:added "1.12"} |
| 114 | + [process-map] |
| 115 | + (zero? (.waitFor ^Process (:process process-map)))) |
| 116 | + |
| 117 | +(defn capture |
| 118 | + "Read from input-stream until EOF and return a String (or nil if 0 length). |
| 119 | + Takes same opts as clojure.java.io/copy - :buffer and :encoding" |
| 120 | + {:added "1.12"} |
| 121 | + [input-stream & opts] |
| 122 | + (let [writer (StringWriter.)] |
| 123 | + (apply jio/copy input-stream writer opts) |
| 124 | + (let [s (str/trim (.toString writer))] |
| 125 | + (when-not (zero? (.length s)) |
| 126 | + s)))) |
| 127 | + |
| 128 | +(defn exec |
| 129 | + "Execute a command and on successful exit, return the captured output, |
| 130 | + else throw RuntimeException. Args are the same as 'start' and options |
| 131 | + if supplied override the default 'exec' settings." |
| 132 | + {:added "1.12"} |
| 133 | + [& opts+args] |
| 134 | + (let [[opts command] (if (map? (first opts+args)) |
| 135 | + [(first opts+args) (rest opts+args)] |
| 136 | + [{} opts+args]) |
| 137 | + opts (merge opts {:err :inherit})] |
| 138 | + (let [state (apply start opts command) |
| 139 | + out-promise (promise) |
| 140 | + capture-fn #(deliver out-promise (capture (:out state)))] |
| 141 | + (doto (Thread. ^Runnable capture-fn) (.setDaemon true) (.start)) |
| 142 | + (if (ok? state) |
| 143 | + @out-promise |
| 144 | + (throw (RuntimeException. (str "Process failed with exit=" (.exitValue ^Process (:process state))))))))) |
| 145 | + |
| 146 | +(comment |
| 147 | + ;; shell out and inherit the i/o |
| 148 | + (start {:out :inherit, :err :stdout} "ls" "-l") |
| 149 | + |
| 150 | + ;; write out and err to files, wait for process to exit, return exit code |
| 151 | + @(start {:out (to-file "out") :err (to-file "err")} "ls" "-l") |
| 152 | + |
| 153 | + ;; capture output to string |
| 154 | + (-> (start "ls" "-l") :out capture) |
| 155 | + |
| 156 | + ;; with exec |
| 157 | + (exec "ls" "-l") |
| 158 | + |
| 159 | + ;; read input from file |
| 160 | + (exec {:in (from-file "deps.edn")} "wc" "-l") |
| 161 | + ) |
0 commit comments