coulomb-pureconfig

The coulomb-pureconfig package defines pureconfig ConfigReader and ConfigWriter implicit context rules for Quantity, RuntimeQuantity, and RuntimeUnit objects.

At this time pureconfig does not cross-compile to ScalaJS or ScalaNative, and so coulomb-pureconfig also only builds for JVM. This issue is tracked by pureconfig at #1307.

Quick Start

Before you begin, it is recommended to first familiarize yourself with the coulomb introduction and coulomb-core.

packages

// coulomb pureconfig integrations
libraryDependencies += "com.manyangled" %% "coulomb-pureconfig" % "0.8.0"

// dependencies
libraryDependencies += "com.manyangled" %% "coulomb-core" % "0.8.0"
libraryDependencies += "com.manyangled" %% "coulomb-runtime" % "0.8.0"
libraryDependencies += "com.manyangled" %% "coulomb-parser" % "0.8.0"

// coulomb predefined units
libraryDependencies += "com.manyangled" %% "coulomb-units" % "0.8.0"

import

The following example imports basic coulomb and coulomb-pureconfig definitions

// fundamental coulomb types and methods
import coulomb.*
import coulomb.syntax.*

// algebraic definitions
import algebra.instances.all.given
import coulomb.ops.algebra.all.given

// unit and value type policies for operations
import coulomb.policy.standard.given
import scala.language.implicitConversions

// unit definitions
import coulomb.units.si.prefixes.{*, given}
import coulomb.units.info.{*, given}
import coulomb.units.time.{*, given}

// pureconfig defs
import _root_.pureconfig.{*, given}

// import basic coulomb-pureconfig defs
import coulomb.pureconfig.*

examples

Define a pureconfig runtime to enable io of coulomb objects. You can list either package and object names, or type definitions. When you provide a package or object name, as in the example below, any unit type definitions inside that object will be included in the runtime.

// define a pureconfig runtime with SI and SI prefix unit definitions
given given_pureconfig: PureconfigRuntime = PureconfigRuntime.of[
    "coulomb.units.si.prefixes" *:
    "coulomb.units.info" *:
    "coulomb.units.time" *:
    EmptyTuple
]

For our example, we define a simple configuration class, using values with units.

case class Config(
    duration: Quantity[Double, Second],
    storage: Quantity[Double, Giga * Byte],
    bandwidth: Quantity[Float, (Mega * Bit) / Second]
)

We will also define a ConfigReader for our config class, because pureconfig in scala 3 does not currently support automatic derivation of ConfigReader for case classes.

// defined with 'using' context so that this function defers
// resolution and can operate with multiple pureconfig io policies
given given_ConfigLoader(using
    ConfigReader[Quantity[Double, Second]],
    ConfigReader[Quantity[Double, Giga * Byte]],
    ConfigReader[Quantity[Float, (Mega * Bit) / Second]]
): ConfigReader[Config] =
    ConfigReader.forProduct3("duration", "storage", "bandwidth") {
        (d: Quantity[Double, Second],
        s: Quantity[Double, Giga * Byte],
        b: Quantity[Float, (Mega * Bit) / Second]) =>
        Config(d, s, b)
    }

In this example, we will import the DSL-based derivations for RuntimeUnit objects, and demonstrate that these rules will automatically convert compatible units, and load successfully.

// use the DSL-based io definitions for RuntimeUnit objects
import coulomb.pureconfig.policy.DSL.given

// define a configuration source
// this source uses units that are different than the Config type
// definition, but they are convertable
val source = ConfigSource.string("""
{
    duration: {value: 10, unit: minute},
    storage: {value: 100, unit: megabyte},
    bandwidth: {value: 200, unit: "gigabyte / second"}
}
""")
// source: ConfigObjectSource = pureconfig.ConfigObjectSource@6a35010e

// this load will succeed, with automatic unit conversions
val conf = source.load[Config]
// conf: Either[ConfigReaderFailures, Config] = Right(
//   value = Config(duration = 600.0, storage = 0.1, bandwidth = 1600000.0F)
// )

If a configuration value has incompatible units, the load will fail with a corresponding error.

// this config has the wrong unit for bandwidth
val bad = ConfigSource.string("""
{
    duration: {value: 10, unit: minute},
    storage: {value: 100, unit: megabyte},
    bandwidth: {value: 200, unit: "gigabyte"}
}
""")
// bad: ConfigObjectSource = pureconfig.ConfigObjectSource@3e27c5f5

// this load will fail because bandwidth units are incompatible
val fail = bad.load[Config]
// fail: Either[ConfigReaderFailures, Config] = Left(
//   value = ConfigReaderFailures(
//     head = ConvertFailure(
//       reason = CannotConvert(
//         value = "RuntimeQuantity(200.0,Giga*Byte)",
//         toType = "Quantity",
//         because = "non-convertible units: Giga*Byte, (Mega*Bit)/Second"
//       ),
//       origin = Some(value = ConfigOrigin(String)),
//       path = "bandwidth"
//     ),
//     tail = ArraySeq()
//   )
// )

Integer Values

In coulomb, conversion operations on integer values are considered to be truncating. They may lose precision due to integer truncation. Truncating conversions are generally explicit only, because this loss of precision is numerically unsafe.

In pureconfig I/O, however, there is no way to explicitly invoke a truncating conversion. To mitigate this difficulty, the coulomb-pureconfig integrations will load without error if the conversion factor is exactly 1.

The safest way to ensure unit conversions will always succeed is to use fractional value types such as Float or Double. If desired, coulomb-spire provides integrations for fractional value types of higher precision.

// source for a quantity value
val qsrc = ConfigSource.string("""
{
    value: 3
    unit: megabyte
}
""")
// qsrc: ConfigObjectSource = pureconfig.ConfigObjectSource@4177d6ce

// loading integer value types will succeed when type matches the config
qsrc.load[Quantity[Int, Mega * Byte]]
// res0: Either[ConfigReaderFailures, Quantity[Int, *[Mega, Byte]]] = Right(
//   value = 3
// )

// it will also succeed whenever the conversion coefficient is exactly 1.
qsrc.load[Quantity[Long, Byte * Mega]]
// res1: Either[ConfigReaderFailures, Quantity[Long, *[Byte, Mega]]] = Right(
//   value = 3L
// )

// if the conversion is not exactly 1, load will fail
qsrc.load[Quantity[Int, Kilo * Byte]]
// res2: Either[ConfigReaderFailures, Quantity[Int, *[Kilo, Byte]]] = Left(
//   value = ConfigReaderFailures(
//     head = ConvertFailure(
//       reason = CannotConvert(
//         value = "RuntimeQuantity(3,Mega*Byte)",
//         toType = "Quantity",
//         because = "no safe conversion from Mega*Byte to Kilo*Byte"
//       ),
//       origin = Some(value = ConfigOrigin(String)),
//       path = ""
//     ),
//     tail = ArraySeq()
//   )
// )

IO Policies

The coulomb-pureconfig integrations currently support two options for I/O "policies" which differ primarily in how one represents unit information. In the quick-start example above, the DSL-based policy was demonstrated.

The second option is a JSON-based unit representation. Here, the units are defined using a JSON structured unit expression. This representation is more verbose, but it is more amenable to explicitly structured expressions.

// use the JSON-based io definitions for RuntimeUnit objects
import coulomb.pureconfig.policy.JSON.given

// this configuration source represents units in structured JSON
val source = ConfigSource.string("""
{
    duration: {value: 10, unit: minute},
    storage: {value: 100, unit: {lhs: mega, op: "*", rhs: byte}},
    bandwidth: {value: 200, unit: {lhs: {lhs: giga, op: "*", rhs: byte}, op: "/", rhs: second}}
}
""")
// source: ConfigObjectSource = pureconfig.ConfigObjectSource@3766a3d1

// this load will succeed, with automatic unit conversions
val conf = source.load[Config]
// conf: Either[ConfigReaderFailures, Config] = Right(
//   value = Config(duration = 600.0, storage = 0.1, bandwidth = 1600000.0F)
// )