From aa91353a96cba3fdaf4b6e6233078dff34fe4bca Mon Sep 17 00:00:00 2001 From: James Collier Date: Tue, 16 Jun 2020 17:04:33 +0100 Subject: [PATCH] Cats effect module (#202) * basic support for cats-effect IO * refactor caseapp to use shared parse result -> what to do next method * refactor to get rid of shared AppRunners * add IO versions of all case-app apps * caseapp.cats.app -> caseapp.cats, the .app felt redundant * add cats-effect module usage to readme * add basic tests for IOCaseApp * rename Tests as utest defines Tests * add tests for IO command app update scalajs due to errors related to RecordedApp null refs * add tests for command help/usage * fix io apps docs * Disable compatibility checks for new module Co-authored-by: Alexandre Archambault --- README.md | 28 +++++ build.sbt | 19 ++- .../main/scala/caseapp/cats/IOCaseApp.scala | 79 ++++++++++++ .../scala/caseapp/cats/IOCommandApp.scala | 19 +++ .../scala/caseapp/cats/IOCommandAppA.scala | 18 +++ .../cats/IOCommandAppWithPreCommand.scala | 108 ++++++++++++++++ .../main/scala/caseapp/core/app/CaseApp.scala | 22 +--- project/Deps.scala | 1 + .../{Tests.scala => CaseAppTests.scala} | 4 +- .../src/test/scala/caseapp/DslTests.scala | 2 +- .../src/test/scala/caseapp/HelpTests.scala | 2 +- .../test/scala/caseapp/cats/CatsTests.scala | 118 ++++++++++++++++++ 12 files changed, 399 insertions(+), 21 deletions(-) create mode 100644 cats/shared/src/main/scala/caseapp/cats/IOCaseApp.scala create mode 100644 cats/shared/src/main/scala/caseapp/cats/IOCommandApp.scala create mode 100644 cats/shared/src/main/scala/caseapp/cats/IOCommandAppA.scala create mode 100644 cats/shared/src/main/scala/caseapp/cats/IOCommandAppWithPreCommand.scala rename tests/shared/src/test/scala/caseapp/{Tests.scala => CaseAppTests.scala} (99%) create mode 100644 tests/shared/src/test/scala/caseapp/cats/CatsTests.scala diff --git a/README.md b/README.md index 19721783..23ccf725 100644 --- a/README.md +++ b/README.md @@ -375,9 +375,35 @@ case class Options( ) ``` +### Cats Effect +A [cats-effect](https://github.com/typelevel/cats-effect) module is available, providing +`IO` versions of the application classes referenced above. They all extend [IOApp](https://typelevel.org/cats-effect/datatypes/ioapp.html) +so [`Timer`](https://typelevel.org/cats-effect/datatypes/timer.html) and [`ContextShift`](https://typelevel.org/cats-effect/datatypes/contextshift.html) +are conveniently available. +```scala +// additional imports +import caseapp.cats._ +import cats.effect._ + +object IOCaseExample extends IOCaseApp[ExampleOptions] { + def run(options: ExampleOptions, arg: RemainingArgs): IO[ExitCode] = IO { + // Core of the app + // ... + ExitCode.Success + } +} + +object IOCommandExample extends CommandApp[DemoCommand] { + def run(command: DemoCommand, args: RemainingArgs): IO[ExitCode] = IO { + // ... + ExitCode.Success + } +} +``` + ### Migration from the previous version Shared options used to be automatic, and now require the `@Recurse` @@ -390,6 +416,8 @@ Add to your `build.sbt` ```scala resolvers += Resolver.sonatypeRepo("releases") libraryDependencies += "com.github.alexarchambault" %% "case-app" % "2.0.0-M3" +// cats-effect module +libraryDependencies += "com.github.alexarchambault" %% "case-app-cats" % "2.0.0-M3" ``` Note that case-app depends on shapeless 2.3. Use the `1.0.0` version if you depend on shapeless 2.2. diff --git a/build.sbt b/build.sbt index c21ef074..73301312 100644 --- a/build.sbt +++ b/build.sbt @@ -43,6 +43,23 @@ lazy val util = crossProject(JSPlatform, JVMPlatform) lazy val utilJVM = util.jvm lazy val utilJS = util.js +lazy val cats = crossProject(JSPlatform, JVMPlatform) + .dependsOn(core) + .settings( + shared, + name := "case-app-cats", + Mima.settings, + mimaPreviousArtifacts := { + mimaPreviousArtifacts.value.filter(_.revision != "2.0.0") + }, + libs ++= Seq( + Deps.catsEffect.value + ) + ) + +lazy val catsJVM = cats.jvm +lazy val catsJS = cats.js + lazy val core = crossProject(JSPlatform, JVMPlatform) .dependsOn(annotations, util) .settings( @@ -57,7 +74,7 @@ lazy val coreJS = core.js lazy val tests = crossProject(JSPlatform, JVMPlatform) .disablePlugins(MimaPlugin) - .dependsOn(core) + .dependsOn(cats, core) .settings( shared, caseAppPrefix, diff --git a/cats/shared/src/main/scala/caseapp/cats/IOCaseApp.scala b/cats/shared/src/main/scala/caseapp/cats/IOCaseApp.scala new file mode 100644 index 00000000..8ccdad7a --- /dev/null +++ b/cats/shared/src/main/scala/caseapp/cats/IOCaseApp.scala @@ -0,0 +1,79 @@ +package caseapp.cats + +import caseapp.core.Error +import caseapp.core.help.{Help, WithHelp} +import caseapp.core.parser.Parser +import caseapp.core.RemainingArgs +import caseapp.Name +import caseapp.core.util.Formatter +import cats.effect.{ExitCode, IO, IOApp} + +abstract class IOCaseApp[T](implicit val parser0: Parser[T], val messages: Help[T]) extends IOApp { + + def parser: Parser[T] = { + val p = parser0.nameFormatter(nameFormatter) + if (stopAtFirstUnrecognized) + p.stopAtFirstUnrecognized + else + p + } + + def run(options: T, remainingArgs: RemainingArgs): IO[ExitCode] + + def error(message: Error): IO[ExitCode] = + IO(Console.err.println(message.message)) + .as(ExitCode.Error) + + def helpAsked: IO[ExitCode] = + println(messages.withHelp.help) + .as(ExitCode.Success) + + def usageAsked: IO[ExitCode] = + println(messages.withHelp.usage) + .as(ExitCode.Success) + + def println(x: String): IO[Unit] = + IO(Console.println(x)) + + /** + * Arguments are expanded then parsed. By default, argument expansion is the identity function. + * Overriding this method allows plugging in an arbitrary argument expansion logic. + * + * One such expansion logic involves replacing each argument of the form '@' with the + * contents of that file where each line in the file becomes a distinct argument. + * To enable this behavior, override this method as shown below. + + * @example + * {{{ + * import caseapp.core.parser.PlatformArgsExpander + * override def expandArgs(args: List[String]): List[String] + * = PlatformArgsExpander.expand(args) + * }}} + * + * @param args + * @return + */ + def expandArgs(args: List[String]): List[String] = args + + /** + * Whether to stop parsing at the first unrecognized argument. + * + * That is, stop parsing at the first non option (not starting with "-"), or + * the first unrecognized option. The unparsed arguments are put in the `args` + * argument of `run`. + */ + def stopAtFirstUnrecognized: Boolean = + false + + def nameFormatter: Formatter[Name] = + Formatter.DefaultNameFormatter + + override def run(args: List[String]): IO[ExitCode] = + parser.withHelp.detailedParse(args) match { + case Left(err) => error(err) + case Right((WithHelp(true, _, _), _)) => usageAsked + case Right((WithHelp(_, true, _), _)) => helpAsked + case Right((WithHelp(_, _, Left(err)), _)) => error(err) + case Right((WithHelp(_, _, Right(t)), remainingArgs)) => run(t, remainingArgs) + } +} diff --git a/cats/shared/src/main/scala/caseapp/cats/IOCommandApp.scala b/cats/shared/src/main/scala/caseapp/cats/IOCommandApp.scala new file mode 100644 index 00000000..9477a21d --- /dev/null +++ b/cats/shared/src/main/scala/caseapp/cats/IOCommandApp.scala @@ -0,0 +1,19 @@ +package caseapp.cats + +import caseapp.core.commandparser.CommandParser +import caseapp.core.Error +import caseapp.core.help.CommandsHelp +import cats.effect.{ExitCode, IO} + +abstract class IOCommandApp[T](implicit + commandParser: CommandParser[T], + commandsMessages: CommandsHelp[T] +) extends IOCommandAppWithPreCommand[None.type , T] { + + override def beforeCommand(options: None.type, remainingArgs: Seq[String]): IO[Option[ExitCode]] = { + if (remainingArgs.nonEmpty) { + error(Error.Other(s"Found extra arguments: ${remainingArgs.mkString(" ")}")) + .map(Some(_)) + } else IO.none + } +} diff --git a/cats/shared/src/main/scala/caseapp/cats/IOCommandAppA.scala b/cats/shared/src/main/scala/caseapp/cats/IOCommandAppA.scala new file mode 100644 index 00000000..88b26896 --- /dev/null +++ b/cats/shared/src/main/scala/caseapp/cats/IOCommandAppA.scala @@ -0,0 +1,18 @@ +package caseapp.cats + +import caseapp.core.commandparser.CommandParser +import caseapp.core.help.CommandsHelp +import caseapp.core.RemainingArgs +import cats.effect.{ExitCode, IO} + +/* The A suffix stands for anonymous */ +abstract class IOCommandAppA[T](implicit + commandParser: CommandParser[T], + commandsMessages: CommandsHelp[T] +) extends IOCommandApp[T]()(commandParser, commandsMessages) { + + def runA: RemainingArgs => T => IO[ExitCode] + + override def run(options: T, remainingArgs: RemainingArgs): IO[ExitCode] = + runA(remainingArgs)(options) +} diff --git a/cats/shared/src/main/scala/caseapp/cats/IOCommandAppWithPreCommand.scala b/cats/shared/src/main/scala/caseapp/cats/IOCommandAppWithPreCommand.scala new file mode 100644 index 00000000..d060c184 --- /dev/null +++ b/cats/shared/src/main/scala/caseapp/cats/IOCommandAppWithPreCommand.scala @@ -0,0 +1,108 @@ +package caseapp.cats + +import caseapp.core.Error +import caseapp.core.commandparser.CommandParser +import caseapp.core.help.{CommandsHelp, Help, WithHelp} +import caseapp.core.parser.Parser +import caseapp.core.RemainingArgs +import cats.effect._ + +abstract class IOCommandAppWithPreCommand[D, T](implicit + val beforeCommandParser: Parser[D], + baseBeforeCommandMessages: Help[D], + val commandParser: CommandParser[T], + val commandsMessages: CommandsHelp[T] +) extends IOApp { + + /** + * Override to support conditional early exit, suppressing a run. + * @param options parsed options + * @param remainingArgs extra arguments + * @return exit code for early exit, none to call run + */ + def beforeCommand(options: D, remainingArgs: Seq[String]): IO[Option[ExitCode]] + + def run(options: T, remainingArgs: RemainingArgs): IO[ExitCode] + + def error(message: Error): IO[ExitCode] = IO { + Console.err.println(message.message) + ExitCode(255) + } + + lazy val beforeCommandMessages: Help[D] = + baseBeforeCommandMessages + .withAppName(appName) + .withAppVersion(appVersion) + .withProgName(progName) + .withOptionsDesc(s"[options] [command] [command-options]") + .asInstanceOf[Help[D]] // circumventing data-class losing the type param :| + + lazy val commands: Seq[Seq[String]] = CommandsHelp[T].messages.map(_._1) + + def helpAsked(): IO[ExitCode] = + println( + s"""${beforeCommandMessages.help} + |Available commands: ${commands.map(_.mkString(" ")).mkString(", ")} + | + |Type $progName command --help for help on an individual command""" + .stripMargin) + .as(ExitCode.Success) + + def commandHelpAsked(command: Seq[String]): IO[ExitCode] = + println(commandsMessages.messagesMap(command).helpMessage(beforeCommandMessages.progName, command)) + .as(ExitCode.Success) + + def usageAsked(): IO[ExitCode] = + println( + s"""${beforeCommandMessages.usage} + |Available commands: ${commands.map(_.mkString(" ")).mkString(", ")} + | + |Type $progName command --usage for usage of an individual command""" + .stripMargin) + .as(ExitCode.Success) + + def commandUsageAsked(command: Seq[String]): IO[ExitCode] = + println(commandsMessages.messagesMap(command).usageMessage(beforeCommandMessages.progName, command)) + .as(ExitCode.Success) + + def println(x: String): IO[Unit] = + IO(Console.println(x)) + + def appName: String = Help[D].appName + def appVersion: String = Help[D].appVersion + def progName: String = Help[D].progName + + override def run(args: List[String]): IO[ExitCode] = { + commandParser.withHelp.detailedParse(args.toVector)(beforeCommandParser.withHelp) match { + case Left(err) => + error(err) + case Right((WithHelp(true, _, _), _, _)) => + usageAsked() + case Right((WithHelp(_, true, _), _, _)) => + helpAsked() + case Right((WithHelp(false, false, Left(err)), _, _)) => + error(err) + case Right((WithHelp(false, false, Right(d)), dArgs, optCmd)) => + beforeCommand(d, dArgs).flatMap { + case Some(exitCode) => IO.pure(exitCode) + case None => + optCmd + .map { + case Left(err) => + error(err) + case Right((c, WithHelp(true, _, _), _)) => + commandUsageAsked(c) + case Right((c, WithHelp(_, true, _), _)) => + commandHelpAsked(c) + case Right((_, WithHelp(_, _, t), commandArgs)) => + t.fold( + error, + run(_, commandArgs) + ) + } + .getOrElse(IO(ExitCode.Success)) + } + } + } + +} diff --git a/core/shared/src/main/scala/caseapp/core/app/CaseApp.scala b/core/shared/src/main/scala/caseapp/core/app/CaseApp.scala index b365e6cf..fe713832 100644 --- a/core/shared/src/main/scala/caseapp/core/app/CaseApp.scala +++ b/core/shared/src/main/scala/caseapp/core/app/CaseApp.scala @@ -74,22 +74,12 @@ abstract class CaseApp[T](implicit val parser0: Parser[T], val messages: Help[T] Formatter.DefaultNameFormatter def main(args: Array[String]): Unit = - parser.withHelp.detailedParse(expandArgs(args.toList), stopAtFirstUnrecognized) match { - case Left(err) => - error(err) - - case Right((WithHelp(usage, help, t), remainingArgs)) => - - if (help) - helpAsked() - - if (usage) - usageAsked() - - t.fold( - error, - run(_, remainingArgs) - ) + parser.withHelp.detailedParse(args) match { + case Left(err) => error(err) + case Right((WithHelp(true, _, _), _)) => usageAsked() + case Right((WithHelp(_, true, _), _)) => helpAsked() + case Right((WithHelp(_, _, Left(err)), _)) => error(err) + case Right((WithHelp(_, _, Right(t)), remainingArgs)) => run(_, remainingArgs) } } diff --git a/project/Deps.scala b/project/Deps.scala index d0642365..c17d5754 100644 --- a/project/Deps.scala +++ b/project/Deps.scala @@ -8,6 +8,7 @@ object Deps { import Def.setting + def catsEffect = setting("org.typelevel" %%% "cats-effect" % "2.1.3") def dataClass = "io.github.alexarchambault" %% "data-class" % "0.2.3" def macroParadise = "org.scalamacros" % "paradise" % "2.1.1" cross CrossVersion.patch def refined = setting("eu.timepit" %%% "refined" % "0.9.14") diff --git a/tests/shared/src/test/scala/caseapp/Tests.scala b/tests/shared/src/test/scala/caseapp/CaseAppTests.scala similarity index 99% rename from tests/shared/src/test/scala/caseapp/Tests.scala rename to tests/shared/src/test/scala/caseapp/CaseAppTests.scala index 056fab3c..0b9f5d6e 100644 --- a/tests/shared/src/test/scala/caseapp/Tests.scala +++ b/tests/shared/src/test/scala/caseapp/CaseAppTests.scala @@ -8,12 +8,12 @@ import shapeless.{Inl, Inr} import utest._ import caseapp.core.util.Formatter -object Tests extends TestSuite { +object CaseAppTests extends TestSuite { import Definitions._ - val tests = TestSuite { + val tests = Tests { "parse no args" - { val res = Parser[NoArgs].parse(Seq.empty) diff --git a/tests/shared/src/test/scala/caseapp/DslTests.scala b/tests/shared/src/test/scala/caseapp/DslTests.scala index f12c4de3..d8cdf255 100644 --- a/tests/shared/src/test/scala/caseapp/DslTests.scala +++ b/tests/shared/src/test/scala/caseapp/DslTests.scala @@ -6,7 +6,7 @@ object DslTests extends TestSuite { final case class Result(foo: Int, bar: String = "ab", value: Double) - val tests = TestSuite { + val tests: Tests = Tests { "simple" - { diff --git a/tests/shared/src/test/scala/caseapp/HelpTests.scala b/tests/shared/src/test/scala/caseapp/HelpTests.scala index b4477fed..9c072dbc 100644 --- a/tests/shared/src/test/scala/caseapp/HelpTests.scala +++ b/tests/shared/src/test/scala/caseapp/HelpTests.scala @@ -17,7 +17,7 @@ object HelpTests extends TestSuite { @ValueDescription("overridden description") value: String ) - val tests = TestSuite { + val tests = Tests { def lines(s: String) = s.linesIterator.toVector def checkLines(message: String, expectedMessage: String) = { diff --git a/tests/shared/src/test/scala/caseapp/cats/CatsTests.scala b/tests/shared/src/test/scala/caseapp/cats/CatsTests.scala new file mode 100644 index 00000000..193a112e --- /dev/null +++ b/tests/shared/src/test/scala/caseapp/cats/CatsTests.scala @@ -0,0 +1,118 @@ +package caseapp.cats + +import cats.effect._ +import cats.effect.concurrent.Ref +import caseapp._ +import caseapp.core.help.{CommandsHelp, Help} +import caseapp.core.Error +import utest._ + +sealed trait RecordedApp { + + val stdoutBuff: Ref[IO, List[String]] = Ref.unsafe(List.empty) + val stderrBuff: Ref[IO, List[String]] = Ref.unsafe(List.empty) + + def run(args: List[String]): IO[ExitCode] +} + +private class RecordedIOCaseApp[T](implicit parser0: Parser[T], messages: Help[T]) extends IOCaseApp[T]()(parser0, messages) with RecordedApp { + + override def error(message: Error): IO[ExitCode] = + stderrBuff.update(message.message :: _) + .as(ExitCode.Error) + + override def println(x: String): IO[Unit] = + stdoutBuff.update(x :: _) + + override def run(options: T, remainingArgs: RemainingArgs): IO[ExitCode] = + println(s"run: $options").as(ExitCode.Success) +} + +private class RecordedIOCommandApp[T](implicit parser0: CommandParser[T], messages: CommandsHelp[T]) extends IOCommandApp[T]()(parser0, messages) with RecordedApp { + + override def error(message: Error): IO[ExitCode] = + stderrBuff.update(message.message :: _) + .as(ExitCode.Error) + + override def println(x: String): IO[Unit] = + stdoutBuff.update(x :: _) + + override def run(options: T, remainingArgs: RemainingArgs): IO[ExitCode] = + println(s"run: $options").as(ExitCode.Success) +} + +object CatsTests extends TestSuite { + + import Definitions._ + + private def testCaseStdout(args: List[String], expected: String) = + testRunFuture(new RecordedIOCaseApp[FewArgs](), args, expectedStdout = List(expected), expectedStderr = List.empty) + + private def testCaseStderr(args: List[String], expected: String) = + testRunFuture(new RecordedIOCaseApp[FewArgs](), args, expectedStdout = List.empty, expectedStderr = List(expected)) + + private def testCommandStdout(args: List[String], expected: String) = + testRunFuture(new RecordedIOCommandApp[Command](), args, expectedStdout = List(expected), expectedStderr = List.empty) + + private def testCommandStderr(args: List[String], expected: String) = + testRunFuture(new RecordedIOCommandApp[Command](), args, expectedStdout = List.empty, expectedStderr = List(expected)) + + private def testRunFuture(app: RecordedApp, args: List[String], expectedStdout: List[String], expectedStderr: List[String]) = { + app.run(args) + .flatMap { _ => + for { + stdoutRes <- app.stdoutBuff.get + stderrRes <- app.stderrBuff.get + } yield assert(stdoutRes == expectedStdout, stderrRes == expectedStderr) + } + .unsafeToFuture() + } + + override def tests: Tests = Tests { + test("IOCaseApp") - { + test("output usage") - { + testCaseStdout(List("--usage"), Help[FewArgs].withHelp.usage) + } + test("output help") - { + testCaseStdout(List("--help"), Help[FewArgs].withHelp.help) + } + test("parse error") - { + testCaseStderr(List("--invalid"), "Unrecognized argument: --invalid") + } + test("run") - { + testCaseStdout(List("--value", "foo", "--num-foo", "42"), "run: FewArgs(foo,42)") + } + } + test("IOCommandApp") - { + test("output usage") - { + testCommandStdout(List("--usage"), + """Usage: none.type [options] [command] [command-options] + |Available commands: first, second, third + | + |Type none.type command --usage for usage of an individual command""".stripMargin) + } + test("output help") - { + testCommandStdout(List("--help"), + """None.type + |Usage: none.type [options] [command] [command-options] + | + | + |Available commands: first, second, third + | + |Type none.type command --help for help on an individual command""".stripMargin) + } + test("parse error") - { + testCommandStderr(List("--invalid"), "Unrecognized argument: --invalid") + } + test("output command usage") - { + testCommandStdout(List("first", "--usage"), CommandsHelp[Command].messagesMap(List("first")).usageMessage("none.type", List("first"))) + } + test("output command help") - { + testCommandStdout(List("first", "--help"), CommandsHelp[Command].messagesMap(List("first")).helpMessage("none.type", List("first"))) + } + test("run") - { + testCommandStdout(List("first", "--foo", "foo", "--bar", "42"), "run: First(foo,42)") + } + } + } +}