This is an automated email from the ASF dual-hosted git repository.
paulk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new 9dcf1ba9a9 GROOVY-11901: Renovate process handling methods
9dcf1ba9a9 is described below
commit 9dcf1ba9a9e1734a83bea4fb134b54f05a9abf33
Author: Paul King <[email protected]>
AuthorDate: Sat Apr 4 08:23:52 2026 +1000
GROOVY-11901: Renovate process handling methods
---
.../groovy/runtime/ProcessGroovyMethods.java | 329 +++++++++++++++++++-
.../org/codehaus/groovy/runtime/ProcessResult.java | 86 ++++++
src/spec/doc/_working-with-io.adoc | 155 ++++++++--
src/spec/test/gdk/WorkingWithIOSpecTest.groovy | 117 ++++++-
src/test/groovy/groovy/execute/ExecuteTest.groovy | 343 +++++++++++++++++++++
5 files changed, 1000 insertions(+), 30 deletions(-)
diff --git
a/src/main/java/org/codehaus/groovy/runtime/ProcessGroovyMethods.java
b/src/main/java/org/codehaus/groovy/runtime/ProcessGroovyMethods.java
index 1297621717..fd20400fc2 100644
--- a/src/main/java/org/codehaus/groovy/runtime/ProcessGroovyMethods.java
+++ b/src/main/java/org/codehaus/groovy/runtime/ProcessGroovyMethods.java
@@ -20,12 +20,15 @@ package org.codehaus.groovy.runtime;
import groovy.lang.Closure;
import groovy.lang.GroovyRuntimeException;
+import groovy.transform.NamedParam;
+import groovy.transform.NamedParams;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
+import java.nio.file.Path;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
@@ -34,6 +37,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.StringTokenizer;
+import java.util.concurrent.TimeUnit;
/**
* This class defines new groovy methods which appear on normal JDK
@@ -282,6 +286,103 @@ public class ProcessGroovyMethods extends
DefaultGroovyMethodsSupport {
}
}
+ /**
+ * Executes the process and waits for it to complete, capturing
+ * the standard output, standard error, and exit code into a
+ * {@link ProcessResult}.
+ *
+ * <pre>
+ * def result = "ls -la".execute().waitForResult()
+ * if (result.ok) println result.out
+ * else System.err.println result.err
+ * </pre>
+ *
+ * @param self a Process
+ * @return a ProcessResult containing stdout, stderr, and exit code
+ * @throws InterruptedException if the current thread is interrupted.
+ * @since 6.0.0
+ */
+ public static ProcessResult waitForResult(Process self) throws
InterruptedException {
+ StringBuilder sout = new StringBuilder();
+ StringBuilder serr = new StringBuilder();
+ Thread tout = consumeProcessOutputStream(self, sout);
+ Thread terr = consumeProcessErrorStream(self, serr);
+ boolean interrupted = false;
+ try {
+ try { tout.join(); } catch (InterruptedException ignore) {
interrupted = true; }
+ try { terr.join(); } catch (InterruptedException ignore) {
interrupted = true; }
+ try { self.waitFor(); } catch (InterruptedException ignore) {
interrupted = true; }
+ closeStreams(self);
+ } finally {
+ if (interrupted) Thread.currentThread().interrupt();
+ }
+ return new ProcessResult(sout.toString(), serr.toString(),
self.exitValue());
+ }
+
+ /**
+ * Executes the process and waits for it to complete within the given
timeout,
+ * capturing the standard output, standard error, and exit code into a
+ * {@link ProcessResult}. If the process does not complete within the
timeout,
+ * it is forcibly destroyed.
+ *
+ * <pre>
+ * def result = "cmd".execute().waitForResult(30, TimeUnit.SECONDS)
+ * </pre>
+ *
+ * @param self a Process
+ * @param timeout the maximum time to wait
+ * @param unit the time unit of the timeout argument
+ * @return a ProcessResult containing stdout, stderr, and exit code
+ * @throws InterruptedException if the current thread is interrupted.
+ * @since 6.0.0
+ */
+ public static ProcessResult waitForResult(Process self, long timeout,
TimeUnit unit) throws InterruptedException {
+ StringBuilder sout = new StringBuilder();
+ StringBuilder serr = new StringBuilder();
+ Thread tout = consumeProcessOutputStream(self, sout);
+ Thread terr = consumeProcessErrorStream(self, serr);
+ boolean interrupted = false;
+ try {
+ try {
+ boolean finished = self.waitFor(timeout, unit);
+ if (!finished) {
+ self.destroyForcibly();
+ self.waitFor();
+ }
+ } catch (InterruptedException ignore) {
+ interrupted = true;
+ self.destroyForcibly();
+ try { self.waitFor(); } catch (InterruptedException ie) { /*
best effort */ }
+ }
+ try { tout.join(); } catch (InterruptedException ignore) {
interrupted = true; }
+ try { terr.join(); } catch (InterruptedException ignore) {
interrupted = true; }
+ closeStreams(self);
+ } finally {
+ if (interrupted) Thread.currentThread().interrupt();
+ }
+ return new ProcessResult(sout.toString(), serr.toString(),
self.exitValue());
+ }
+
+ /**
+ * Registers a closure to be called when the process terminates.
+ * Uses {@link Process#onExit()} to asynchronously notify completion.
+ *
+ * <pre>
+ * "cmd".execute().onExit { proc ->
+ * println "Exited with code: ${proc.exitValue()}"
+ * }
+ * </pre>
+ *
+ * @param self a Process
+ * @param action a closure to call with the completed Process
+ * @return the Process to allow chaining
+ * @since 6.0.0
+ */
+ public static Process onExit(final Process self, final Closure action) {
+ self.onExit().thenAccept(action::call);
+ return self;
+ }
+
/**
* Gets the error stream from a process and reads it
* to keep the process from blocking due to a full buffer.
@@ -433,6 +534,43 @@ public class ProcessGroovyMethods extends
DefaultGroovyMethodsSupport {
return pipeTo(left, right);
}
+ /**
+ * Creates a native OS pipeline from a list of commands, using
+ * {@link ProcessBuilder#startPipeline(List)}. Each element in the list
+ * can be a String (tokenized on whitespace), a List (elements converted
+ * to Strings via {@code toString()}), a String array, or a {@link
ProcessBuilder}
+ * for full control.
+ *
+ * <pre>
+ * def procs = ["ps aux", "grep java", "wc -l"].pipeline()
+ * println procs.last().text
+ * </pre>
+ *
+ * @param commands a list of commands, each a String, List, String[], or
ProcessBuilder
+ * @return the list of started {@link Process} instances
+ * @throws IOException if an IOException occurs.
+ * @since 6.0.0
+ */
+ public static List<Process> pipeline(final List<?> commands) throws
IOException {
+ List<ProcessBuilder> builders = new ArrayList<>();
+ for (Object cmd : commands) {
+ if (cmd instanceof ProcessBuilder) {
+ builders.add((ProcessBuilder) cmd);
+ } else if (cmd instanceof String) {
+ builders.add(toProcessBuilder((String) cmd));
+ } else if (cmd instanceof String[]) {
+ builders.add(toProcessBuilder((String[]) cmd));
+ } else if (cmd instanceof List) {
+ builders.add(toProcessBuilder((List) cmd));
+ } else {
+ throw new IllegalArgumentException(
+ "pipeline() elements must be String, List, String[],
or ProcessBuilder; got: "
+ + (cmd == null ? "null" :
cmd.getClass().getName()));
+ }
+ }
+ return ProcessBuilder.startPipeline(builders);
+ }
+
/**
* A Runnable which waits for a process to complete together with a
notification scheme
* allowing another thread to wait a maximum number of seconds for the
process to complete
@@ -530,10 +668,53 @@ public class ProcessGroovyMethods extends
DefaultGroovyMethodsSupport {
}
}
+ /**
+ * Creates a {@link ProcessBuilder} from a command line String,
+ * tokenized on whitespace. This bridges Groovy's convenient string
+ * syntax with ProcessBuilder's full feature set.
+ *
+ * <pre>
+ * def pb = "find . -name '*.groovy'".toProcessBuilder()
+ * pb.directory(new File("/project"))
+ * pb.redirectErrorStream(true)
+ * def proc = pb.start()
+ * </pre>
+ *
+ * @param self a command line String
+ * @return a ProcessBuilder ready to be configured further or started
+ * @since 6.0.0
+ */
+ public static ProcessBuilder toProcessBuilder(final String self) {
+ return new ProcessBuilder(tokenize(self));
+ }
+
+ /**
+ * Creates a {@link ProcessBuilder} from a command array.
+ *
+ * @param commandArray an array of Strings containing the command name and
parameters
+ * @return a ProcessBuilder ready to be configured further or started
+ * @since 6.0.0
+ */
+ public static ProcessBuilder toProcessBuilder(final String[] commandArray)
{
+ return new ProcessBuilder(commandArray);
+ }
+
+ /**
+ * Creates a {@link ProcessBuilder} from a command list. The toString()
method
+ * is called for each item in the list to convert into a resulting String.
+ *
+ * @param commands a list containing the command name and parameters
+ * @return a ProcessBuilder ready to be configured further or started
+ * @since 6.0.0
+ */
+ public static ProcessBuilder toProcessBuilder(final List commands) {
+ return new ProcessBuilder(stringify(commands));
+ }
+
/**
* Executes the command specified by <code>self</code> as a command-line
process.
* <p>For more control over Process construction you can use
- * <code>java.lang.ProcessBuilder</code>.
+ * <code>java.lang.ProcessBuilder</code> or {@link
#toProcessBuilder(String)}.
*
* @param self a command line String
* @return the Process which has just started for this command line
representation
@@ -735,6 +916,96 @@ public class ProcessGroovyMethods extends
DefaultGroovyMethodsSupport {
return Runtime.getRuntime().exec(stringify(commands), stringify(envp),
dir);
}
+ /**
+ * Executes the command specified by <code>self</code> with options
provided
+ * as named parameters. Supported options:
+ * <ul>
+ * <li><b>dir</b>: (File, Path, or String) the working directory</li>
+ * <li><b>env</b>: (Map) environment variables to add (merged with
inherited environment)</li>
+ * <li><b>redirectErrorStream</b>: (boolean) merge stderr into
stdout</li>
+ * <li><b>inheritIO</b>: (boolean) inherit the parent process I/O
streams</li>
+ * <li><b>outputFile</b>: (File, Path, or String) redirect stdout to a
file</li>
+ * <li><b>errorFile</b>: (File, Path, or String) redirect stderr to a
file</li>
+ * <li><b>inputFile</b>: (File, Path, or String) redirect stdin from a
file</li>
+ * <li><b>appendOutput</b>: (File, Path, or String) append stdout to a
file</li>
+ * <li><b>appendError</b>: (File, Path, or String) append stderr to a
file</li>
+ * </ul>
+ *
+ * <pre>
+ * "make test".execute(dir: new File("/project"), env: [CI: "true"])
+ * "cmd".execute(redirectErrorStream: true)
+ * </pre>
+ *
+ * @param self a command line String
+ * @param options a Map of options to configure the process
+ * @return the Process which has just started
+ * @throws IOException if an IOException occurs.
+ * @since 6.0.0
+ */
+ public static Process execute(final String self, @NamedParams({
+ @NamedParam(value = "dir"),
+ @NamedParam(value = "env", type = Map.class),
+ @NamedParam(value = "redirectErrorStream", type = Boolean.class),
+ @NamedParam(value = "inheritIO", type = Boolean.class),
+ @NamedParam(value = "outputFile"),
+ @NamedParam(value = "errorFile"),
+ @NamedParam(value = "inputFile"),
+ @NamedParam(value = "appendOutput"),
+ @NamedParam(value = "appendError")
+ }) Map<String, Object> options) throws IOException {
+ return configureProcessBuilder(toProcessBuilder(self),
options).start();
+ }
+
+ /**
+ * Executes the command specified by the given <code>String</code> array
with options
+ * provided as named parameters.
+ *
+ * @param commandArray an array of Strings containing the command name and
parameters
+ * @param options a Map of options to configure the process (see {@link
#execute(String, Map)})
+ * @return the Process which has just started
+ * @throws IOException if an IOException occurs.
+ * @since 6.0.0
+ * @see #execute(String, Map)
+ */
+ public static Process execute(final String[] commandArray, @NamedParams({
+ @NamedParam(value = "dir"),
+ @NamedParam(value = "env", type = Map.class),
+ @NamedParam(value = "redirectErrorStream", type = Boolean.class),
+ @NamedParam(value = "inheritIO", type = Boolean.class),
+ @NamedParam(value = "outputFile"),
+ @NamedParam(value = "errorFile"),
+ @NamedParam(value = "inputFile"),
+ @NamedParam(value = "appendOutput"),
+ @NamedParam(value = "appendError")
+ }) Map<String, Object> options) throws IOException {
+ return configureProcessBuilder(toProcessBuilder(commandArray),
options).start();
+ }
+
+ /**
+ * Executes the command specified by the given list with options
+ * provided as named parameters.
+ *
+ * @param commands a list containing the command name and parameters
+ * @param options a Map of options to configure the process (see {@link
#execute(String, Map)})
+ * @return the Process which has just started
+ * @throws IOException if an IOException occurs.
+ * @since 6.0.0
+ * @see #execute(String, Map)
+ */
+ public static Process execute(final List commands, @NamedParams({
+ @NamedParam(value = "dir"),
+ @NamedParam(value = "env", type = Map.class),
+ @NamedParam(value = "redirectErrorStream", type = Boolean.class),
+ @NamedParam(value = "inheritIO", type = Boolean.class),
+ @NamedParam(value = "outputFile"),
+ @NamedParam(value = "errorFile"),
+ @NamedParam(value = "inputFile"),
+ @NamedParam(value = "appendOutput"),
+ @NamedParam(value = "appendError")
+ }) Map<String, Object> options) throws IOException {
+ return configureProcessBuilder(toProcessBuilder(commands),
options).start();
+ }
+
// just simple parsing otherwise use ProcessBuilder directly
private static List<String> tokenize(final String command) {
StringTokenizer st = new StringTokenizer(command);
@@ -754,4 +1025,60 @@ public class ProcessGroovyMethods extends
DefaultGroovyMethodsSupport {
return result;
}
+ private static File toFile(Object obj) {
+ if (obj instanceof File) return (File) obj;
+ if (obj instanceof Path) return ((Path) obj).toFile();
+ return new File(obj.toString());
+ }
+
+ private static ProcessBuilder configureProcessBuilder(ProcessBuilder pb,
Map<String, Object> options) {
+ if (options == null || options.isEmpty()) return pb;
+
+ Object dir = options.get("dir");
+ if (dir != null) {
+ pb.directory(toFile(dir));
+ }
+
+ Object env = options.get("env");
+ if (env instanceof Map) {
+ Map<String, String> pbEnv = pb.environment();
+ ((Map<?, ?>) env).forEach((k, v) -> pbEnv.put(String.valueOf(k),
String.valueOf(v)));
+ }
+
+ if (Boolean.TRUE.equals(options.get("redirectErrorStream"))) {
+ pb.redirectErrorStream(true);
+ }
+
+ if (Boolean.TRUE.equals(options.get("inheritIO"))) {
+ pb.inheritIO();
+ }
+
+ Object outputFile = options.get("outputFile");
+ if (outputFile != null) {
+ pb.redirectOutput(toFile(outputFile));
+ }
+
+ Object appendOutput = options.get("appendOutput");
+ if (appendOutput != null) {
+
pb.redirectOutput(ProcessBuilder.Redirect.appendTo(toFile(appendOutput)));
+ }
+
+ Object errorFile = options.get("errorFile");
+ if (errorFile != null) {
+ pb.redirectError(toFile(errorFile));
+ }
+
+ Object appendError = options.get("appendError");
+ if (appendError != null) {
+
pb.redirectError(ProcessBuilder.Redirect.appendTo(toFile(appendError)));
+ }
+
+ Object inputFile = options.get("inputFile");
+ if (inputFile != null) {
+ pb.redirectInput(toFile(inputFile));
+ }
+
+ return pb;
+ }
+
}
diff --git a/src/main/java/org/codehaus/groovy/runtime/ProcessResult.java
b/src/main/java/org/codehaus/groovy/runtime/ProcessResult.java
new file mode 100644
index 0000000000..826e8fb397
--- /dev/null
+++ b/src/main/java/org/codehaus/groovy/runtime/ProcessResult.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.codehaus.groovy.runtime;
+
+/**
+ * Captures the result of executing an external process, including
+ * the standard output, standard error, and exit code.
+ *
+ * <pre>
+ * def result = "ls -la".execute().waitForResult()
+ * assert result.ok
+ * println result.out
+ * </pre>
+ *
+ * @since 6.0.0
+ * @see ProcessGroovyMethods#waitForResult(Process)
+ */
+public class ProcessResult {
+ private final String out;
+ private final String err;
+ private final int exitCode;
+
+ public ProcessResult(final String out, final String err, final int
exitCode) {
+ this.out = out;
+ this.err = err;
+ this.exitCode = exitCode;
+ }
+
+ /**
+ * Returns the standard output of the process as a String.
+ */
+ public String getOut() {
+ return out;
+ }
+
+ /**
+ * Returns the standard error of the process as a String.
+ */
+ public String getErr() {
+ return err;
+ }
+
+ /**
+ * Returns the exit code of the process.
+ */
+ public int getExitCode() {
+ return exitCode;
+ }
+
+ /**
+ * Returns {@code true} if the process exited with code 0.
+ */
+ public boolean isOk() {
+ return exitCode == 0;
+ }
+
+ @Override
+ public String toString() {
+ return "ProcessResult(exitCode=" + exitCode
+ + ", out=" + abbreviate(out)
+ + ", err=" + abbreviate(err) + ")";
+ }
+
+ private static String abbreviate(final String s) {
+ if (s == null) return "null";
+ String trimmed = s.trim();
+ if (trimmed.length() <= 60) return "\"" + trimmed + "\"";
+ return "\"" + trimmed.substring(0, 57) + "...\"";
+ }
+}
\ No newline at end of file
diff --git a/src/spec/doc/_working-with-io.adoc
b/src/spec/doc/_working-with-io.adoc
index ea46d334f7..e79755134c 100644
--- a/src/spec/doc/_working-with-io.adoc
+++ b/src/spec/doc/_working-with-io.adoc
@@ -215,8 +215,50 @@
include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=object_in_out,indent=0]
The previous section described how easy it was to deal with files, readers or
streams in Groovy. However in domains
like system administration or devops it is often required to communicate with
external processes.
-Groovy provides a simple way to execute command line processes. Simply
-write the command line as a string and call the `execute()` method.
+Groovy provides `execute` and `toProcessBuilder` methods as two mechanisms to
execute external processes.
+In simple cases, calls to `execute()`
+are invoked directly on a command string:
+
+[source,groovy]
+----
+"javac src/main/java/*.java".execute()
+----
+
+For more sophisticated workloads, a _named-parameter_ variant configures
+the process with options like working directory,
+environment, and stream redirection:
+
+[source,groovy]
+----
+def result = "javac src/main/java/*.java".execute(
+ dir: new File('/myproject'),
+ redirectErrorStream: true
+).waitForResult(2, TimeUnit.MINUTES)
+
+if (!result.ok) System.err.println result.out
+----
+
+The `toProcessBuilder()` method lets you piggyback directly onto
+the `ProcessBuilder` fluent API:
+
+[source,groovy]
+----
+def result = "javac src/main/java/*.java".toProcessBuilder()
+ .directory(new File('/myproject'))
+ .redirectErrorStream(true)
+ .start()
+ .waitForResult(2, TimeUnit.MINUTES)
+
+if (!result.ok) System.err.println result.out
+----
+
+The `execute()` and `start()` methods return a `java.lang.Process` instance,
and
+`waitForResult()` returns a `ProcessResult` capturing stdout, stderr, and the
exit code.
+The following sections cover each approach in detail.
+
+=== Simple execution
+
+Simply write the command line as a string and call the `execute()` method.
E.g., on a *nix machine (or a Windows machine with appropriate *nix
commands installed), you can execute this:
@@ -227,11 +269,7 @@
include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=process_list_files,indent
<1> executes the `ls` command in an external process
<2> consume the output of the command and retrieve the text
-The `execute()` method returns a `java.lang.Process` instance which will
-subsequently allow the in/out/err streams to be processed and the exit
-value from the process to be inspected etc.
-
-e.g. here is the same command as above but we will now process the
+Here is the same command as above but we will now process the
resulting stream a line at a time:
[source,groovy]
@@ -254,28 +292,80 @@ machine and write:
include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=dir_windows,indent=0]
----
-you will receive an `IOException` saying _Cannot run program "dir":
+you will receive an `IOException` saying _Cannot run program "dir":
CreateProcess error=2, The system cannot find the file specified._
This is because `dir` is built-in to the Windows shell (`cmd.exe`) and
-can’t be run as a simple executable. Instead, you will need to write:
+can't be run as a simple executable. Instead, you will need to write:
[source,groovy]
-----------------------------------
+----
include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=dir_windows_fixed,indent=0]
-----------------------------------
+----
+
+=== Named parameters for execute
-Also, because this functionality currently makes use of
-`java.lang.Process` undercover, the deficiencies of that class
-must be taken into consideration. In particular, the javadoc
-for this class says:
+The `execute()` method accepts named parameters to configure the process.
+Supported options include `dir` (working directory), `env` (environment
+variables), `redirectErrorStream`, `inheritIO`, `outputFile`, `errorFile`,
+`inputFile`, `appendOutput`, and `appendError`.
+The file-related options (`dir`, `outputFile`, `errorFile`, `inputFile`,
+`appendOutput`, `appendError`) accept a `File`, `Path`, or `String`:
+
+[source,groovy]
+----
+include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=execute_named_params,indent=0]
+----
+
+To merge stderr into stdout:
+
+[source,groovy]
+----
+include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=execute_redirect_error,indent=0]
+----
+
+=== Using ProcessBuilder directly
+
+For full control over process construction, the `toProcessBuilder()` method
+creates a `java.lang.ProcessBuilder` from a command string, array, or list:
+
+[source,groovy]
+----
+include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=to_process_builder,indent=0]
+----
+
+=== Capturing output, error, and exit code
+
+A common need is to capture a process's standard output, standard error,
+and exit code all at once. The `waitForResult()` method returns a
+`ProcessResult` with `out`, `err`, `exitCode`, and `ok` properties:
+
+[source,groovy]
+----
+include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=waitforresult,indent=0]
+----
+
+If the process produces an error:
+
+[source,groovy]
+----
+include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=waitforresult_error,indent=0]
+----
+
+A timeout variant is available that forcibly kills the process if it
+exceeds the given duration:
+
+[source,groovy]
+----
+include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=waitforresult_timeout,indent=0]
+----
+
+=== Stream handling
-________________________________________________________________________
Because some native platforms only provide limited buffer size for
standard input and output streams, failure to promptly write the input
stream or read the output stream of the subprocess may cause the
-subprocess to block, and even deadlock
-________________________________________________________________________
+subprocess to block, and even deadlock.
Because of this, Groovy provides some additional helper methods which
make stream handling for processes easier.
@@ -284,17 +374,18 @@ Here is how to gobble all of the output (including the
error stream
output) from your process:
[source,groovy]
-----------------------------------
+----
include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=consumeoutput,indent=0]
-----------------------------------
+----
There are also variations of `consumeProcessOutput` that make use of
`StringBuffer`, `InputStream`,
`OutputStream` etc... For a complete list, please read the
http://docs.groovy-lang.org/latest/html/groovy-jdk/java/lang/Process.html[GDK
API for java.lang.Process]
-In addition, there is a `pipeTo` command (mapped to `|`
-to allow overloading) which lets the output stream of one process be fed
-into the input stream of another process.
+=== Piping
+
+The `pipeTo` command (mapped to `|` to allow overloading) lets the output
+stream of one process be fed into the input stream of another process.
Here are some examples of use:
@@ -309,3 +400,21 @@
include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=pipe_example_1,indent=0]
----
include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=pipe_example_2,indent=0]
----
+
+For more robust piping, the `pipeline()` method uses native OS-level piping
+via `ProcessBuilder.startPipeline()`:
+
+[source,groovy]
+----
+include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=pipeline,indent=0]
+----
+
+=== Asynchronous notifications
+
+The `onExit` method registers a closure to be called when the process
+terminates:
+
+[source,groovy]
+----
+include::../test/gdk/WorkingWithIOSpecTest.groovy[tags=on_exit,indent=0]
+----
diff --git a/src/spec/test/gdk/WorkingWithIOSpecTest.groovy
b/src/spec/test/gdk/WorkingWithIOSpecTest.groovy
index 50d2234424..0126c81c75 100644
--- a/src/spec/test/gdk/WorkingWithIOSpecTest.groovy
+++ b/src/spec/test/gdk/WorkingWithIOSpecTest.groovy
@@ -23,6 +23,9 @@ import groovy.io.FileVisitResult
import groovy.transform.CompileStatic
import org.junit.jupiter.api.Test
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
import static org.junit.jupiter.api.Assumptions.assumeTrue
final class WorkingWithIOSpecTest {
@@ -310,8 +313,8 @@ Fin.''')
void testProcess1() {
if (unixlike) {
// tag::process_list_files[]
- def process = "ls -l".execute() // <1>
- println "Found text ${process.text}" // <2>
+ def process = "ls -l".execute() // <1>
+ println "Files: $process.text" // <2>
// end::process_list_files[]
assert process instanceof Process
}
@@ -319,14 +322,14 @@ Fin.''')
try {
// tag::dir_windows[]
def process = "dir".execute()
- println "${process.text}"
+ println "Files: $process.text"
// end::dir_windows[]
// we do not check that the expected exception is really
thrown,
// because the command succeeds if PATH contains cygwin
- } catch (e) {
+ } catch (ignore) {
// tag::dir_windows_fixed[]
def process = "cmd /c dir".execute()
- println "${process.text}"
+ println "Files: $process.text"
// end::dir_windows_fixed[]
}
}
@@ -403,7 +406,109 @@ Fin.''')
}
}
- public static class Person implements Serializable {
+ @Test
+ void testWaitForResult() {
+ assumeUnixLikeSystem()
+
+ // tag::waitforresult[]
+ def result = "echo Hello World".execute().waitForResult()
+ assert result.ok
+ assert result.out.trim() == 'Hello World'
+ assert result.exitCode == 0
+ // end::waitforresult[]
+ }
+
+ @Test
+ void testWaitForResultTimeout() {
+ assumeUnixLikeSystem()
+
+ // tag::waitforresult_timeout[]
+ def result = "echo Fast".execute().waitForResult(10, TimeUnit.SECONDS)
+ assert result.ok
+ assert result.out.trim() == 'Fast'
+ // end::waitforresult_timeout[]
+ }
+
+ @Test
+ void testWaitForResultCapturesError() {
+ assumeUnixLikeSystem()
+
+ // tag::waitforresult_error[]
+ def result = "ls /nonexistent_path_xxx".execute().waitForResult()
+ assert !result.ok
+ assert result.err.length() > 0
+ // end::waitforresult_error[]
+ }
+
+ @Test
+ void testExecuteWithNamedParams() {
+ assumeUnixLikeSystem()
+
+ doInTmpDir { b ->
+ File tmpDir = b.baseDir
+ b.'test.txt'('hello')
+
+ // tag::execute_named_params[]
+ def process = "ls".execute(dir: tmpDir)
+ def result = process.waitForResult()
+ assert result.ok
+ assert result.out.contains('test.txt')
+ // end::execute_named_params[]
+ }
+ }
+
+ @Test
+ void testExecuteWithRedirectErrorStream() {
+ assumeUnixLikeSystem()
+
+ // tag::execute_redirect_error[]
+ def process = "ls /nonexistent_path_xxx".execute(redirectErrorStream:
true)
+ def output = process.text
+ assert output.length() > 0 // error message now in stdout
+ // end::execute_redirect_error[]
+ }
+
+ @Test
+ void testToProcessBuilder() {
+ assumeUnixLikeSystem()
+
+ // tag::to_process_builder[]
+ def pb = "echo Hello".toProcessBuilder()
+ pb.redirectErrorStream(true)
+ def process = pb.start()
+ assert process.text.trim() == 'Hello'
+ // end::to_process_builder[]
+ }
+
+ @Test
+ void testPipeline() {
+ assumeUnixLikeSystem()
+
+ // tag::pipeline[]
+ def procs = ["echo one two three", "wc -w"].pipeline()
+ def result = procs.last().waitForResult()
+ assert result.ok
+ assert result.out.trim() == '3'
+ // end::pipeline[]
+ }
+
+ @Test
+ void testOnExit() {
+ assumeUnixLikeSystem()
+
+ // tag::on_exit[]
+ def latch = new CountDownLatch(1)
+ def exitCode = -1
+ "echo done".execute().onExit { proc ->
+ exitCode = proc.exitValue()
+ latch.countDown()
+ }
+ latch.await(10, TimeUnit.SECONDS)
+ assert exitCode == 0
+ // end::on_exit[]
+ }
+
+ static class Person implements Serializable {
String name
int age
}
diff --git a/src/test/groovy/groovy/execute/ExecuteTest.groovy
b/src/test/groovy/groovy/execute/ExecuteTest.groovy
index 4c87004a60..d1d90d9b82 100644
--- a/src/test/groovy/groovy/execute/ExecuteTest.groovy
+++ b/src/test/groovy/groovy/execute/ExecuteTest.groovy
@@ -18,8 +18,13 @@
*/
package groovy.execute
+import org.codehaus.groovy.runtime.ProcessResult
import org.junit.jupiter.api.Test
+import java.nio.file.Path
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
/**
* Cross platform tests for the DGM#execute() family of methods.
*/
@@ -33,6 +38,13 @@ final class ExecuteTest {
return cmd
}
+ private String getEchoCmd() {
+ if (System.properties.'os.name'.startsWith('Windows ')) {
+ return "cmd /c echo"
+ }
+ return "echo"
+ }
+
@Test
void testExecuteCommandLineProcessUsingAString() {
StringBuffer sbout = new StringBuffer()
@@ -167,4 +179,335 @@ final class ExecuteTest {
assert out.toString().contains('hello')
assert process.exitValue() == 0
}
+
+ // --- toProcessBuilder ---
+
+ @Test
+ void testToProcessBuilderFromString() {
+ def pb = cmd.toProcessBuilder()
+ assert pb instanceof ProcessBuilder
+ def process = pb.start()
+ process.waitForProcessOutput()
+ assert process.exitValue() == 0
+ }
+
+ @Test
+ void testToProcessBuilderFromArray() {
+ def pb = cmd.split(' ').toProcessBuilder()
+ assert pb instanceof ProcessBuilder
+ def process = pb.start()
+ process.waitForProcessOutput()
+ assert process.exitValue() == 0
+ }
+
+ @Test
+ void testToProcessBuilderFromList() {
+ def pb = Arrays.asList(cmd.split(' ')).toProcessBuilder()
+ assert pb instanceof ProcessBuilder
+ def process = pb.start()
+ process.waitForProcessOutput()
+ assert process.exitValue() == 0
+ }
+
+ // --- waitForResult ---
+
+ @Test
+ void testWaitForResultFromString() {
+ ProcessResult result = "$echoCmd hello".execute().waitForResult()
+ assert result.ok
+ assert result.exitCode == 0
+ assert result.out.trim().contains('hello')
+ assert result.err.isEmpty()
+ }
+
+ @Test
+ void testWaitForResultFromArray() {
+ ProcessResult result = (echoCmd.split(' ') +
'hello').execute().waitForResult()
+ assert result.ok
+ assert result.out.trim().contains('hello')
+ }
+
+ @Test
+ void testWaitForResultFromList() {
+ ProcessResult result = ([*echoCmd.split(' '), 'hello'] as
List).execute().waitForResult()
+ assert result.ok
+ assert result.out.trim().contains('hello')
+ }
+
+ @Test
+ void testWaitForResultWithTimeout() {
+ ProcessResult result = "$echoCmd fast".execute().waitForResult(30,
TimeUnit.SECONDS)
+ assert result.ok
+ assert result.out.trim().contains('fast')
+ }
+
+ @Test
+ void testWaitForResultToString() {
+ ProcessResult result = "$echoCmd test".execute().waitForResult()
+ def str = result.toString()
+ assert str.startsWith('ProcessResult(exitCode=')
+ assert str.contains('out=')
+ assert str.contains('err=')
+ }
+
+ // --- execute with Map options ---
+
+ @Test
+ void testExecuteWithDirOption() {
+ def tmpDir = File.createTempDir()
+ try {
+ ProcessResult result = cmd.execute(dir: tmpDir).waitForResult()
+ assert result.ok
+ } finally {
+ tmpDir.deleteDir()
+ }
+ }
+
+ @Test
+ void testExecuteWithDirOptionAsString() {
+ def tmpDir = File.createTempDir()
+ try {
+ ProcessResult result = cmd.execute(dir:
tmpDir.absolutePath).waitForResult()
+ assert result.ok
+ } finally {
+ tmpDir.deleteDir()
+ }
+ }
+
+ @Test
+ void testExecuteWithEnvOption() {
+ List<String> java = [
+ System.getProperty('java.home') + '/bin/java',
+ '-classpath',
+ System.getProperty('java.class.path'),
+ 'groovy.ui.GroovyMain',
+ '-e',
+ "println(System.getenv('MY_TEST_VAR'))"
+ ]
+ ProcessResult result = java.execute(env: [MY_TEST_VAR:
'groovy_test']).waitForResult()
+ assert result.ok
+ assert result.out.trim().contains('groovy_test')
+ }
+
+ @Test
+ void testExecuteWithRedirectErrorStream() {
+ List<String> java = [
+ System.getProperty('java.home') + '/bin/java',
+ '-classpath',
+ System.getProperty('java.class.path'),
+ 'groovy.ui.GroovyMain',
+ '-e',
+ "System.err.println('errMsg'); println('outMsg')"
+ ]
+ ProcessResult result = java.execute(redirectErrorStream:
true).waitForResult()
+ assert result.out.contains('errMsg')
+ assert result.out.contains('outMsg')
+ }
+
+ @Test
+ void testExecuteMapFromArray() {
+ def tmpDir = File.createTempDir()
+ try {
+ ProcessResult result = cmd.split(' ').execute(dir:
tmpDir).waitForResult()
+ assert result.ok
+ } finally {
+ tmpDir.deleteDir()
+ }
+ }
+
+ @Test
+ void testExecuteWithOutputFile() {
+ def tmpDir = File.createTempDir()
+ try {
+ def outFile = new File(tmpDir, 'output.txt')
+ def process = "$echoCmd captured".execute(outputFile: outFile)
+ process.waitFor()
+ assert outFile.text.trim().contains('captured')
+ } finally {
+ tmpDir.deleteDir()
+ }
+ }
+
+ @Test
+ void testExecuteWithInputFile() {
+ def tmpDir = File.createTempDir()
+ try {
+ def inFile = new File(tmpDir, 'input.txt')
+ inFile.text = 'hello from file'
+ def catCmd = System.properties.'os.name'.startsWith('Windows ')
+ ? ['cmd', '/c', 'more']
+ : ['cat']
+ def result = catCmd.execute(inputFile: inFile).waitForResult()
+ assert result.ok
+ assert result.out.contains('hello from file')
+ } finally {
+ tmpDir.deleteDir()
+ }
+ }
+
+ @Test
+ void testExecuteWithDirOptionAsPath() {
+ def tmpDir = File.createTempDir()
+ try {
+ ProcessResult result = cmd.execute(dir:
tmpDir.toPath()).waitForResult()
+ assert result.ok
+ } finally {
+ tmpDir.deleteDir()
+ }
+ }
+
+ @Test
+ void testExecuteWithOutputFileAsPath() {
+ def tmpDir = File.createTempDir()
+ try {
+ def outPath = tmpDir.toPath().resolve('output.txt')
+ def process = "$echoCmd captured".execute(outputFile: outPath)
+ process.waitFor()
+ assert outPath.text.trim().contains('captured')
+ } finally {
+ tmpDir.deleteDir()
+ }
+ }
+
+ @Test
+ void testExecuteWithInputFileAsPath() {
+ def tmpDir = File.createTempDir()
+ try {
+ def inFile = Path.of(tmpDir.absolutePath, 'input.txt')
+ inFile.text = 'hello from path'
+ def catCmd = System.properties.'os.name'.startsWith('Windows ')
+ ? ['cmd', '/c', 'more']
+ : ['cat']
+ def result = catCmd.execute(inputFile: inFile).waitForResult()
+ assert result.ok
+ assert result.out.contains('hello from path')
+ } finally {
+ tmpDir.deleteDir()
+ }
+ }
+
+ @Test
+ void testExecuteWithAppendOutput() {
+ def tmpDir = File.createTempDir()
+ try {
+ def outFile = new File(tmpDir, 'append.txt')
+ outFile.text = 'existing\n'
+ "$echoCmd appended".execute(appendOutput: outFile).waitFor()
+ def content = outFile.text
+ assert content.contains('existing')
+ assert content.contains('appended')
+ } finally {
+ tmpDir.deleteDir()
+ }
+ }
+
+ @Test
+ void testExecuteWithAppendErrorAsPath() {
+ def tmpDir = File.createTempDir()
+ try {
+ def errPath = tmpDir.toPath().resolve('errors.txt')
+ errPath.text = ''
+ List<String> java = [
+ System.getProperty('java.home') + '/bin/java',
+ '-classpath',
+ System.getProperty('java.class.path'),
+ 'groovy.ui.GroovyMain',
+ '-e',
+ "System.err.println('errMsg')"
+ ]
+ java.execute(appendError: errPath).waitFor()
+ assert errPath.text.contains('errMsg')
+ } finally {
+ tmpDir.deleteDir()
+ }
+ }
+
+ @Test
+ void testExecuteWithErrorFile() {
+ def tmpDir = File.createTempDir()
+ try {
+ def errFile = new File(tmpDir, 'stderr.txt')
+ List<String> java = [
+ System.getProperty('java.home') + '/bin/java',
+ '-classpath',
+ System.getProperty('java.class.path'),
+ 'groovy.ui.GroovyMain',
+ '-e',
+ "System.err.println('error output')"
+ ]
+ java.execute(errorFile: errFile).waitFor()
+ assert errFile.text.contains('error output')
+ } finally {
+ tmpDir.deleteDir()
+ }
+ }
+
+ @Test
+ void testExecuteWithOutputFileAsString() {
+ def tmpDir = File.createTempDir()
+ try {
+ def outPath = new File(tmpDir, 'output.txt').absolutePath
+ def process = "$echoCmd stringpath".execute(outputFile: outPath)
+ process.waitFor()
+ assert new File(outPath).text.trim().contains('stringpath')
+ } finally {
+ tmpDir.deleteDir()
+ }
+ }
+
+ // --- pipeline ---
+
+ @Test
+ void testPipelineWithStrings() {
+ if (System.properties.'os.name'.startsWith('Windows ')) return
+ def procs = ["echo one two three", "wc -w"].pipeline()
+ assert procs.size() == 2
+ def result = procs.last().waitForResult()
+ assert result.ok
+ assert result.out.trim() == '3'
+ }
+
+ @Test
+ void testPipelineWithLists() {
+ if (System.properties.'os.name'.startsWith('Windows ')) return
+ def procs = [["echo", "alpha beta"], ["wc", "-w"]].pipeline()
+ def result = procs.last().waitForResult()
+ assert result.ok
+ assert result.out.trim() == '2'
+ }
+
+ @Test
+ void testPipelineWithProcessBuilders() {
+ if (System.properties.'os.name'.startsWith('Windows ')) return
+ def pb1 = "echo hello world".toProcessBuilder()
+ def pb2 = ["wc", "-w"].toProcessBuilder()
+ def procs = [pb1, pb2].pipeline()
+ def result = procs.last().waitForResult()
+ assert result.ok
+ assert result.out.trim() == '2'
+ }
+
+ @Test
+ void testPipelineWithMixedTypes() {
+ if (System.properties.'os.name'.startsWith('Windows ')) return
+ def procs = ["echo one two three", ["wc", "-w"]].pipeline()
+ def result = procs.last().waitForResult()
+ assert result.ok
+ assert result.out.trim() == '3'
+ }
+
+ // --- onExit ---
+
+ @Test
+ void testOnExitCallback() {
+ def latch = new CountDownLatch(1)
+ def capturedCode = -1
+ def process = "$echoCmd done".execute().onExit { proc ->
+ capturedCode = proc.exitValue()
+ latch.countDown()
+ }
+ assert process instanceof Process
+ latch.await(10, TimeUnit.SECONDS)
+ assert capturedCode == 0
+ }
}