-
Notifications
You must be signed in to change notification settings - Fork 79
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for reading command-line options from file(s) (#191) #317
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
package zio.cli | ||
|
||
trait FileArgsPlatformSpecific { | ||
val default: FileArgs = FileArgs.Noop | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
package zio.cli | ||
|
||
trait FileArgsPlatformSpecific { | ||
val default: FileArgs = FileArgs.Live | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
package zio.cli | ||
|
||
import zio._ | ||
import zio.internal.stacktracer.SourceLocation | ||
import zio.test._ | ||
|
||
import java.nio.file.{Files, Path} | ||
|
||
object LiveFileArgsSpec extends ZIOSpecDefault { | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can be put also in the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can add this, yes.
|
||
private val createTempDirectory: RIO[Scope, Path] = | ||
for { | ||
random <- Random.nextUUID | ||
path <- | ||
ZIO.attempt(Files.createTempDirectory(random.toString)).withFinalizer(f => ZIO.attempt(f.toFile.delete()).orDie) | ||
} yield path | ||
|
||
private def resolvePath(path: Path, paths: List[String]): Path = | ||
if (paths.nonEmpty) path.resolve(paths.mkString("/")) | ||
else path | ||
|
||
private def makeTest(name: String)(cwd: List[String], home: List[String])( | ||
writeFiles: (List[String], String)* | ||
)( | ||
exp: (List[String], List[String])* | ||
)(implicit loc: SourceLocation): Spec[Scope, Throwable] = | ||
test(name) { | ||
for { | ||
// setup | ||
dir <- createTempDirectory | ||
_ <- TestSystem.putProperty("user.dir", resolvePath(dir, cwd).toString) | ||
_ <- TestSystem.putProperty("user.home", resolvePath(dir, home).toString) | ||
_ <- ZIO.foreachDiscard(writeFiles) { case (paths, contents) => | ||
val writePath = resolvePath(dir, paths :+ s".$cmd") | ||
val parentFile = writePath.getParent.toFile | ||
ZIO.attempt(parentFile.mkdirs()).unlessZIO(ZIO.attempt(parentFile.exists())) *> | ||
ZIO.writeFile(writePath, contents) | ||
} | ||
|
||
// test | ||
result <- FileArgs.Live.getArgsFromFile(cmd) | ||
resolvedExp = exp.toList.map { case (paths, args) => | ||
FileArgs.ArgsFromFile(resolvePath(dir, paths :+ s".$cmd").toString, args) | ||
} | ||
|
||
} yield assertTrue(result == resolvedExp) | ||
} | ||
|
||
private val cmd: String = "command" | ||
|
||
override def spec: Spec[TestEnvironment with Scope, Any] = | ||
suite("FileArgsSpec")( | ||
makeTest("empty")(List("abc", "home"), List("abc", "home"))()(), | ||
makeTest("home in cwd parent path")(List("abc", "home", "d", "e", "f"), List("abc", "home"))( | ||
List("abc", "home", "d") -> "d\nd\n\n", | ||
List("abc", "home", "d", "e") -> "e\ne\n\n", | ||
List("abc", "home", "d", "e", "f") -> "f\nf\n\n", | ||
List("abc", "home") -> "_home_" | ||
)( | ||
List("abc", "home", "d", "e", "f") -> List("f", "f"), | ||
List("abc", "home", "d", "e") -> List("e", "e"), | ||
List("abc", "home", "d") -> List("d", "d"), | ||
List("abc", "home") -> List("_home_") // only appears once | ||
), | ||
makeTest("home not in cwd parent path")(List("abc", "cwd", "d", "e", "f"), List("abc", "home"))( | ||
List("abc", "cwd", "d") -> "d\nd\n\n", | ||
List("abc", "cwd", "d", "e") -> "e\ne\n\n", | ||
List("abc", "cwd", "d", "e", "f") -> "f\nf\n\n", | ||
List("abc", "home") -> "_home_" | ||
)( | ||
List("abc", "cwd", "d", "e", "f") -> List("f", "f"), | ||
List("abc", "cwd", "d", "e") -> List("e", "e"), | ||
List("abc", "cwd", "d") -> List("d", "d"), | ||
List("abc", "home") -> List("_home_") | ||
), | ||
makeTest("parent dirs of home are not searched")(Nil, List("abc", "home"))( | ||
List("abc") -> "a\nb" | ||
)( | ||
) | ||
) @@ TestAspect.withLiveRandom | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
package zio.cli | ||
|
||
trait FileArgsPlatformSpecific { | ||
val default: FileArgs = FileArgs.Live | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -46,7 +46,11 @@ sealed trait Command[+A] extends Parameter with Named { self => | |
|
||
final def orElseEither[B](that: Command[B]): Command[Either[A, B]] = map(Left(_)) | that.map(Right(_)) | ||
|
||
def parse(args: List[String], conf: CliConfig): IO[ValidationError, CommandDirective[A]] | ||
def parse( | ||
args: List[String], | ||
fromFiles: List[FileArgs.ArgsFromFile], | ||
conf: CliConfig | ||
): IO[ValidationError, CommandDirective[A]] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it might be better to have There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. seems reasonable |
||
|
||
final def subcommands[B](that: Command[B])(implicit ev: Reducable[A, B]): Command[ev.Out] = | ||
Command.Subcommands(self, that).map(ev.fromTuple2(_)) | ||
|
@@ -110,14 +114,15 @@ object Command { | |
|
||
def parse( | ||
args: List[String], | ||
fromFiles: List[FileArgs.ArgsFromFile], | ||
conf: CliConfig | ||
): IO[ValidationError, CommandDirective[(OptionsType, ArgsType)]] = { | ||
def parseBuiltInArgs(args: List[String]): IO[ValidationError, CommandDirective[Nothing]] = | ||
if (args.headOption.exists(conf.normalizeCase(_) == conf.normalizeCase(self.name))) { | ||
val options = BuiltInOption | ||
.builtInOptions(self, self.synopsis, self.helpDoc) | ||
Options | ||
.validate(options, args.tail, conf) | ||
.validate(options, args.tail, Nil, conf) | ||
.map(_._3) | ||
.someOrFail( | ||
ValidationError( | ||
|
@@ -158,7 +163,7 @@ object Command { | |
} | ||
tuple1 = splitForcedArgs(commandOptionsAndArgs) | ||
(optionsAndArgs, forcedCommandArgs) = tuple1 | ||
tuple2 <- Options.validate(options, optionsAndArgs, conf) | ||
tuple2 <- Options.validate(options, optionsAndArgs, fromFiles, conf) | ||
(optionsError, commandArgs, optionsType) = tuple2 | ||
tuple <- self.args.validate(commandArgs ++ forcedCommandArgs, conf).mapError(optionsError.getOrElse(_)) | ||
(argsLeftover, argsType) = tuple | ||
|
@@ -202,9 +207,10 @@ object Command { | |
|
||
def parse( | ||
args: List[String], | ||
fromFiles: List[FileArgs.ArgsFromFile], | ||
conf: CliConfig | ||
): IO[ValidationError, CommandDirective[B]] = | ||
command.parse(args, conf).map(_.map(f)) | ||
command.parse(args, fromFiles, conf).map(_.map(f)) | ||
|
||
lazy val synopsis: UsageSynopsis = command.synopsis | ||
|
||
|
@@ -222,9 +228,12 @@ object Command { | |
|
||
def parse( | ||
args: List[String], | ||
fromFiles: List[FileArgs.ArgsFromFile], | ||
conf: CliConfig | ||
): IO[ValidationError, CommandDirective[A]] = | ||
left.parse(args, conf).catchSome { case ValidationError(CommandMismatch, _) => right.parse(args, conf) } | ||
left.parse(args, fromFiles, conf).catchSome { case ValidationError(CommandMismatch, _) => | ||
right.parse(args, fromFiles, conf) | ||
} | ||
|
||
lazy val synopsis: UsageSynopsis = UsageSynopsis.Mixed | ||
|
||
|
@@ -286,6 +295,7 @@ object Command { | |
|
||
def parse( | ||
args: List[String], | ||
fromFiles: List[FileArgs.ArgsFromFile], | ||
conf: CliConfig | ||
): IO[ValidationError, CommandDirective[(A, B)]] = { | ||
val helpDirectiveForChild = { | ||
|
@@ -294,7 +304,7 @@ object Command { | |
case _ :: tail => tail | ||
} | ||
child | ||
.parse(safeTail, conf) | ||
.parse(safeTail, fromFiles, conf) | ||
.collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) { | ||
case CommandDirective.BuiltIn(BuiltInOption.ShowHelp(synopsis, helpDoc)) => | ||
val parentName = names.headOption.getOrElse("") | ||
|
@@ -316,7 +326,7 @@ object Command { | |
case _ :: tail => tail | ||
} | ||
child | ||
.parse(safeTail, conf) | ||
.parse(safeTail, fromFiles, conf) | ||
.collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) { | ||
case directive @ CommandDirective.BuiltIn(BuiltInOption.ShowWizard(_)) => directive | ||
} | ||
|
@@ -326,7 +336,7 @@ object Command { | |
ZIO.succeed(CommandDirective.builtIn(BuiltInOption.ShowWizard(self))) | ||
|
||
parent | ||
.parse(args, conf) | ||
.parse(args, fromFiles, conf) | ||
.flatMap { | ||
case CommandDirective.BuiltIn(BuiltInOption.ShowHelp(_, _)) => | ||
helpDirectiveForChild orElse helpDirectiveForParent | ||
|
@@ -335,7 +345,7 @@ object Command { | |
case builtIn @ CommandDirective.BuiltIn(_) => ZIO.succeed(builtIn) | ||
case CommandDirective.UserDefined(leftover, a) if leftover.nonEmpty => | ||
child | ||
.parse(leftover, conf) | ||
.parse(leftover, fromFiles, conf) | ||
.mapBoth( | ||
{ | ||
case ValidationError(CommandMismatch, _) => | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
package zio.cli | ||
|
||
import zio._ | ||
import java.nio.file.Path | ||
|
||
trait FileArgs { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would change There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good call |
||
def getArgsFromFile(command: String): UIO[List[FileArgs.ArgsFromFile]] | ||
} | ||
object FileArgs extends FileArgsPlatformSpecific { | ||
|
||
final case class ArgsFromFile(path: String, args: List[String]) | ||
|
||
case object Noop extends FileArgs { | ||
override def getArgsFromFile(command: String): UIO[List[ArgsFromFile]] = ZIO.succeed(Nil) | ||
} | ||
|
||
case object Live extends FileArgs { | ||
|
||
private def optReadPath(path: Path): UIO[Option[FileArgs.ArgsFromFile]] = | ||
(for { | ||
exists <- ZIO.attempt(path.toFile.exists()) | ||
pathString = path.toString | ||
optContents <- | ||
ZIO | ||
.readFile(pathString) | ||
.map(c => FileArgs.ArgsFromFile(pathString, c.split('\n').map(_.trim).filter(_.nonEmpty).toList)) | ||
.when(exists) | ||
} yield optContents) | ||
.catchAllCause(ZIO.logErrorCause(s"Error reading options from file '$path', skipping...", _).as(None)) | ||
|
||
private def getPathAndParents(path: Path): Task[List[Path]] = | ||
for { | ||
parentPath <- ZIO.attempt(Option(path.getParent)) | ||
parents <- parentPath match { | ||
case Some(parentPath) => getPathAndParents(parentPath) | ||
case None => ZIO.succeed(Nil) | ||
} | ||
} yield path :: parents | ||
|
||
override def getArgsFromFile(command: String): UIO[List[ArgsFromFile]] = | ||
(for { | ||
cwd <- System.property("user.dir") | ||
home <- System.property("user.home") | ||
commandFile = s".$command" | ||
|
||
pathsFromCWD <- cwd match { | ||
case Some(cwd) => ZIO.attempt(Path.of(cwd)).flatMap(getPathAndParents) | ||
case None => ZIO.succeed(Nil) | ||
} | ||
homePath <- ZIO.foreach(home)(p => ZIO.attempt(Path.of(p))) | ||
allPaths = (pathsFromCWD ::: homePath.toList).distinct | ||
|
||
argsFromFiles <- ZIO.foreach(allPaths) { path => | ||
ZIO.attempt(path.resolve(commandFile)).flatMap(optReadPath) | ||
} | ||
} yield argsFromFiles.flatten) | ||
.catchAllCause(ZIO.logErrorCause(s"Error reading options from files, skipping...", _).as(Nil)) | ||
|
||
} | ||
|
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can be
private[cli]
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mean
or
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
did the first one, looked at other
*PlatformSpecifc