Skip to content

Commit 0b62438

Browse files
authored
Add lightweight syntax for os.proc().call() and os.proc().spawn() (#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
1 parent c36de15 commit 0b62438

File tree

5 files changed

+298
-27
lines changed

5 files changed

+298
-27
lines changed

Readme.adoc

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1424,8 +1424,9 @@ os.owner.set(wd / "File.txt", originalOwner)
14241424

14251425
=== Spawning Subprocesses
14261426

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

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

1470-
==== `os.proc.call`
1472+
==== `os.call`
14711473

14721474
[source,scala]
14731475
----
1474-
os.proc(command: os.Shellable*)
1475-
.call(cwd: Path = null,
1476+
os.call(cmd: os.Shellable,
1477+
cwd: Path = null,
14761478
env: Map[String, String] = null,
14771479
stdin: ProcessInput = Pipe,
14781480
stdout: ProcessOutput = Pipe,
@@ -1483,6 +1485,8 @@ os.proc(command: os.Shellable*)
14831485
propagateEnv: Boolean = true): os.CommandResult
14841486
----
14851487

1488+
_Also callable via `os.proc(cmd).call(...)`_
1489+
14861490
Invokes the given subprocess like a function, passing in input and returning a
14871491
`CommandResult`. You can then call `result.exitCode` to see how it exited, or
14881492
`result.out.bytes` or `result.err.string` to access the aggregated stdout and
@@ -1508,7 +1512,7 @@ Note that redirecting `stdout`/`stderr` elsewhere means that the respective
15081512

15091513
[source,scala]
15101514
----
1511-
val res = os.proc('ls, wd/"folder2").call()
1515+
val res = os.call(cmd = ('ls, wd/"folder2"))
15121516
15131517
res.exitCode ==> 0
15141518
@@ -1531,13 +1535,13 @@ res.out.bytes
15311535
15321536
// Non-zero exit codes throw an exception by default
15331537
val thrown = intercept[os.SubprocessException]{
1534-
os.proc('ls, "doesnt-exist").call(cwd = wd)
1538+
os.call(cmd = ('ls, "doesnt-exist"), cwd = wd)
15351539
}
15361540
15371541
assert(thrown.result.exitCode != 0)
15381542
15391543
// Though you can avoid throwing by setting `check = false`
1540-
val fail = os.proc('ls, "doesnt-exist").call(cwd = wd, check = false)
1544+
val fail = os.call(cmd = ('ls, "doesnt-exist"), cwd = wd, check = false)
15411545
15421546
assert(fail.exitCode != 0)
15431547
@@ -1547,11 +1551,11 @@ fail.out.text() ==> ""
15471551
assert(fail.err.text().contains("No such file or directory"))
15481552
15491553
// You can pass in data to a subprocess' stdin
1550-
val hash = os.proc("shasum", "-a", "256").call(stdin = "Hello World")
1554+
val hash = os.call(cmd = ("shasum", "-a", "256"), stdin = "Hello World")
15511555
hash.out.trim() ==> "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e -"
15521556
15531557
// Taking input from a file and directing output to another file
1554-
os.proc("base64").call(stdin = wd / "File.txt", stdout = wd / "File.txt.b64")
1558+
os.call(cmd = ("base64"), stdin = wd / "File.txt", stdout = wd / "File.txt.b64")
15551559
15561560
os.read(wd / "File.txt.b64") ==> "SSBhbSBjb3c="
15571561
----
@@ -1570,7 +1574,8 @@ of `os.proc.call` in a streaming fashion, either on groups of bytes:
15701574
[source,scala]
15711575
----
15721576
var lineCount = 1
1573-
os.proc('find, ".").call(
1577+
os.call(
1578+
cmd = ('find, "."),
15741579
cwd = wd,
15751580
stdout = os.ProcessOutput(
15761581
(buf, len) => lineCount += buf.slice(0, len).count(_ == '\n')
@@ -1584,7 +1589,8 @@ Or on lines of output:
15841589
----
15851590
lineCount ==> 22
15861591
var lineCount = 1
1587-
os.proc('find, ".").call(
1592+
os.call(
1593+
cmd = ('find, "."),
15881594
cwd = wd,
15891595
stdout = os.ProcessOutput.Readlines(
15901596
line => lineCount += 1
@@ -1593,12 +1599,12 @@ os.proc('find, ".").call(
15931599
lineCount ==> 22
15941600
----
15951601

1596-
==== `os.proc.spawn`
1602+
==== `os.spawn`
15971603

15981604
[source,scala]
15991605
----
1600-
os.proc(command: os.Shellable*)
1601-
.spawn(cwd: Path = null,
1606+
os.spawn(cmd: os.Shellable,
1607+
cwd: Path = null,
16021608
env: Map[String, String] = null,
16031609
stdin: os.ProcessInput = os.Pipe,
16041610
stdout: os.ProcessOutput = os.Pipe,
@@ -1607,7 +1613,9 @@ os.proc(command: os.Shellable*)
16071613
propagateEnv: Boolean = true): os.SubProcess
16081614
----
16091615

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

16271632
`stdin`, `stdout` and `stderr` are ``java.lang.OutputStream``s and
16281633
``java.lang.InputStream``s enhanced with the `.writeLine(s: String)`/`.readLine()`
@@ -1631,8 +1636,10 @@ methods for easy reading and writing of character and line-based data.
16311636
[source,scala]
16321637
----
16331638
// Start a long-lived python process which you can communicate with
1634-
val sub = os.proc("python", "-u", "-c", "while True: print(eval(raw_input()))")
1635-
.spawn(cwd = wd)
1639+
val sub = os.spawn(
1640+
cmd = ("python", "-u", "-c", "while True: print(eval(raw_input()))"),
1641+
cwd = wd
1642+
)
16361643
16371644
// Sending some text to the subprocess
16381645
sub.stdin.write("1 + 2")
@@ -1654,9 +1661,9 @@ sub.stdout.read() ==> '8'.toByte
16541661
sub.destroy()
16551662
16561663
// You can chain multiple subprocess' stdin/stdout together
1657-
val curl = os.proc("curl", "-L" , "https://git.io/fpfTs").spawn(stderr = os.Inherit)
1658-
val gzip = os.proc("gzip", "-n").spawn(stdin = curl.stdout)
1659-
val sha = os.proc("shasum", "-a", "256").spawn(stdin = gzip.stdout)
1664+
val curl = os.spawn(cmd = ("curl", "-L" , "https://git.io/fpfTs"), stderr = os.Inherit)
1665+
val gzip = os.spawn(cmd = ("gzip", "-n"), stdin = curl.stdout)
1666+
val sha = os.spawn(cmd = ("shasum", "-a", "256"), stdin = gzip.stdout)
16601667
sha.stdout.trim ==> "acc142175fa520a1cb2be5b97cbbe9bea092e8bba3fe2e95afa645615908229e -"
16611668
----
16621669

build.sc

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,31 @@ trait OsModule extends OsLibModule { outer =>
112112

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

115+
def generatedSources = T{
116+
val conversions = for(i <- Range.inclusive(2, 22)) yield {
117+
val ts = Range.inclusive(1, i).map(n => s"T$n").mkString(", ")
118+
val fs = Range.inclusive(1, i).map(n => s"f$n: T$n => R").mkString(", ")
119+
val vs = Range.inclusive(1, i).map(n => s"f$n(t._$n)").mkString(", ")
120+
s""" implicit def tuple${i}Conversion[$ts]
121+
| (t: ($ts))
122+
| (implicit $fs): R = {
123+
| this.flatten($vs)
124+
| }
125+
|""".stripMargin
126+
}
127+
_root_.os.write(
128+
T.dest / "os" / "GeneratedTupleConversions.scala",
129+
s"""package os
130+
|trait GeneratedTupleConversions[R]{
131+
| protected def flatten(vs: R*): R
132+
| ${conversions.mkString("\n")}
133+
|}
134+
|
135+
|""".stripMargin,
136+
createFolders = true
137+
)
138+
Seq(PathRef(T.dest))
139+
}
115140
}
116141

117142
object os extends Module {

os/src/Model.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ case class SubprocessException(result: CommandResult) extends Exception(result.t
215215
* be "interpolated" directly into a subprocess call.
216216
*/
217217
case class Shellable(value: Seq[String])
218-
object Shellable {
218+
object Shellable extends os.GeneratedTupleConversions[Shellable] {
219219
implicit def StringShellable(s: String): Shellable = Shellable(Seq(s))
220220
implicit def CharSequenceShellable(cs: CharSequence): Shellable = Shellable(Seq(cs.toString))
221221

@@ -232,6 +232,8 @@ object Shellable {
232232

233233
implicit def ArrayShellable[T](s: Array[T])(implicit f: T => Shellable): Shellable =
234234
Shellable(s.toIndexedSeq.flatMap(f(_).value))
235+
236+
protected def flatten(vs: Shellable*): Shellable = IterableShellable(vs)
235237
}
236238

237239
/**

os/src/ProcessOps.scala

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,69 @@ import java.util.concurrent.LinkedBlockingQueue
1010
import ProcessOps._
1111
import scala.util.Try
1212

13+
object call {
14+
15+
/**
16+
* @see [[os.proc.call]]
17+
*/
18+
def apply(
19+
cmd: Shellable,
20+
env: Map[String, String] = null,
21+
// Make sure `cwd` only comes after `env`, so `os.call("foo", path)` is a compile error
22+
// since the correct syntax is `os.call(("foo", path))`
23+
cwd: Path = null,
24+
stdin: ProcessInput = Pipe,
25+
stdout: ProcessOutput = Pipe,
26+
stderr: ProcessOutput = os.Inherit,
27+
mergeErrIntoOut: Boolean = false,
28+
timeout: Long = -1,
29+
check: Boolean = true,
30+
propagateEnv: Boolean = true,
31+
timeoutGracePeriod: Long = 100
32+
): CommandResult = {
33+
os.proc(cmd).call(
34+
cwd = cwd,
35+
env = env,
36+
stdin = stdin,
37+
stdout = stdout,
38+
stderr = stderr,
39+
mergeErrIntoOut = mergeErrIntoOut,
40+
timeout = timeout,
41+
check = check,
42+
propagateEnv = propagateEnv,
43+
timeoutGracePeriod = timeoutGracePeriod
44+
)
45+
}
46+
}
47+
object spawn {
48+
49+
/**
50+
* @see [[os.proc.spawn]]
51+
*/
52+
def apply(
53+
cmd: Shellable,
54+
// Make sure `cwd` only comes after `env`, so `os.spawn("foo", path)` is a compile error
55+
// since the correct syntax is `os.spawn(("foo", path))`
56+
env: Map[String, String] = null,
57+
cwd: Path = null,
58+
stdin: ProcessInput = Pipe,
59+
stdout: ProcessOutput = Pipe,
60+
stderr: ProcessOutput = os.Inherit,
61+
mergeErrIntoOut: Boolean = false,
62+
propagateEnv: Boolean = true
63+
): SubProcess = {
64+
os.proc(cmd).spawn(
65+
cwd = cwd,
66+
env = env,
67+
stdin = stdin,
68+
stdout = stdout,
69+
stderr = stderr,
70+
mergeErrIntoOut = mergeErrIntoOut,
71+
propagateEnv = propagateEnv
72+
)
73+
}
74+
}
75+
1376
/**
1477
* Convenience APIs around [[java.lang.Process]] and [[java.lang.ProcessBuilder]]:
1578
*
@@ -27,7 +90,6 @@ import scala.util.Try
2790
* the standard stdin/stdout/stderr streams, using whatever protocol you
2891
* want
2992
*/
30-
3193
case class proc(command: Shellable*) {
3294
def commandChunks: Seq[String] = command.flatMap(_.value)
3395

0 commit comments

Comments
 (0)