Skip to content

Commit

Permalink
Merge branch 'master' into regex
Browse files Browse the repository at this point in the history
  • Loading branch information
derekmorr committed May 18, 2017
2 parents 0a8c4c9 + 1bccfd5 commit 8bd0103
Show file tree
Hide file tree
Showing 12 changed files with 332 additions and 6 deletions.
4 changes: 4 additions & 0 deletions build.sbt
Expand Up @@ -10,6 +10,10 @@ lazy val docs = (project in file("docs")).
settings(settings, publishArtifact := false).
dependsOn(core)

lazy val cats = (project in file("modules/cats")).
settings(settings).
dependsOn(core)

lazy val enumeratum = (project in file("modules/enumeratum")).
settings(settings).
dependsOn(core)
Expand Down
53 changes: 51 additions & 2 deletions core/src/main/scala/pureconfig/DurationConvert.scala
Expand Up @@ -5,7 +5,9 @@ package pureconfig

import pureconfig.error.{ CannotConvert, ConfigReaderFailure, ConfigValueLocation }

import scala.concurrent.duration.{ Duration, FiniteDuration }
import scala.concurrent.duration.Duration.{ Inf, MinusInf }
import scala.concurrent.duration.{ DAYS, Duration, FiniteDuration, HOURS, MICROSECONDS, MILLISECONDS, MINUTES, NANOSECONDS, SECONDS, TimeUnit }
import scala.util.Try

/**
* Utility functions for converting a Duration to a String and vice versa.
Expand All @@ -17,14 +19,61 @@ private[pureconfig] object DurationConvert {
val fromString: String => Option[ConfigValueLocation] => Either[ConfigReaderFailure, Duration] = { string => location =>
if (string == UndefinedDuration) Right(Duration.Undefined)
else try {
Right(Duration(addZeroUnit(justAMinute(itsGreekToMe(string)))))
Right(parseDuration(addZeroUnit(justAMinute(itsGreekToMe(string)))))
} catch {
case ex: NumberFormatException =>
val err = s"${ex.getMessage}. (try a number followed by any of ns, us, ms, s, m, h, d)"
Left(CannotConvert(string, "Duration", err, location, None))
}
}

////////////////////////////////////
// This is a copy of Duration(str: String) that fixes the bug on precision
//

// "ms milli millisecond" -> List("ms", "milli", "millis", "millisecond", "milliseconds")
private[this] def words(s: String) = (s.trim split "\\s+").toList
private[this] def expandLabels(labels: String): List[String] = {
val hd :: rest = words(labels)
hd :: rest.flatMap(s => List(s, s + "s"))
}

private[this] val timeUnitLabels = List(
DAYS -> "d day",
HOURS -> "h hour",
MINUTES -> "min minute",
SECONDS -> "s sec second",
MILLISECONDS -> "ms milli millisecond",
MICROSECONDS -> "µs micro microsecond",
NANOSECONDS -> "ns nano nanosecond")

// Label => TimeUnit
protected[pureconfig] val timeUnit: Map[String, TimeUnit] =
timeUnitLabels.flatMap { case (unit, names) => expandLabels(names) map (_ -> unit) }.toMap

private[pureconfig] def parseDuration(s: String): Duration = {
val s1: String = s filterNot (_.isWhitespace)
s1 match {
case "Inf" | "PlusInf" | "+Inf" => Inf
case "MinusInf" | "-Inf" => MinusInf
case _ =>
val unitName = s1.reverse.takeWhile(_.isLetter).reverse
timeUnit get unitName match {
case Some(unit) =>
val valueStr = s1 dropRight unitName.length
// Reading Long first avoids losing precision unnecessarily
Try(Duration(java.lang.Long.parseLong(valueStr), unit)).getOrElse {
// But if the value is a fractional number, then we have to parse it
// as a Double, which will lose precision and possibly change the units.
Duration(java.lang.Double.parseDouble(valueStr), unit)
}
case _ => throw new NumberFormatException("format error " + s)
}
}
}

////////////////////////////////////

private val zeroRegex = "\\s*[+-]?0+\\s*$".r
private val fauxMuRegex = "([0-9])(\\s*)us(\\s*)$".r
private val shortMinuteRegex = "([0-9])(\\s*)m(\\s*)$".r
Expand Down
Expand Up @@ -61,7 +61,7 @@ object ConfigValueLocation {
* ConfigValue. The failure contains an optional location of the ConfigValue
* that raised the error.
*/
sealed abstract class ConfigReaderFailure {
abstract class ConfigReaderFailure {
/**
* The optional location of the ConfigReaderFailure.
*/
Expand Down
2 changes: 2 additions & 0 deletions core/src/test/scala/pureconfig/BasicConvertersSuite.scala
Expand Up @@ -19,6 +19,8 @@ import scala.util.matching.Regex

class BasicConvertersSuite extends BaseSuite {

implicit override val generatorDrivenConfig = PropertyCheckConfiguration(minSuccessful = 100)

behavior of "ConfigConvert"

checkArbitrary[Duration]
Expand Down
37 changes: 34 additions & 3 deletions core/src/test/scala/pureconfig/DurationConvertSuite.scala
Expand Up @@ -7,10 +7,10 @@ import org.scalatest.{ EitherValues, FlatSpec, Matchers }
import org.scalatest.Inspectors._
import pureconfig.error.CannotConvert

import scala.concurrent.duration.Duration
import scala.concurrent.duration._
import DurationConvertSuite._

class DurationConvertSuite extends FlatSpec with Matchers with EitherValues {
import DurationConvert.{ fromDuration => fromD }
"Converting a Duration to a String" should "pick an appropriate unit when dealing with whole units less than the next step up" in {
fromD(Duration(14, TimeUnit.DAYS)) shouldBe "14d"
fromD(Duration(16, TimeUnit.HOURS)) shouldBe "16h"
Expand Down Expand Up @@ -40,7 +40,6 @@ class DurationConvertSuite extends FlatSpec with Matchers with EitherValues {
fromS(fromD(expected)).right.value shouldBe expected
}

val fromS = DurationConvert.fromString(_: String)(None)
"Converting a String to a Duration" should "succeed for known units" in {
fromS("1d") shouldBe Right(Duration(1, TimeUnit.DAYS))
fromS("47h") shouldBe Right(Duration(47, TimeUnit.HOURS))
Expand Down Expand Up @@ -91,4 +90,36 @@ class DurationConvertSuite extends FlatSpec with Matchers with EitherValues {
it should "correctly round trip when converting Duration.Inf" in {
fromS(fromD(Duration.Inf)) shouldEqual Right(Duration.Inf)
}
it should "convert a value larger than 2^52" in {
fromS("8092048641075763 ns").right.value shouldBe Duration(8092048641075763L, NANOSECONDS)
}
it should "round trip a value which is greater than 2^52" in {
val expected = Duration(781251341142500992L, NANOSECONDS)
fromS(fromD(expected)).right.value shouldBe expected
}
it should "round trip a value < 2^52 which is > 2^52 when converted to milliseconds" in {
val expected = Duration(781251341142501L, MICROSECONDS)
fromS(fromD(expected)).right.value shouldBe expected
}
it should "freak when given a value larger than 2^64" in {
val result = dcc.from(scc.to("12345678901234567890 ns"))
result shouldBe a[Left[_, _]]
val ex = result.left.value.head.asInstanceOf[error.ThrowableFailure].throwable
ex.getMessage should include regex "trying to construct too large duration"
}
it should "parse a fractional value" in {
val expected = 1.5.minutes
fromS(fromD(expected)).right.value shouldBe expected
}
it should "change the units on a fractional value, failing to round trip in the config representation" in {
val duration = dcc.from(scc.to("1.5 minutes")).right.value
val rep = scc.from(dcc.to(duration)).right.value shouldBe "90s" // Not "1.5 minutes" as we might hope
}
}

object DurationConvertSuite {
val scc = implicitly[ConfigConvert[String]]
val dcc = implicitly[ConfigConvert[Duration]]
val fromD = DurationConvert.fromDuration(_: Duration)
val fromS = DurationConvert.fromString(_: String)(None)
}
5 changes: 5 additions & 0 deletions core/src/test/scala/pureconfig/gen/package.scala
Expand Up @@ -21,6 +21,11 @@ package object gen {
val genJavaDuration: Gen[JavaDuration] = for {
seconds <- Gen.choose(Long.MinValue + 1, Long.MaxValue)
nanoseconds <- Gen.choose(0L, MaximumNanoseconds)
// JDK Bug: when seconds % 60 == -1 and nanoseconds > 0, Duration.toString produces
// a strange value for the seconds with is -0. followed by 1000_000_000 - nanoseconds
// e.g. Duration.ofSeconds(-1, 1).toString returns PT-0.999999999S
// Duration.parse loads this value as PT0.999999999S instead of the original value
if nanoseconds == 0 || seconds % 60 != -1
} yield JavaDuration.ofSeconds(seconds, nanoseconds)

val genDuration: Gen[Duration] =
Expand Down
45 changes: 45 additions & 0 deletions modules/cats/README.md
@@ -0,0 +1,45 @@
# Cats module for PureConfig

Adds support for selected [cats](http://typelevel.org/cats/) classes to PureConfig.

## Add pureconfig-cats to your project

In addition to [core pureconfig](https://github.com/pureconfig/pureconfig), you'll need:

```scala
libraryDependencies += "com.github.pureconfig" %% "pureconfig-cats" % "0.7.1"
```

## Example

To load a `NonEmptyList[Int]` into a configuration, we need a class to hold our configuration:

```scala
import cats.data.{NonEmptyList, NonEmptyVector}
import com.typesafe.config.ConfigFactory.parseString
import pureconfig._
import pureconfig.module.cats._

case class MyConfig(numbers: NonEmptyList[Int])
```

We can read a `MyConfig` like:
```scala
val conf = parseString("""{ numbers: [1,2,3] }""")
// conf: com.typesafe.config.Config = Config(SimpleConfigObject({"numbers":[1,2,3]}))

loadConfig[MyConfig](conf)
// res1: Either[pureconfig.error.ConfigReaderFailures,MyConfig] = Right(MyConfig(NonEmptyList(1, 2, 3)))
```

You can also load `NonEmptyVector`. First, define a case class for the config:

```scala
case class MyVecConfig(numbers: NonEmptyVector[Int])
```

then load the config:
```scala
loadConfig[MyVecConfig](conf)
// res2: Either[pureconfig.error.ConfigReaderFailures,MyVecConfig] = Right(MyVecConfig(NonEmptyVector(1, 2, 3)))
```
52 changes: 52 additions & 0 deletions modules/cats/build.sbt
@@ -0,0 +1,52 @@
name := "pureconfig-cats"

organization := "com.github.pureconfig"

homepage := Some(url("https://github.com/pureconfig/pureconfig"))

licenses := Seq("Mozilla Public License, version 2.0" -> url("https://www.mozilla.org/MPL/2.0/"))

resolvers ++= Seq(
Resolver.sonatypeRepo("releases"),
Resolver.sonatypeRepo("snapshots")
)

libraryDependencies ++= Seq(
"org.typelevel" %% "cats-core" % "0.9.0",
Dependencies.scalaMacrosParadise,
Dependencies.scalaTest,
Dependencies.scalaCheck
)

publishMavenStyle := true

publishTo := {
val nexus = "https://oss.sonatype.org/"
if (isSnapshot.value)
Some("snapshots" at nexus + "content/repositories/snapshots")
else
Some("releases" at nexus + "service/local/staging/deploy/maven2")
}

publishArtifact in Test := false

pomExtra := (
<scm>
<url>git@github.com:pureconfig/pureconfig.git</url>
<connection>scm:git:git@github.com:pureconfig/pureconfig.git</connection>
</scm>
<developers>
<developer>
<id>derekmorr</id>
<name>Derek Morr</name>
<url>https://github.com/derekmorr</url>
</developer>
</developers>)

osgiSettings

OsgiKeys.exportPackage := Seq("pureconfig.module.cats.*")

OsgiKeys.privatePackage := Seq()

OsgiKeys.importPackage := Seq(s"""scala.*;version="[${scalaBinaryVersion.value}.0,${scalaBinaryVersion.value}.50)"""", "*")
22 changes: 22 additions & 0 deletions modules/cats/src/main/scala/pureconfig/module/Cats.scala
@@ -0,0 +1,22 @@
package pureconfig.module

import pureconfig.error.{ ConfigReaderFailure, ConfigValueLocation }

object Cats {

/**
* A failure representing an unexpected empty traversable
*
* @param typ the type that was attempted to be converted to from an empty string
* @param location an optional location of the ConfigValue that raised the
* failure
* @param path an optional path to the value which was an unexpected empty string
*/
final case class EmptyTraversableFound(typ: String, location: Option[ConfigValueLocation], path: Option[String]) extends ConfigReaderFailure {
def description = s"Empty collection found when trying to convert to $typ."

def withImprovedContext(parentKey: String, parentLocation: Option[ConfigValueLocation]) =
this.copy(location = location orElse parentLocation, path = path.map(parentKey + "." + _) orElse Some(parentKey))
}

}
38 changes: 38 additions & 0 deletions modules/cats/src/main/scala/pureconfig/module/package.scala
@@ -0,0 +1,38 @@
package pureconfig.module

import scala.language.higherKinds
import scala.reflect.ClassTag

import _root_.cats.data.{ NonEmptyList, NonEmptyVector }
import com.typesafe.config.ConfigValue
import pureconfig.{ ConfigReader, ConfigWriter }
import pureconfig.ConfigReader.{ fromFunction => fromFunctionReader }
import pureconfig.ConfigWriter.{ fromFunction => fromFunctionWriter }
import pureconfig.error.{ ConfigReaderFailures, ConfigValueLocation }
import pureconfig.module.Cats.EmptyTraversableFound

/**
* [[ConfigReader]] and [[ConfigWriter]] instances for cats data structures.
*/
package object cats {

private[pureconfig] def fromNonEmpty[F[_], G[_], T](fromFT: F[T] => Option[G[T]])(configValue: ConfigValue)(implicit ct: ClassTag[F[T]], fReader: ConfigReader[F[T]]): Either[ConfigReaderFailures, G[T]] =
fReader.from(configValue).right.flatMap { ft =>
fromFT(ft) match {
case None => Left(ConfigReaderFailures(EmptyTraversableFound(ct.toString, ConfigValueLocation(configValue), None)))
case Some(nonEmpty) => Right(nonEmpty)
}
}

implicit def nonEmptyListReader[T](implicit listReader: ConfigReader[List[T]]): ConfigReader[NonEmptyList[T]] =
fromFunctionReader(fromNonEmpty[List, NonEmptyList, T](NonEmptyList.fromList))

implicit def nonEmptyListWriter[T](implicit listWriter: ConfigWriter[List[T]]): ConfigWriter[NonEmptyList[T]] =
fromFunctionWriter(nel => listWriter.to(nel.toList))

implicit def nonEmptyVectorReader[T](implicit vectorReader: ConfigReader[Vector[T]]): ConfigReader[NonEmptyVector[T]] =
fromFunctionReader(fromNonEmpty[Vector, NonEmptyVector, T](NonEmptyVector.fromVector))

implicit def nonEmptyVectorWriter[T](implicit vectorWriter: ConfigWriter[Vector[T]]): ConfigWriter[NonEmptyVector[T]] =
fromFunctionWriter(nonEmptyVector => vectorWriter.to(nonEmptyVector.toVector))
}
41 changes: 41 additions & 0 deletions modules/cats/src/main/tut/README.md
@@ -0,0 +1,41 @@
# Cats module for PureConfig

Adds support for selected [cats](http://typelevel.org/cats/) classes to PureConfig.

## Add pureconfig-cats to your project

In addition to [core pureconfig](https://github.com/pureconfig/pureconfig), you'll need:

```scala
libraryDependencies += "com.github.pureconfig" %% "pureconfig-cats" % "0.7.1"
```

## Example

To load a `NonEmptyList[Int]` into a configuration, we need a class to hold our configuration:

```tut:silent
import cats.data.{NonEmptyList, NonEmptyVector}
import com.typesafe.config.ConfigFactory.parseString
import pureconfig._
import pureconfig.module.cats._
case class MyConfig(numbers: NonEmptyList[Int])
```

We can read a `MyConfig` like:
```tut:book
val conf = parseString("""{ numbers: [1,2,3] }""")
loadConfig[MyConfig](conf)
```

You can also load `NonEmptyVector`. First, define a case class for the config:

```tut:silent
case class MyVecConfig(numbers: NonEmptyVector[Int])
```

then load the config:
```tut:book
loadConfig[MyVecConfig](conf)
```

0 comments on commit 8bd0103

Please sign in to comment.