Add lyng.io.process module for process execution utilities and shell scripting sugar (e.g., sh, exec, CommandRun).
This commit is contained in:
1 parent
3e74019d9d
commit
234d1ef02b
8 files changed
+611
-7
No files matched your search
@@ -17,10 +17,16 @@
|
||||
|
||||
package net.sergeych.lyng.io.process
|
||||
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import net.sergeych.lyng.ModuleScope
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.ScopeFacade
|
||||
import net.sergeych.lyng.Source
|
||||
import net.sergeych.lyng.miniast.*
|
||||
import net.sergeych.lyng.obj.*
|
||||
import net.sergeych.lyng.pacman.ImportManager
|
||||
@@ -28,6 +34,24 @@ import net.sergeych.lyng.requireScope
|
||||
import net.sergeych.lyngio.process.*
|
||||
import net.sergeych.lyngio.process.security.ProcessAccessDeniedException
|
||||
import net.sergeych.lyngio.process.security.ProcessAccessPolicy
|
||||
import net.sergeych.lyngio.stdlib_included.processLyng
|
||||
import kotlin.Boolean
|
||||
import kotlin.Exception
|
||||
import kotlin.Int
|
||||
import kotlin.String
|
||||
import kotlin.also
|
||||
import kotlin.apply
|
||||
import kotlin.collections.joinToString
|
||||
import kotlin.collections.listOf
|
||||
import kotlin.collections.map
|
||||
import kotlin.collections.mutableListOf
|
||||
import kotlin.collections.mutableMapOf
|
||||
import kotlin.collections.toTypedArray
|
||||
import kotlin.let
|
||||
import kotlin.takeIf
|
||||
import kotlin.text.isNotBlank
|
||||
import kotlin.text.uppercase
|
||||
import kotlin.to
|
||||
|
||||
/**
|
||||
* Install Lyng module `lyng.io.process` into the given scope's ImportManager.
|
||||
@@ -47,6 +71,8 @@ fun createProcessModule(policy: ProcessAccessPolicy, manager: ImportManager): Bo
|
||||
}
|
||||
|
||||
private suspend fun buildProcessModule(module: ModuleScope, policy: ProcessAccessPolicy) {
|
||||
module.eval(Source("lyng.io.process", processLyng))
|
||||
|
||||
val runner = try {
|
||||
SecuredLyngProcessRunner(getSystemProcessRunner(), policy)
|
||||
} catch (e: Exception) {
|
||||
@@ -54,6 +80,7 @@ private suspend fun buildProcessModule(module: ModuleScope, policy: ProcessAcces
|
||||
}
|
||||
|
||||
val runningProcessType = object : ObjClass("RunningProcess") {}
|
||||
val commandRunType = object : ObjClass("CommandRun") {}
|
||||
|
||||
runningProcessType.apply {
|
||||
addFnDoc(
|
||||
@@ -149,6 +176,87 @@ private suspend fun buildProcessModule(module: ModuleScope, policy: ProcessAcces
|
||||
}
|
||||
}
|
||||
|
||||
commandRunType.apply {
|
||||
addPropertyDoc(
|
||||
name = "command",
|
||||
doc = "Original shell command or argv-style command display text.",
|
||||
type = type("lyng.String"),
|
||||
moduleName = module.packageName,
|
||||
getter = { ObjString(thisAs<ObjCommandRun>().command) }
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "out",
|
||||
doc = "Captured standard output as a string. Captures both stdout and stderr concurrently.",
|
||||
type = type("lyng.String"),
|
||||
moduleName = module.packageName,
|
||||
getter = { ObjString(thisAs<ObjCommandRun>().captureAll().stdoutText) }
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "err",
|
||||
doc = "Captured standard error as a string. Captures both stdout and stderr concurrently.",
|
||||
type = type("lyng.String"),
|
||||
moduleName = module.packageName,
|
||||
getter = { ObjString(thisAs<ObjCommandRun>().captureAll().stderrText) }
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "lines",
|
||||
doc = "Streaming standard output lines. Use this for large output instead of `out`.",
|
||||
type = type("lyng.Flow"),
|
||||
moduleName = module.packageName,
|
||||
getter = { thisAs<ObjCommandRun>().process.stdout.toLyngFlow(this) }
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "errorLines",
|
||||
doc = "Streaming standard error lines. Use this for large stderr output instead of `err`.",
|
||||
type = type("lyng.Flow"),
|
||||
moduleName = module.packageName,
|
||||
getter = { thisAs<ObjCommandRun>().process.stderr.toLyngFlow(this) }
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "code",
|
||||
doc = "Exit code after `wait`, `check`, `out`, or `err`; otherwise null.",
|
||||
type = type("lyng.Int?"),
|
||||
moduleName = module.packageName,
|
||||
getter = {
|
||||
val code = thisAs<ObjCommandRun>().knownExitCode()
|
||||
code?.toObj() ?: ObjNull
|
||||
}
|
||||
)
|
||||
addPropertyDoc(
|
||||
name = "ok",
|
||||
doc = "True if the known exit code is zero, false if non-zero, or null before the process is known to have exited.",
|
||||
type = type("lyng.Bool?"),
|
||||
moduleName = module.packageName,
|
||||
getter = {
|
||||
val code = thisAs<ObjCommandRun>().knownExitCode()
|
||||
code?.let { (it == 0).toObj() } ?: ObjNull
|
||||
}
|
||||
)
|
||||
addFnDoc(
|
||||
name = "wait",
|
||||
doc = "Wait for the process to exit and return its exit code.",
|
||||
returns = type("lyng.Int"),
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
thisAs<ObjCommandRun>().waitFor().toObj()
|
||||
}
|
||||
addFnDoc(
|
||||
name = "check",
|
||||
doc = "Capture output, wait for completion, and fail if the exit code is non-zero.",
|
||||
returns = type("CommandRun"),
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
val command = thisAs<ObjCommandRun>()
|
||||
val captured = command.captureAll()
|
||||
if (captured.exitCode != 0) {
|
||||
val detail = captured.stderrText.takeIf { it.isNotBlank() } ?: captured.stdoutText
|
||||
val suffix = detail.takeIf { it.isNotBlank() }?.let { ": $it" } ?: ""
|
||||
raiseError("command failed with exit code ${captured.exitCode}: ${command.command}$suffix")
|
||||
}
|
||||
command
|
||||
}
|
||||
}
|
||||
|
||||
val platformType = object : ObjClass("Platform") {}
|
||||
|
||||
platformType.apply {
|
||||
@@ -197,6 +305,45 @@ private suspend fun buildProcessModule(module: ModuleScope, policy: ProcessAcces
|
||||
type = type("RunningProcess"),
|
||||
moduleName = module.packageName
|
||||
)
|
||||
module.addConstDoc(
|
||||
name = "CommandRun",
|
||||
value = commandRunType,
|
||||
doc = "Shell-script friendly handle for a running command.",
|
||||
type = type("CommandRun"),
|
||||
moduleName = module.packageName
|
||||
)
|
||||
|
||||
module.addFnDoc(
|
||||
"sh",
|
||||
doc = "Run a command via the system shell and return an active command handle.",
|
||||
params = listOf(ParamDoc("command", type("lyng.String"))),
|
||||
returns = type("CommandRun"),
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
if (runner == null) raiseError("Processes are not supported on this platform")
|
||||
processGuard {
|
||||
val command = requireOnlyArg<ObjString>().value
|
||||
ObjCommandRun(commandRunType, command, runner.shell(command))
|
||||
}
|
||||
}
|
||||
module.addFnDoc(
|
||||
"exec",
|
||||
doc = "Run an executable with argv-style arguments and return an active command handle.",
|
||||
params = listOf(ParamDoc("executable", type("lyng.String")), ParamDoc("args", type("lyng.List"))),
|
||||
returns = type("CommandRun"),
|
||||
moduleName = module.packageName
|
||||
) {
|
||||
if (runner == null) raiseError("Processes are not supported on this platform")
|
||||
processGuard {
|
||||
if (args.list.size > 2) {
|
||||
raiseError("Expected at most 2 arguments, got ${args.list.size}")
|
||||
}
|
||||
val executable = requiredArg<ObjString>(0).value
|
||||
val rawArgs = if (args.list.size >= 2) requiredArg<ObjList>(1) else ObjList(mutableListOf())
|
||||
val argv = rawArgs.list.map { (it as? ObjString)?.value ?: it.toString() }
|
||||
ObjCommandRun(commandRunType, listOf(executable, *argv.toTypedArray()).joinToString(" "), runner.execute(executable, argv))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ObjRunningProcess(
|
||||
@@ -206,6 +353,49 @@ class ObjRunningProcess(
|
||||
override fun toString(): String = "RunningProcess($process)"
|
||||
}
|
||||
|
||||
private data class CapturedCommandOutput(
|
||||
val exitCode: Int,
|
||||
val stdoutText: String,
|
||||
val stderrText: String
|
||||
)
|
||||
|
||||
private class ObjCommandRun(
|
||||
override val objClass: ObjClass,
|
||||
val command: String,
|
||||
val process: LyngProcess
|
||||
) : Obj() {
|
||||
private val captureMutex = Mutex()
|
||||
private var captured: CapturedCommandOutput? = null
|
||||
private var exitCode: Int? = null
|
||||
|
||||
fun knownExitCode(): Int? = captured?.exitCode ?: exitCode
|
||||
|
||||
suspend fun waitFor(): Int {
|
||||
captured?.let { return it.exitCode }
|
||||
exitCode?.let { return it }
|
||||
return process.waitFor().also { exitCode = it }
|
||||
}
|
||||
|
||||
suspend fun captureAll(): CapturedCommandOutput = captureMutex.withLock {
|
||||
captured?.let { return@withLock it }
|
||||
coroutineScope {
|
||||
val stdout = async { process.stdout.toList().joinToString("\n") }
|
||||
val stderr = async { process.stderr.toList().joinToString("\n") }
|
||||
val code = async { process.waitFor() }
|
||||
CapturedCommandOutput(
|
||||
exitCode = code.await(),
|
||||
stdoutText = stdout.await(),
|
||||
stderrText = stderr.await()
|
||||
).also {
|
||||
captured = it
|
||||
exitCode = it.exitCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString(): String = "CommandRun($command)"
|
||||
}
|
||||
|
||||
private suspend inline fun ScopeFacade.processGuard(crossinline block: suspend () -> Obj): Obj {
|
||||
return try {
|
||||
block()
|
||||
|
||||
@@ -19,9 +19,11 @@ package net.sergeych.lyng.io.process
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.sergeych.lyng.Compiler
|
||||
import net.sergeych.lyng.ExecutionError
|
||||
import net.sergeych.lyng.Script
|
||||
import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class LyngProcessModuleTest {
|
||||
@@ -84,4 +86,78 @@ class LyngProcessModuleTest {
|
||||
val result = script.execute(scope)
|
||||
assertTrue(result.inspect(scope).contains("name"), "Result should contain 'name', but was: ${result.inspect(scope)}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShellSugarCapturesOutput() = runBlocking {
|
||||
val scope = Script.newScope()
|
||||
createProcessModule(PermitAllProcessAccessPolicy, scope)
|
||||
|
||||
val code = """
|
||||
import lyng.io.process
|
||||
|
||||
val r = sh("echo shell-sugar")
|
||||
assert(r.out.trim() == "shell-sugar")
|
||||
assert(r.code == 0)
|
||||
assert(r.ok == true)
|
||||
true
|
||||
""".trimIndent()
|
||||
|
||||
val script = Compiler.compile(code)
|
||||
val result = script.execute(scope)
|
||||
assertTrue(result.inspect(scope).contains("true"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShellSugarStreamsLines() = runBlocking {
|
||||
val scope = Script.newScope()
|
||||
createProcessModule(PermitAllProcessAccessPolicy, scope)
|
||||
|
||||
val code = """
|
||||
import lyng.io.process
|
||||
|
||||
val lines: List<String> = []
|
||||
for (line in sh("echo one && echo two").lines) {
|
||||
lines.add(line)
|
||||
}
|
||||
lines.joinToString(",")
|
||||
""".trimIndent()
|
||||
|
||||
val script = Compiler.compile(code)
|
||||
val result = script.execute(scope)
|
||||
assertTrue(result.inspect(scope).contains("one,two"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testExecSugarCapturesOutput() = runBlocking {
|
||||
val scope = Script.newScope()
|
||||
createProcessModule(PermitAllProcessAccessPolicy, scope)
|
||||
|
||||
val code = """
|
||||
import lyng.io.process
|
||||
|
||||
exec("echo", ["exec-sugar"]).out.trim()
|
||||
""".trimIndent()
|
||||
|
||||
val script = Compiler.compile(code)
|
||||
val result = script.execute(scope)
|
||||
assertTrue(result.inspect(scope).contains("exec-sugar"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShellSugarCheckFailsOnNonZeroExit() = runBlocking {
|
||||
val scope = Script.newScope()
|
||||
createProcessModule(PermitAllProcessAccessPolicy, scope)
|
||||
|
||||
val code = """
|
||||
import lyng.io.process
|
||||
|
||||
sh("echo check-failed && exit 7").check()
|
||||
""".trimIndent()
|
||||
|
||||
val script = Compiler.compile(code)
|
||||
assertFailsWith<ExecutionError> {
|
||||
script.execute(scope)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package lyng.io.process
|
||||
|
||||
/* Handle to a running process. This is the low-level process API. */
|
||||
extern class RunningProcess {
|
||||
/* Standard output as a flow of process output lines. */
|
||||
fun stdout(): Flow<String>
|
||||
/* Standard error as a flow of process error lines. */
|
||||
fun stderr(): Flow<String>
|
||||
/* Send a signal such as "SIGINT", "SIGTERM", or "SIGKILL". */
|
||||
fun signal(signal: String): void
|
||||
/* Wait for process completion and return its exit code. */
|
||||
fun waitFor(): Int
|
||||
/* Forcefully terminate the process. */
|
||||
fun destroy(): void
|
||||
}
|
||||
|
||||
/* Shell-script friendly active handle for a running command. */
|
||||
extern class CommandRun {
|
||||
/* Original shell command or argv-style command display text. */
|
||||
val command: String
|
||||
/* Capture standard output as a string, wait for completion, and drain stderr concurrently. Use `lines` for large output. */
|
||||
val out: String
|
||||
/* Capture standard error as a string, wait for completion, and drain stdout concurrently. Use `errorLines` for large output. */
|
||||
val err: String
|
||||
/* Streaming standard output lines. Treat the process stdout stream as one-shot. */
|
||||
val lines: Flow<String>
|
||||
/* Streaming standard error lines. Treat the process stderr stream as one-shot. */
|
||||
val errorLines: Flow<String>
|
||||
/* Exit code after `wait`, `check`, `out`, or `err`; otherwise null. */
|
||||
val code: Int?
|
||||
/* True for exit code 0, false for non-zero, or null before the exit code is known. */
|
||||
val ok: Bool?
|
||||
/* Wait for the command to exit and return the exit code. */
|
||||
fun wait(): Int
|
||||
/* Capture output, wait for completion, and fail if the exit code is non-zero. */
|
||||
fun check(): CommandRun
|
||||
}
|
||||
|
||||
/* Process execution and control. */
|
||||
extern object Process {
|
||||
/* Execute a process with arguments without using a shell. */
|
||||
fun execute(executable: String, args: List<String>): RunningProcess
|
||||
/* Execute a command via the system shell. */
|
||||
fun shell(command: String): RunningProcess
|
||||
}
|
||||
|
||||
/* Platform information for process support. */
|
||||
extern object Platform {
|
||||
/* Get platform core details. */
|
||||
fun details(): Map<String,Object?>
|
||||
/* Check if processes are supported on this platform. */
|
||||
fun isSupported(): Bool
|
||||
}
|
||||
|
||||
/* Run a command via the system shell and return an active command handle. */
|
||||
extern fun sh(command: String): CommandRun
|
||||
|
||||
/* Run an executable with argv-style arguments and return an active command handle. */
|
||||
extern fun exec(executable: String, args: List<String> = []): CommandRun
|
||||
Reference in new issue
Block a user