Skip to content

Commit

Permalink
Add lightweight syntax for os.proc().call() and os.proc().spawn() (
Browse files Browse the repository at this point in the history
…#292)

Now these can be spelled `os.call()` and `os.spawn()`, and we provide
`Shellable[TupleN]` conversions to make it convenient to call without
constructing a `Seq(...)` every time. So this:

```scala
os.proc("ls", "doesnt-exist").call(cwd = wd, check = false, stderr = os.Pipe)
```

Becomes

```scala
os.call(cmd = ("ls", "doesnt-exist"), cwd = wd, check = false, stderr = os.Pipe)
```

The original purpose of the `os.proc().call()` style was to avoid the
verbosity of constructing a `Seq` each time, and by making it flexible
enough to take tuples, this mitigates that issue without the annoying
method chaining style.

The new style still isn't actually shorter in terms of number of
characters, but it is a lot cleaner in terms of "function call taking
named/optional arguments" rather than "fluent call chain with first call
taking varargs and second call taking named/optional parameters". It
also aligns with the Python `subprocess.*` functions which OS-Lib in
general is inspired by

To support Scala 2, the `Shellable[TupleN]` conversions are defined
using codegen. Scala 3 allows a nicer generic-tuple implementation, but
we'll be supporting Scala 2 for the foreseeable future.

The older `os.proc.*` APIs remain, both for backwards compatibility, as
well as to support the `pipeTo` API used to construct process pipelines

Duplicated some of the existing subprocess tests to exercise the new
APIs. Did not duplicate all of them, as the new APIs are pretty dumb
forwarders to the existing ones so we don't need to exercise every flag
in detail.

Updated the docs to point towards the new APIs, but with a mention that
the older `os.proc().call()` style is still supported
  • Loading branch information
lihaoyi authored Aug 19, 2024
1 parent c36de15 commit 0b62438
Show file tree
Hide file tree
Showing 5 changed files with 298 additions and 27 deletions.
57 changes: 32 additions & 25 deletions Readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -1424,8 +1424,9 @@ os.owner.set(wd / "File.txt", originalOwner)

=== Spawning Subprocesses

Subprocess are spawned using `+os.proc(command: os.Shellable*).foo(...)+` calls,
where the `command: Shellable*` sets up the basic command you wish to run and
Subprocess are spawned using `+os.call(cmd: os.Shellable, ...)+` or
`+os.spawn(cmd: os.Shellable, ...)+` calls,
where the `cmd: Shellable` sets up the basic command you wish to run and
`+.foo(...)+` specifies how you want to run it. `os.Shellable` represents a value
that can make up part of your subprocess command, and the following values can
be used as ``os.Shellable``s:
Expand All @@ -1436,6 +1437,7 @@ be used as ``os.Shellable``s:
* `os.RelPath`
* `T: Numeric`
* ``Iterable[T]``s of any of the above
* ``TupleN[T1, T2, ...Tn]``s of any of the above

Most of the subprocess commands also let you redirect the subprocess's
`stdin`/`stdout`/`stderr` streams via `os.ProcessInput` or `os.ProcessOutput`
Expand Down Expand Up @@ -1467,12 +1469,12 @@ Often, if you are only interested in capturing the standard output of the
subprocess but want any errors sent to the console, you might set `stderr =
os.Inherit` while leaving `stdout = os.Pipe`.

==== `os.proc.call`
==== `os.call`

[source,scala]
----
os.proc(command: os.Shellable*)
.call(cwd: Path = null,
os.call(cmd: os.Shellable,
cwd: Path = null,
env: Map[String, String] = null,
stdin: ProcessInput = Pipe,
stdout: ProcessOutput = Pipe,
Expand All @@ -1483,6 +1485,8 @@ os.proc(command: os.Shellable*)
propagateEnv: Boolean = true): os.CommandResult
----

_Also callable via `os.proc(cmd).call(...)`_

Invokes the given subprocess like a function, passing in input and returning a
`CommandResult`. You can then call `result.exitCode` to see how it exited, or
`result.out.bytes` or `result.err.string` to access the aggregated stdout and
Expand All @@ -1508,7 +1512,7 @@ Note that redirecting `stdout`/`stderr` elsewhere means that the respective

[source,scala]
----
val res = os.proc('ls, wd/"folder2").call()
val res = os.call(cmd = ('ls, wd/"folder2"))
res.exitCode ==> 0
Expand All @@ -1531,13 +1535,13 @@ res.out.bytes
// Non-zero exit codes throw an exception by default
val thrown = intercept[os.SubprocessException]{
os.proc('ls, "doesnt-exist").call(cwd = wd)
os.call(cmd = ('ls, "doesnt-exist"), cwd = wd)
}
assert(thrown.result.exitCode != 0)
// Though you can avoid throwing by setting `check = false`
val fail = os.proc('ls, "doesnt-exist").call(cwd = wd, check = false)
val fail = os.call(cmd = ('ls, "doesnt-exist"), cwd = wd, check = false)
assert(fail.exitCode != 0)
Expand All @@ -1547,11 +1551,11 @@ fail.out.text() ==> ""
assert(fail.err.text().contains("No such file or directory"))
// You can pass in data to a subprocess' stdin
val hash = os.proc("shasum", "-a", "256").call(stdin = "Hello World")
val hash = os.call(cmd = ("shasum", "-a", "256"), stdin = "Hello World")
hash.out.trim() ==> "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e -"
// Taking input from a file and directing output to another file
os.proc("base64").call(stdin = wd / "File.txt", stdout = wd / "File.txt.b64")
os.call(cmd = ("base64"), stdin = wd / "File.txt", stdout = wd / "File.txt.b64")
os.read(wd / "File.txt.b64") ==> "SSBhbSBjb3c="
----
Expand All @@ -1570,7 +1574,8 @@ of `os.proc.call` in a streaming fashion, either on groups of bytes:
[source,scala]
----
var lineCount = 1
os.proc('find, ".").call(
os.call(
cmd = ('find, "."),
cwd = wd,
stdout = os.ProcessOutput(
(buf, len) => lineCount += buf.slice(0, len).count(_ == '\n')
Expand All @@ -1584,7 +1589,8 @@ Or on lines of output:
----
lineCount ==> 22
var lineCount = 1
os.proc('find, ".").call(
os.call(
cmd = ('find, "."),
cwd = wd,
stdout = os.ProcessOutput.Readlines(
line => lineCount += 1
Expand All @@ -1593,12 +1599,12 @@ os.proc('find, ".").call(
lineCount ==> 22
----

==== `os.proc.spawn`
==== `os.spawn`

[source,scala]
----
os.proc(command: os.Shellable*)
.spawn(cwd: Path = null,
os.spawn(cmd: os.Shellable,
cwd: Path = null,
env: Map[String, String] = null,
stdin: os.ProcessInput = os.Pipe,
stdout: os.ProcessOutput = os.Pipe,
Expand All @@ -1607,7 +1613,9 @@ os.proc(command: os.Shellable*)
propagateEnv: Boolean = true): os.SubProcess
----

The most flexible of the `os.proc` calls, `os.proc.spawn` simply configures and
_Also callable via `os.proc(cmd).spawn(...)`_

The most flexible of the `os.proc` calls, `os.spawn` simply configures and
starts a subprocess, and returns it as a `os.SubProcess`. `os.SubProcess` is a
simple wrapper around `java.lang.Process`, which provides `stdin`, `stdout`, and
`stderr` streams for you to interact with however you like. e.g. You can sending
Expand All @@ -1619,10 +1627,7 @@ as the stdin of a second spawned process.
Note that if you provide `ProcessOutput` callbacks to `stdout`/`stderr`, the
calls to those callbacks take place on newly spawned threads that execute in
parallel with the main thread. Thus make sure any data processing you do in
those callbacks is thread safe! For simpler cases, it may be easier to use
`os.proc.stream` which triggers it's `onOut`/`onErr` callbacks
all on the calling thread, avoiding needing to think about multithreading and
concurrency issues.
those callbacks is thread safe!

`stdin`, `stdout` and `stderr` are ``java.lang.OutputStream``s and
``java.lang.InputStream``s enhanced with the `.writeLine(s: String)`/`.readLine()`
Expand All @@ -1631,8 +1636,10 @@ methods for easy reading and writing of character and line-based data.
[source,scala]
----
// Start a long-lived python process which you can communicate with
val sub = os.proc("python", "-u", "-c", "while True: print(eval(raw_input()))")
.spawn(cwd = wd)
val sub = os.spawn(
cmd = ("python", "-u", "-c", "while True: print(eval(raw_input()))"),
cwd = wd
)
// Sending some text to the subprocess
sub.stdin.write("1 + 2")
Expand All @@ -1654,9 +1661,9 @@ sub.stdout.read() ==> '8'.toByte
sub.destroy()
// You can chain multiple subprocess' stdin/stdout together
val curl = os.proc("curl", "-L" , "https://git.io/fpfTs").spawn(stderr = os.Inherit)
val gzip = os.proc("gzip", "-n").spawn(stdin = curl.stdout)
val sha = os.proc("shasum", "-a", "256").spawn(stdin = gzip.stdout)
val curl = os.spawn(cmd = ("curl", "-L" , "https://git.io/fpfTs"), stderr = os.Inherit)
val gzip = os.spawn(cmd = ("gzip", "-n"), stdin = curl.stdout)
val sha = os.spawn(cmd = ("shasum", "-a", "256"), stdin = gzip.stdout)
sha.stdout.trim ==> "acc142175fa520a1cb2be5b97cbbe9bea092e8bba3fe2e95afa645615908229e -"
----

Expand Down
25 changes: 25 additions & 0 deletions build.sc
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,31 @@ trait OsModule extends OsLibModule { outer =>

def scalaDocOptions = super.scalaDocOptions() ++ conditionalScalaDocOptions()

def generatedSources = T{
val conversions = for(i <- Range.inclusive(2, 22)) yield {
val ts = Range.inclusive(1, i).map(n => s"T$n").mkString(", ")
val fs = Range.inclusive(1, i).map(n => s"f$n: T$n => R").mkString(", ")
val vs = Range.inclusive(1, i).map(n => s"f$n(t._$n)").mkString(", ")
s""" implicit def tuple${i}Conversion[$ts]
| (t: ($ts))
| (implicit $fs): R = {
| this.flatten($vs)
| }
|""".stripMargin
}
_root_.os.write(
T.dest / "os" / "GeneratedTupleConversions.scala",
s"""package os
|trait GeneratedTupleConversions[R]{
| protected def flatten(vs: R*): R
| ${conversions.mkString("\n")}
|}
|
|""".stripMargin,
createFolders = true
)
Seq(PathRef(T.dest))
}
}

object os extends Module {
Expand Down
4 changes: 3 additions & 1 deletion os/src/Model.scala
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ case class SubprocessException(result: CommandResult) extends Exception(result.t
* be "interpolated" directly into a subprocess call.
*/
case class Shellable(value: Seq[String])
object Shellable {
object Shellable extends os.GeneratedTupleConversions[Shellable] {
implicit def StringShellable(s: String): Shellable = Shellable(Seq(s))
implicit def CharSequenceShellable(cs: CharSequence): Shellable = Shellable(Seq(cs.toString))

Expand All @@ -232,6 +232,8 @@ object Shellable {

implicit def ArrayShellable[T](s: Array[T])(implicit f: T => Shellable): Shellable =
Shellable(s.toIndexedSeq.flatMap(f(_).value))

protected def flatten(vs: Shellable*): Shellable = IterableShellable(vs)
}

/**
Expand Down
64 changes: 63 additions & 1 deletion os/src/ProcessOps.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,69 @@ import java.util.concurrent.LinkedBlockingQueue
import ProcessOps._
import scala.util.Try

object call {

/**
* @see [[os.proc.call]]
*/
def apply(
cmd: Shellable,
env: Map[String, String] = null,
// Make sure `cwd` only comes after `env`, so `os.call("foo", path)` is a compile error
// since the correct syntax is `os.call(("foo", path))`
cwd: Path = null,
stdin: ProcessInput = Pipe,
stdout: ProcessOutput = Pipe,
stderr: ProcessOutput = os.Inherit,
mergeErrIntoOut: Boolean = false,
timeout: Long = -1,
check: Boolean = true,
propagateEnv: Boolean = true,
timeoutGracePeriod: Long = 100
): CommandResult = {
os.proc(cmd).call(
cwd = cwd,
env = env,
stdin = stdin,
stdout = stdout,
stderr = stderr,
mergeErrIntoOut = mergeErrIntoOut,
timeout = timeout,
check = check,
propagateEnv = propagateEnv,
timeoutGracePeriod = timeoutGracePeriod
)
}
}
object spawn {

/**
* @see [[os.proc.spawn]]
*/
def apply(
cmd: Shellable,
// Make sure `cwd` only comes after `env`, so `os.spawn("foo", path)` is a compile error
// since the correct syntax is `os.spawn(("foo", path))`
env: Map[String, String] = null,
cwd: Path = null,
stdin: ProcessInput = Pipe,
stdout: ProcessOutput = Pipe,
stderr: ProcessOutput = os.Inherit,
mergeErrIntoOut: Boolean = false,
propagateEnv: Boolean = true
): SubProcess = {
os.proc(cmd).spawn(
cwd = cwd,
env = env,
stdin = stdin,
stdout = stdout,
stderr = stderr,
mergeErrIntoOut = mergeErrIntoOut,
propagateEnv = propagateEnv
)
}
}

/**
* Convenience APIs around [[java.lang.Process]] and [[java.lang.ProcessBuilder]]:
*
Expand All @@ -27,7 +90,6 @@ import scala.util.Try
* the standard stdin/stdout/stderr streams, using whatever protocol you
* want
*/

case class proc(command: Shellable*) {
def commandChunks: Seq[String] = command.flatMap(_.value)

Expand Down
Loading

0 comments on commit 0b62438

Please sign in to comment.