scopt 4

in

scopt is a little command line options parsing library.

最近 scopt 4.0 を書いている。せっかちな人は readme に飛んでほしい。

ベータ版を試すには以下を build.sbt に書く:

libraryDependencies += "com.github.scopt" %% "scopt" % "4.0.0-RC2"

scopt は、2008年に aaronharnly/scala-options として始まり、当初は Ruby の OptionParser を緩めにベースにしたものだった。scopt 2 で immutable parsing を導入して、scopt 3 では Read 型クラスを使ってメソッド数を大幅に減らすことができた。

後方ソース互換性

Sonatype によると scopt 3.x は 2018年11月に 370,325回ダウンロードされた。GitHub のコード検索を行うと "com.github.scopt" に対して 61,449件のヒットがある。CI やキャッシングがあるため、絶対値にはあまり意味が無いが、これらは scopt 3.x がある程度のユーザに使われていることを示唆する。そのため、マイグレーションコストを意識する必要がある。

scopt 4 はオプションパーサーを定義するための新しい方法を導入するが、scopt 3 での「オブジェクト指向 DSL」もそのままキープする予定だ。

val parser = new scopt.OptionParser[Config]("scopt") {
  head("scopt", "3.x")
 
  opt[Int]('f', "foo")
    .action((x, c) => c.copy(foo = x))
    .text("foo is an integer property")
 
  opt[File]('o', "out")
    .required()
    .valueName("<file>")
    .action((x, c) => c.copy(out = x))
    .text("out is a required file property")
}

これまで scopt 3 を使ってきた人は、コンパイルが通れば多分 ok なはずだ。

コマンドラインパーサーの合成

scopt で何回か聞かれた質問の機能の要望として、複数の小さいオプションパーサーを合成して一つのパーサーを作りたいというものがある。例えば scopt/scopt#215 がある:

互いに素であるオプションの集合に対して別々のパーサーを定義して、必要に応じてそれらを合成したい。例えば、サブプロジェクトにそれぞれ別のパーサーを定義したい。

2014年に書いた「モナドはフラクタルだ」でオプションパーサーをモナディックなデータ型として定義すれば合成可能になるのではないかというアイディアを思いついた。

関数型 DSL

scopt 4 における関数型 DSL は以下のようになる:

import scopt.OParser
val builder = OParser.builder[Config]
val parser1 = {
  import builder._
  OParser.sequence(
    programName("scopt"),
    head("scopt", "4.x"),
    // option -f, --foo
    opt[Int]('f', "foo")
      .action((x, c) => c.copy(foo = x))
      .text("foo is an integer property"),
    // more options here...
  )
}
 
// OParser.parse returns Option[Config]
OParser.parse(parser1, args, Config()) match {
  case Some(config) =>
    // do something
  case _ =>
    // arguments are bad, error message will have been displayed
}

OptionParser 内でメソッドを呼ぶのではなく、関数型 DSL はまず特定の Config データ型に対するビルダーを作って、opt[A](...) など Oparser[A, Config] を返す関数を呼ぶ。

これらの OParser[A, Config] パーサーは OParser.sequence(...) を用いて合成できる。

最初は for 内包表記を使ってこの合成を表すことも考えていたが、その見た目に慣れて人にとっては分かりづらいと思ったので sequence 関数を作った。

OParser.sequence を用いた合成

OParser.sequence を用いた OParser の合成の具体例を見てみる。

import scopt.OParser
val builder = OParser.builder[Config]
import builder._
 
val p1 =
  OParser.sequence(
    opt[Int]('f', "foo")
      .action((x, c) => c.copy(intValue = x))
      .text("foo is an integer property"),
    opt[Unit]("debug")
      .action((_, c) => c.copy(debug = true))
      .text("debug is a flag")
  )
val p2 =
  OParser.sequence(
    arg[String]("<source>")
      .action((x, c) => c.copy(a = x)),
    arg[String]("<dest>")
      .action((x, c) => c.copy(b = x))
  )
val p3 =
  OParser.sequence(
    head("scopt", "4.x"),
    programName("scopt"),
    p1,
    p2
  )

cmd("...").children(...) を用いた合成

OParser を再利用するもう一つの方法があって、それは cmd("...") パーサーの .children(...) メソッドに渡すことだ。

val p4 = {
  import builder._
  OParser.sequence(
    programName("scopt"),
    head("scopt", "4.x"),
    cmd("update")
      .action((x, c) => c.copy(update = true))
      .children(suboptionParser1),
    cmd("status")
      .action((x, c) => c.copy(status = true))
      .children(suboptionParser1)
  )
}

上の例では suboptionParser1OParser だ。これによって例えば update コマンドと status コマンドにおいて共通のコマンドを再利用することができる。

コンフィギュレーションデータ型の合成

OParser.sequence はパーサープログラムの合成を可能とするが、同じ Config データ型を使わなければいけないという制約がある。別々のサププロジェクトからパーサーを提供しようした場合これは嬉しくない。

Config データ型を分ける一つの具体例をここに紹介する。

// provide this in subproject1
trait ConfigLike1[R] {
  def withDebug(value: Boolean): R
}
def parser1[R <: ConfigLike1[R]]: OParser[_, R] = {
  val builder = OParser.builder[R]
  import builder._
  OParser.sequence(
    opt[Unit]("debug").action((_, c) => c.withDebug(true)),
    note("something")
  )
}
 
// provide this in subproject2
trait ConfigLike2[R] {
  def withVerbose(value: Boolean): R
}
def parser2[R <: ConfigLike2[R]]: OParser[_, R] = {
  val builder = OParser.builder[R]
  import builder._
  OParser.sequence(
    opt[Unit]("verbose").action((_, c) => c.withVerbose(true)),
    note("something else")
  )
}
 
// compose config datatypes and parsers
case class Config1(debug: Boolean = false, verbose: Boolean = false)
    extends ConfigLike1[Config1]
    with ConfigLike2[Config1] {
  override def withDebug(value: Boolean) = copy(debug = value)
  override def withVerbose(value: Boolean) = copy(verbose = value)
}
val parser3: OParser[_, Config1] = {
  val builder = OParser.builder[Config1]
  import builder._
  OParser.sequence(
    programName("scopt"),
    head("scopt", "4.x"),
    parser1,
    parser2
  )
}

この例では parser1parser2 は、ConfigLike1[R]ConfigLike2[R] のサブタイプであるという制約を満たす抽象型 R に対して書かれている。parser3 において、R は具象データ型 Config1 に束縛される。

usage の自動生成

scopt 3 同様に、usage text が自動的に生成される。

scopt 4.x
Usage: scopt [update] [options] [<file>...]
 
  -f, --foo <value>        foo is an integer property
  -o, --out <file>         out is a required file property
  --max:<libname>=<max>    maximum count for <libname>
  -j, --jars <jar1>,<jar2>...
                           jars to include
  --kwargs k1=v1,k2=v2...  other arguments
  --verbose                verbose is a flag
  --help                   prints this usage text
  <file>...                optional unbounded args
some notes.
 
Command: update [options]
update is a command.
  -nk, --not-keepalive     disable keepalive
  --xyz <value>            xyz is a boolean property

scopt 4 を使ってみて、何か気づいたらバグ報告をしてほしい。