All Downloads are FREE. Search and download functionalities are using the official Maven repository.

pallet.shell.clj Maven / Gradle / Ivy

The newest version!
;;;; A modified version of clojure.java.shell, that allows for reading of
;;;; shell output as it is produced.

; Copyright (c) Rich Hickey. All rights reserved.
; The use and distribution terms for this software are covered by the
; Eclipse Public License 1.0 (http://opensource.org/licenses/eclipse-1.0.php)
; which can be found in the file epl-v10.html at the root of this distribution.
; By using this software in any fashion, you are agreeing to be bound by
; the terms of this license.
; You must not remove this notice, or any other, from this software.

(ns
  ^{:author "Chris Houser, Stuart Halloway, Hugo Duncan"
    :doc "Conveniently launch a sub-process providing its stdin and
collecting its stdout"}
  pallet.shell
  (:use [clojure.java.io :only (as-file copy)])
  (:require
   [clojure.tools.logging :as logging])
  (:import (java.io OutputStreamWriter ByteArrayOutputStream StringWriter)
           (java.nio.charset Charset)))

(def ^{:dynamic true} *sh-dir* nil)
(def ^{:dynamic true} *sh-env* nil)

(defmacro with-sh-dir
  "Sets the directory for use with sh, see sh for details."
  {:added "1.2"}
  [dir & forms]
  `(binding [*sh-dir* ~dir]
     ~@forms))

(defmacro with-sh-env
  "Sets the environment for use with sh, see sh for details."
  {:added "1.2"}
  [env & forms]
  `(binding [*sh-env* ~env]
     ~@forms))

(defn- aconcat
  "Concatenates arrays of given type."
  [type & xs]
  (let [target (make-array type (apply + (map count xs)))]
    (loop [i 0 idx 0]
      (when-let [a (nth xs i nil)]
        (System/arraycopy a 0 target idx (count a))
        (recur (inc i) (+ idx (count a)))))
    target))

(defn- parse-args
  [args]
  (let [default-encoding "UTF-8" ;; see sh doc string
        default-opts {:out-enc default-encoding
                      :in-enc default-encoding
                      :dir *sh-dir*
                      :env *sh-env*}
        [cmd opts] (split-with string? args)]
    [cmd (merge default-opts (apply hash-map opts))]))

(defn- ^"[Ljava.lang.String;" as-env-strings
  "Helper so that callers can pass a Clojure map for the :env to sh."
  [arg]
  (cond
   (nil? arg) nil
   (map? arg) (into-array String (map (fn [[k v]] (str (name k) "=" v)) arg))
   true arg))

(defn- stream-to-bytes
  [in]
  (with-open [bout (ByteArrayOutputStream.)]
    (copy in bout)
    (.toByteArray bout)))

(defn- stream-to-string
  ([in] (stream-to-string in (.name (Charset/defaultCharset))))
  ([in enc]
     (with-open [bout (StringWriter.)]
       (copy in bout :encoding enc)
       (.toString bout))))

(defn- stream-to-enc
  [stream enc]
  (if (= enc :bytes)
    (stream-to-bytes stream)
    (stream-to-string stream enc)))

(defn sh
  "Passes the given strings to Runtime.exec() to launch a sub-process.

  Options are

  :in      may be given followed by a String or byte array specifying input
           to be fed to the sub-process's stdin.
  :in-enc  option may be given followed by a String, used as a character
           encoding name (for example \"UTF-8\" or \"ISO-8859-1\") to
           convert the input string specified by the :in option to the
           sub-process's stdin.  Defaults to UTF-8.
           If the :in option provides a byte array, then the bytes are passed
           unencoded, and this option is ignored.
  :out-enc option may be given followed by :bytes, :stream or a String. If a
           String is given, it will be used as a character encoding
           name (for example \"UTF-8\" or \"ISO-8859-1\") to convert
           the sub-process's stdout to a String which is returned.
           If :bytes is given, the sub-process's stdout will be stored
           in a byte array and returned.  Defaults to UTF-8.
  :async   If true, returns a map with :out, :err and :proc keys, and
           the caller is responsible for reading these and
           the exit status.
  :env     override the process env with a map (or the underlying Java
           String[] if you are a masochist).
  :dir     override the process dir with a String or java.io.File.

  You can bind :env or :dir for multiple operations using with-sh-env
  and with-sh-dir.

  sh returns a map of
    :exit => sub-process's exit code
    :out  => sub-process's stdout (as byte[] or String)
    :err  => sub-process's stderr (String via platform default encoding)"
  {:added "1.2"}
  [& args]
  (let [[cmd opts] (parse-args args)
        proc (.exec (Runtime/getRuntime)
                    ^"[Ljava.lang.String;" (into-array String cmd)
                    (as-env-strings (:env opts))
                    (as-file (:dir opts)))
        {:keys [in in-enc out-enc async]} opts]
    (if in
      (future
       (if (instance? (class (byte-array 0)) in)
         (with-open [os (.getOutputStream proc)]
           (.write os ^"[B" in))
         (with-open [osw (OutputStreamWriter.
                          (.getOutputStream proc) ^String in-enc)]
           (.write osw ^String in))))
      (.close (.getOutputStream proc)))
    (if async
      {:out (.getInputStream proc)
       :err (.getErrorStream proc)
       :proc proc}
      (with-open [stdout (.getInputStream proc)
                  stderr (.getErrorStream proc)]
        (let [out (future (stream-to-enc stdout out-enc))
              err (future (stream-to-string stderr))
              exit-code (.waitFor proc)]
          {:exit exit-code :out @out :err @err})))))

;;; Pallet specific functionality
(def
  ^{:doc "Specifies the buffer size used to read the output stream.
    Defaults to 10K"}
  output-buffer-size (atom (* 1024 10)))

(def
  ^{:doc "Specifies the polling period for retrieving command output.
    Defaults to 1000ms."}
  output-poll-period (atom 1000))

(defn read-buffer [stream output-f]
  (let [buffer-size @output-buffer-size
        bytes (byte-array buffer-size)
        sb (StringBuilder.)]
    {:sb sb
     :reader (fn []
               (when (pos? (.available stream))
                 (let [num-read (.read stream bytes 0 buffer-size)
                       s (String. bytes 0 num-read "UTF-8")]
                   (output-f s)
                   (.append sb s)
                   s)))}))

(defn sh-script
  "Run a script on local machine.

   Command:
     :execv  sequence of command and arguments to be run (default /bin/bash).
     :in     standard input for the process.
   Options:
     :output-f  function to incrementally process output"
  [{:keys [execv in] :as command}
   {:keys [output-f] :as options}]
  (logging/tracef "sh-script %s" command)
  (if output-f
    (try
      (let [{:keys [out err proc]} (apply
                                    sh
                                    (concat
                                     (or execv ["/bin/bash"]) ;; TODO generalise
                                     [:in in :async true]))
            out-reader (read-buffer out output-f)
            err-reader (read-buffer err output-f)
            period @output-poll-period
            read-out (:reader out-reader)
            read-err (:reader err-reader)]
        (with-open [out out err err]
          (while (not (try (.exitValue proc)
                           (catch IllegalThreadStateException _)))
            (Thread/sleep period)
            (read-out)
            (read-err))
          (while (read-out))
          (while (read-err))
          (let [exit (.exitValue proc)]
            {:exit exit
             :out (str (:sb out-reader))
             :err (str (:sb err-reader))}))))
    (apply sh (concat (or execv ["/bin/bash"]) [:in in]))))




© 2015 - 2025 Weber Informatics LLC | Privacy Policy