Skip to content

Commit

Permalink
Stream output as a Sequence (#239)
Browse files Browse the repository at this point in the history
* Deprecate commandStreaming and offer function to stream command using a Sequence

* Test for commandSequence

* Format

* Add streaming output as sequence to README
  • Loading branch information
lordcodes authored May 6, 2024
1 parent a64e6fa commit e3f8a04
Show file tree
Hide file tree
Showing 4 changed files with 124 additions and 39 deletions.
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,16 @@ shellRun {

Extra commands can easily be added by either calling `command` or by extending `ShellScript`. If you have created a command that you think should be built in, please feel free to [open a PR](https://github.com/lordcodes/turtle/pull/new/master).

### Streaming output

Instead of returning output as a String via `command`, you can instead receive it as a `Sequence` using `commandSequence`. The sequence provides standard output and standard error line-by-line.

```kotlin
commandSequence("cat", listOf("/path/to/largeFile.txt")).forEach { line ->
println(line)
}
```

## Contributing or Help

If you notice any bugs or have a new feature to suggest, please check out the [contributing guide](https://github.com/lordcodes/turtle/blob/master/CONTRIBUTING.md). If you want to make changes, please make sure to discuss anything big before putting in the effort of creating the PR.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ package com.lordcodes.turtle
*/
data class ShellRunException(
val exitCode: Int,
val errorText: String,
val errorText: String? = null,
) : RuntimeException(
"Running shell command failed with code $exitCode and message: $errorText",
if (errorText == null) {
"Running shell command failed with code $exitCode"
} else {
"Running shell command failed with code $exitCode and message: $errorText"
},
)
132 changes: 98 additions & 34 deletions turtle/src/main/kotlin/com/lordcodes/turtle/ShellScript.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.lordcodes.turtle

import com.lordcodes.turtle.internal.EmptyProcess
import com.lordcodes.turtle.internal.EmptyInputStream
import java.io.BufferedReader
import java.io.File
import java.io.IOException
Expand Down Expand Up @@ -50,7 +50,43 @@ class ShellScript constructor(
command: String,
arguments: List<String> = listOf(),
callbacks: ProcessCallbacks = EmptyProcessCallbacks,
): String = runCommand(command, arguments, callbacks) { it.retrieveOutput() }
): String {
if (dryRun) {
println(dryRunCommand(command, arguments))
return ""
}
return try {
val splitCommand = listOf(command) + arguments
val process = processBuilder
.command(splitCommand)
.start()
onProcessStart(process, callbacks)
process.waitFor(COMMAND_TIMEOUT, TimeUnit.MINUTES)
process.retrieveOutput()
} catch (exception: IOException) {
if (exception.message?.contains("Cannot run program") == true) {
throw ShellCommandNotFoundException(command, exception)
}
throw ShellFailedException(exception)
} catch (exception: InterruptedException) {
throw ShellFailedException(exception)
}
}

private fun dryRunCommand(command: String, arguments: List<String>): String {
val formattedArguments = arguments.joinToString(" ")
return "$command $formattedArguments"
}

private fun Process.retrieveOutput(): String {
val outputText = inputStream.bufferedReader().use(BufferedReader::readText)
val exitCode = exitValue()
if (exitCode != 0) {
val errorText = errorStream.bufferedReader().use(BufferedReader::readText)
throw ShellRunException(exitCode, errorText.trim())
}
return outputText.trim()
}

/**
* Run a shell [command] with the specified [arguments], allowing standard output or error to be read as a stream,
Expand All @@ -60,34 +96,38 @@ class ShellScript constructor(
* @throws [ShellFailedException] There was an issue running the command.
* @throws [ShellRunException] Running the command produced error output.
*/
@Deprecated(
message = "Will be removed in the next major release as it hangs when streaming large amount of output. " +
"Please use 'commandSequence' instead.",
replaceWith = ReplaceWith(
"commandSequence(command, arguments, callbacks)",
),
)
fun commandStreaming(
command: String,
arguments: List<String> = listOf(),
callbacks: ProcessCallbacks = EmptyProcessCallbacks,
): ProcessOutput = runCommand(command, arguments, callbacks) { process ->
ProcessOutput(
exitCode = process.exitValue(),
standardOutput = process.inputStream,
standardError = process.errorStream,
)
}

private fun <OutputT> runCommand(
command: String,
arguments: List<String>,
callbacks: ProcessCallbacks,
prepareOutput: (Process) -> OutputT,
): OutputT = if (dryRun) {
dryRunCommand(command, arguments, prepareOutput)
} else {
try {
): ProcessOutput {
if (dryRun) {
println(dryRunCommand(command, arguments))
return ProcessOutput(
exitCode = 0,
standardOutput = EmptyInputStream(),
standardError = EmptyInputStream(),
)
}
return try {
val splitCommand = listOf(command) + arguments
val process = processBuilder
.command(splitCommand)
.start()
onProcessStart(process, callbacks)
process.waitFor(COMMAND_TIMEOUT, TimeUnit.MINUTES)
prepareOutput(process)
ProcessOutput(
exitCode = process.exitValue(),
standardOutput = process.inputStream,
standardError = process.errorStream,
)
} catch (exception: IOException) {
if (exception.message?.contains("Cannot run program") == true) {
throw ShellCommandNotFoundException(command, exception)
Expand All @@ -98,23 +138,47 @@ class ShellScript constructor(
}
}

private fun <OutputT> dryRunCommand(
/**
* Run a shell [command] with the specified [arguments], returning standard output and standard error
* line-by-line as a [Sequence].
*
* @throws [ShellCommandNotFoundException] The command wasn't found.
* @throws [ShellFailedException] There was an issue running the command.
* @throws [ShellRunException] Running the command produced error output.
*/
fun commandSequence(
command: String,
arguments: List<String>,
prepareOutput: (Process) -> OutputT,
): OutputT {
println("$command ${arguments.joinToString(" ")}")
return prepareOutput(EmptyProcess())
}
arguments: List<String> = listOf(),
callbacks: ProcessCallbacks = EmptyProcessCallbacks,
): Sequence<String> {
if (dryRun) {
println(dryRunCommand(command, arguments))
return sequenceOf("")
}
return try {
val splitCommand = listOf(command) + arguments
val process = processBuilder
.redirectErrorStream(true)
.command(splitCommand)
.start()
onProcessStart(process, callbacks)

private fun Process.retrieveOutput(): String {
val outputText = inputStream.bufferedReader().use(BufferedReader::readText)
val exitCode = exitValue()
if (exitCode != 0) {
val errorText = errorStream.bufferedReader().use(BufferedReader::readText)
throw ShellRunException(exitCode, errorText.trim())
sequence {
yieldAll(process.inputStream.bufferedReader().lineSequence())

val exitCode = process.waitFor()
if (exitCode != 0) {
throw ShellRunException(exitCode)
}
}
} catch (exception: IOException) {
if (exception.message?.contains("Cannot run program") == true) {
throw ShellCommandNotFoundException(command, exception)
}
throw ShellFailedException(exception)
} catch (exception: InterruptedException) {
throw ShellFailedException(exception)
}
return outputText.trim()
}

internal fun multiplatform(createCommand: (Platform) -> Command?): String {
Expand Down
13 changes: 10 additions & 3 deletions turtle/src/test/kotlin/com/lordcodes/turtle/ShellScriptTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,17 @@ internal class ShellScriptTest {
}

@Test
fun commandStreaming() {
val output = ShellScript().commandStreaming("echo", listOf("Hello world!"))
fun commandSequence(@TempDir temporaryFolder: File) {
val testFile = File(temporaryFolder, "testFile")
testFile.createNewFile()
testFile.appendText("Line 1\n")
testFile.appendText("Line 2\n")
testFile.appendText("Line 3\n")
val shell = ShellScript(temporaryFolder)

val sequence = shell.commandSequence("cat", listOf(testFile.name))

assertEquals(output.standardOutput.bufferedReader().readText().trim(), "Hello world!")
assertEquals(sequence.toList(), listOf("Line 1", "Line 2", "Line 3"))
}

@Test
Expand Down

0 comments on commit e3f8a04

Please sign in to comment.