Skip to content

Commit

Permalink
Cats effect module (#202)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
Slakah and alexarchambault authored Jun 16, 2020
1 parent 7592fd8 commit aa91353
Show file tree
Hide file tree
Showing 12 changed files with 399 additions and 21 deletions.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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.
Expand Down
19 changes: 18 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down
79 changes: 79 additions & 0 deletions cats/shared/src/main/scala/caseapp/cats/IOCaseApp.scala
Original file line number Diff line number Diff line change
@@ -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 '@<file>' 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)
}
}
19 changes: 19 additions & 0 deletions cats/shared/src/main/scala/caseapp/cats/IOCommandApp.scala
Original file line number Diff line number Diff line change
@@ -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
}
}
18 changes: 18 additions & 0 deletions cats/shared/src/main/scala/caseapp/cats/IOCommandAppA.scala
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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))
}
}
}

}
22 changes: 6 additions & 16 deletions core/shared/src/main/scala/caseapp/core/app/CaseApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
1 change: 1 addition & 0 deletions project/Deps.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tests/shared/src/test/scala/caseapp/DslTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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" - {

Expand Down
2 changes: 1 addition & 1 deletion tests/shared/src/test/scala/caseapp/HelpTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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) = {
Expand Down
Loading

0 comments on commit aa91353

Please sign in to comment.