From f7178442143142558f57267c6df18079f6166436 Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Tue, 30 Jun 2026 23:30:00 +0200 Subject: [PATCH 01/11] chore: start new development version --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index f09c72c..12f6f25 100644 --- a/build.sbt +++ b/build.sbt @@ -190,7 +190,7 @@ lazy val compileSettings = Def.settings( Test / console / scalacOptions := (Compile / console / scalacOptions).value, ) -lazy val versionNumber = "0.5.2" +lazy val versionNumber = "0.6.0-SNAPSHOT" lazy val metadataSettings = Def.settings( name := projectName, From 71a82d320c87497245ea80b843900ae4095e1aa1 Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Wed, 1 Jul 2026 01:41:58 +0200 Subject: [PATCH 02/11] chore: update plugins to latest versions, and CI configs accordingly --- .github/workflows/ci.yml | 18 +++++++++--------- .github/workflows/clean.yml | 3 +++ project/plugins.sbt | 6 +++--- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72b510f..b235bfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,13 +31,13 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout current branch (full) - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Java (temurin@11) if: matrix.java == 'temurin@11' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 11 @@ -45,7 +45,7 @@ jobs: - name: Setup Java (temurin@17) if: matrix.java == 'temurin@17' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 @@ -53,7 +53,7 @@ jobs: - name: Setup Java (temurin@19) if: matrix.java == 'temurin@19' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 19 @@ -61,7 +61,7 @@ jobs: - name: Setup Java (temurin@21) if: matrix.java == 'temurin@21' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 21 @@ -75,14 +75,14 @@ jobs: - name: Check formatting with scalafmt if: runner.os == 'ubuntu-latest' && runner.java-version == '11' - run: sbt '++ ${{ matrix.scala }}' scalafmtCheckAll + run: sbt '++ ${{ matrix.scala }}; scalafmtCheckAll' - name: Lint with scalafix if: runner.os == 'ubuntu-latest' && runner.java-version == '11' - run: sbt '++ ${{ matrix.scala }}' 'scalafixAll --check' + run: sbt '++ ${{ matrix.scala }}; scalafixAll --check' - name: Check that workflows are up to date - run: sbt '++ ${{ matrix.scala }}' githubWorkflowCheck + run: sbt '++ ${{ matrix.scala }}; githubWorkflowCheck' - name: Build project - run: sbt '++ ${{ matrix.scala }}' test + run: sbt '++ ${{ matrix.scala }}; test' diff --git a/.github/workflows/clean.yml b/.github/workflows/clean.yml index bfc865d..4bb28c8 100644 --- a/.github/workflows/clean.yml +++ b/.github/workflows/clean.yml @@ -9,6 +9,9 @@ name: Clean on: push +permissions: + actions: write + jobs: delete-artifacts: name: Delete Artifacts diff --git a/project/plugins.sbt b/project/plugins.sbt index 5b4a982..42cdb8a 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,5 @@ addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1") -addSbtPlugin("com.github.sbt" % "sbt-github-actions" % "0.25.0") -addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") +addSbtPlugin("com.github.sbt" % "sbt-github-actions" % "0.31.0") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.6.1") addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.13.1") -addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.2") \ No newline at end of file +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.7") \ No newline at end of file From 13b9eabe619961ee8597f70e23528d90d749c256 Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Tue, 30 Jun 2026 23:30:57 +0200 Subject: [PATCH 03/11] feat: add .unsafe(Length) to the API of EuclideanDistance --- CHANGELOG.md | 9 +++++++ build.sbt | 3 ++- .../geometry/src/main/scala/Distance.scala | 6 +++++ modules/geometry/src/main/scala/package.scala | 2 +- .../src/test/scala/TestDistance.scala | 26 +++++++++++++++++-- .../test/scala/TestDistanceJsonCodec.scala | 2 +- 6 files changed, 43 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5036dcf..6bfd17f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added +* `EuclideanDistance.unsafe` method, wrapping a `squants.space.Length` value as a `EuclideanDistance`, +performing the necessary validation as nonnegative and erroring otherwise. + +### Changed +* Changed the error message for refinement of `squants.space.Length` as a `Distance` value from "Allegedly nonnegative length must actually be nonnegative." to "Allegedly nonnegative length is actually negative." + ## [v0.5.2] - 2026-03-18 ### Fixed diff --git a/build.sbt b/build.sbt index 12f6f25..5287430 100644 --- a/build.sbt +++ b/build.sbt @@ -182,7 +182,8 @@ lazy val compileSettings = Def.settings( "-rewrite", // for scalafix RemoveUnused: https://scalacenter.github.io/scalafix/docs/rules/RemoveUnused.html "-Wunused:all", - // Warnings about an unused symbol are expected in some source files testing whether code compiles or not. + /* Warnings about an unused symbol are expected in some source files testing whether code compiles or not. */ + "-Wconf:msg=unused import&src=./modules/geometry/src/test/scala/TestDistance.scala:silent", "-Wconf:msg=unused import&src=./modules/imaging/src/test/scala/TestImagingInstances.scala:silent", "-Wconf:msg=unused import&src=./modules/testing/src/test/scala/TestInstanceAvailability.scala:silent", "-Werror", diff --git a/modules/geometry/src/main/scala/Distance.scala b/modules/geometry/src/main/scala/Distance.scala index adbd2a3..a1a5ec4 100644 --- a/modules/geometry/src/main/scala/Distance.scala +++ b/modules/geometry/src/main/scala/Distance.scala @@ -6,6 +6,8 @@ import cats.* import cats.data.Validated import cats.syntax.all.* +import squants.space.Length + import at.ac.oeaw.imba.gerlich.gerlib.numeric.* /** Piecewise / by-component distance, as absolute differences @@ -91,6 +93,10 @@ object EuclideanDistance: import Distance.given_Order_Distance Order.by(_.get) // use the Double backing the squants.space.Length. + /** Try to read the given text (e.g., a configuration value) as a distance in Euclidean space. */ def parse: String => Either[String, EuclideanDistance] = s => Distance.parse(s).map(EuclideanDistance.apply) + + /** Try to interpret the given length value as a distance in Euclidean space. */ + def unsafe: Length => EuclideanDistance = Distance.applyUnsafe `andThen` EuclideanDistance.apply end EuclideanDistance diff --git a/modules/geometry/src/main/scala/package.scala b/modules/geometry/src/main/scala/package.scala index 59b832b..7c7ed76 100644 --- a/modules/geometry/src/main/scala/package.scala +++ b/modules/geometry/src/main/scala/package.scala @@ -38,7 +38,7 @@ package object geometry: given RuntimeConstraint[Length, Not[Negative]] = new RuntimeConstraint( _.value >= 0, - "Allegedly nonnegative length must actually be nonnegative." + "Allegedly nonnegative length is actually negative." ) /** Leave this alias transparent, since we just want the typelevel 'check' that the length is diff --git a/modules/geometry/src/test/scala/TestDistance.scala b/modules/geometry/src/test/scala/TestDistance.scala index 9b75014..cf6aaa3 100644 --- a/modules/geometry/src/test/scala/TestDistance.scala +++ b/modules/geometry/src/test/scala/TestDistance.scala @@ -1,12 +1,16 @@ package at.ac.oeaw.imba.gerlich.gerlib.geometry -import squants.space.* - import org.scalacheck.{Arbitrary, Gen} import org.scalacheck.Arbitrary.arbitrary import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks + +import io.github.iltotore.iron.{:|, autoRefine} +import io.github.iltotore.iron.constraint.any.Not +import io.github.iltotore.iron.constraint.numeric.Negative +import squants.space.* + import at.ac.oeaw.imba.gerlich.gerlib.refinement.IllegalRefinement /** Tests for the refinement of a [[squants.space.Length]] value as a distance */ @@ -57,4 +61,22 @@ class TestDistance extends AnyFunSuite, should.Matchers, ScalaCheckPropertyCheck l < d shouldBe l < dAsL } + test("EuclideanDistance.unsafe requires a length, not just int or nonnegative int."): + assertTypeError("EuclideanDistance.unsafe(2)") // plain Int prohibited + assertCompiles("2: Int :| Not[Negative]") // building nonnegative int works + assertTypeError("EuclideanDistance.unsafe(2: Int :| Not[Negative])") // nonnegative Int prohibited + assertCompiles("Nanometers(2)") // building Length works + assertCompiles("EuclideanDistance.unsafe(Nanometers(2))") // Length argument works + + test("EuclideanDistance.unsafe is correct for a nonnegative length."): + forAll (Gen.choose(0, Int.MaxValue).map(Nanometers.apply)) { l => + EuclideanDistance.unsafe(l).get shouldEqual Distance.applyUnsafe(l) + } + + test("EuclideanDistance.unsafe fails properly for a negative length."): + forAll (Gen.choose(Int.MinValue, -1).map(Nanometers.apply)) { l => + val obsErr = intercept[IllegalArgumentException]{ EuclideanDistance.unsafe(l) } + obsErr.getMessage shouldEqual "Allegedly nonnegative length is actually negative." + } + end TestDistance diff --git a/modules/json/src/test/scala/TestDistanceJsonCodec.scala b/modules/json/src/test/scala/TestDistanceJsonCodec.scala index dd33239..c775bf1 100644 --- a/modules/json/src/test/scala/TestDistanceJsonCodec.scala +++ b/modules/json/src/test/scala/TestDistanceJsonCodec.scala @@ -27,7 +27,7 @@ class TestDistanceJsonCodec extends AnyFunSuite, should.Matchers, ScalaCheckProp import geometry.given // for the ReadWriter instance val err1 = QuantityParseException("Unable to parse Length", "70") - val msg2Suffix = "Allegedly nonnegative length must actually be nonnegative." + val msg2Suffix = "Allegedly nonnegative length is actually negative." forAll( Table( From 38cfc6fc88f2c5fe57ace4e1ad2801df4dd04e62 Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Thu, 2 Jul 2026 02:17:23 +0200 Subject: [PATCH 04/11] feat: add configuration module, for scopt and pureconfig with Pixels3D and PixelDefinition --- CHANGELOG.md | 1 + build.sbt | 11 +- .../PureConfigImagingInstances.scala | 23 +++ .../PureConfigSquantsInstances.scala | 23 +++ .../src/main/scala/instances/package.scala | 10 ++ .../src/main/scala/package.scala | 4 + .../TestPixelDefinitionConfiguration.scala | 131 ++++++++++++++++++ .../src/test/scala/TestPixelDefinition.scala | 58 ++++++++ project/Dependencies.scala | 10 +- 9 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala create mode 100644 modules/configuration/src/main/scala/instances/PureConfigSquantsInstances.scala create mode 100644 modules/configuration/src/main/scala/instances/package.scala create mode 100644 modules/configuration/src/main/scala/package.scala create mode 100644 modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala create mode 100644 modules/imaging/src/test/scala/TestPixelDefinition.scala diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfd17f..c2bc11e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * `EuclideanDistance.unsafe` method, wrapping a `squants.space.Length` value as a `EuclideanDistance`, performing the necessary validation as nonnegative and erroring otherwise. +* `configuration` module, with support for parsing definition of pixel lengths for `scopt` or `pureconfig`. ### Changed * Changed the error message for refinement of `squants.space.Length` as a `Distance` value from "Allegedly nonnegative length must actually be nonnegative." to "Allegedly nonnegative length is actually negative." diff --git a/build.sbt b/build.sbt index 5287430..bbff376 100644 --- a/build.sbt +++ b/build.sbt @@ -52,7 +52,7 @@ ThisBuild / testOptions += Tests.Argument("-oF") // full stack traces lazy val root = project .in(file(".")) - .aggregate(cell, geometry, graph, imaging, io, json, numeric, pan, refinement, roi, testing, zarr) + .aggregate(cell, configuration, geometry, graph, imaging, io, json, numeric, pan, refinement, roi, testing, zarr) .enablePlugins(BuildInfoPlugin) .settings(commonSettings) .settings(noPublishSettings) @@ -65,6 +65,15 @@ lazy val root = project lazy val cell = defineModule("cell")(project) .dependsOn(numeric) +lazy val configuration = defineModule("configuration")(project) + .dependsOn(imaging) + .settings( + libraryDependencies ++= Seq( + pureconfigCore, + pureconfigGeneric, + ) + ) + lazy val geometry = defineModule("geometry")(project) .dependsOn(numeric, refinement) .settings( diff --git a/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala b/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala new file mode 100644 index 0000000..3559854 --- /dev/null +++ b/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala @@ -0,0 +1,23 @@ +package at.ac.oeaw.imba.gerlich.gerlib.configuration +package instances + +import cats.syntax.all.* +import pureconfig.ConfigReader +import pureconfig.error.CannotConvert +import pureconfig.generic.semiauto.deriveReader +import squants.space.Length + +import at.ac.oeaw.imba.gerlich.gerlib.imaging.{Pixels3D, PixelDefinition} + +/** PureConfig typeclasses instances for imaging-related domain-specific types */ +trait PureConfigImagingInstances: + given (ConfigReader[PixelDefinition]) => ConfigReader[Pixels3D] = + deriveReader[Pixels3D] + + given (readLength: ConfigReader[Length]) => ConfigReader[PixelDefinition] = + readLength.emap(l => + PixelDefinition.tryToDefine(l).leftMap(msg => + CannotConvert(value = l.toString, toType = "PixelDefinition", because = msg) + ) + ) +end PureConfigImagingInstances diff --git a/modules/configuration/src/main/scala/instances/PureConfigSquantsInstances.scala b/modules/configuration/src/main/scala/instances/PureConfigSquantsInstances.scala new file mode 100644 index 0000000..5d1caa5 --- /dev/null +++ b/modules/configuration/src/main/scala/instances/PureConfigSquantsInstances.scala @@ -0,0 +1,23 @@ +package at.ac.oeaw.imba.gerlich.gerlib.configuration +package instances + +import cats.syntax.all.* +import pureconfig.ConfigReader +import pureconfig.error.CannotConvert +import squants.space.Length + +/** PureConfig typeclass instances for squants */ +trait PureConfigSquantsInstances: + given (readString: ConfigReader[String]) => ConfigReader[Length] = + readString.emap: s => + Length + .parseString(s) + .toEither + .leftMap { e => + CannotConvert( + value = s, + toType = "squants.space.Length", + because = e.getMessage + ) + } +end PureConfigSquantsInstances diff --git a/modules/configuration/src/main/scala/instances/package.scala b/modules/configuration/src/main/scala/instances/package.scala new file mode 100644 index 0000000..b7ee344 --- /dev/null +++ b/modules/configuration/src/main/scala/instances/package.scala @@ -0,0 +1,10 @@ +package at.ac.oeaw.imba.gerlich.gerlib.configuration + +/** Configuration-related typeclass instances */ +package object instances: + object all extends AllConfigurationInstances + + trait AllConfigurationInstances + extends PureConfigImagingInstances, + PureConfigSquantsInstances +end instances diff --git a/modules/configuration/src/main/scala/package.scala b/modules/configuration/src/main/scala/package.scala new file mode 100644 index 0000000..880cc59 --- /dev/null +++ b/modules/configuration/src/main/scala/package.scala @@ -0,0 +1,4 @@ +package at.ac.oeaw.imba.gerlich.gerlib + +/** Functionality related to configuration (e.g., from file or CLI) */ +package object configuration diff --git a/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala b/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala new file mode 100644 index 0000000..ad6fc1e --- /dev/null +++ b/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala @@ -0,0 +1,131 @@ +package at.ac.oeaw.imba.gerlich.gerlib.configuration + +import cats.* +import cats.syntax.all.* +import pureconfig.* +import squants.space.{LengthUnit, Microns, Nanometers} +import org.scalacheck.* +import org.scalactic.{Equality, TolerantNumerics} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should +import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks + +import at.ac.oeaw.imba.gerlich.gerlib.configuration.instances.all.given // Bring in the pureconfig.ConfigReader instances. +import at.ac.oeaw.imba.gerlich.gerlib.imaging.Pixels3D + +/** Tests for the parsing of pixel definitions */ +class TestPixelDefinitionConfiguration + extends AnyFunSuite, + ScalaCheckPropertyChecks, + should.Matchers: + + private type MicroOrNano = Microns.type | Nanometers.type + + private def maxSize: Double = 1e6 + + private def genMicroOrNano: Gen[MicroOrNano] = Gen.oneOf(Microns, Nanometers) + + private def genPlusMinusMillion: Gen[Double] = Gen.choose(-maxSize, maxSize) + + private type LengthParseInputs[A] = (A, LengthUnit) + + private def buildRawConfig[A: Show]( + x: LengthParseInputs[A], + y: LengthParseInputs[A], + z: LengthParseInputs[A] + ): String = + given Show[LengthParseInputs[A]] = Show.show { case (num, unit) => + s"${num.show} ${unit.symbol}" + } + s"{ x: ${x.show}, y: ${y.show}, z: ${z.show} }" + + test("Basic examples parse as expected."): + given Arbitrary[Double] = Arbitrary(genPlusMinusMillion) + given Shrink[Double] = Shrink.shrinkAny + given Equality[Double] = + TolerantNumerics.tolerantDoubleEquality(1 / maxSize) + + given Arbitrary[LengthUnit] = Arbitrary { genMicroOrNano } + + forAll(minSuccessful(10000)) { + (x: Double, y: Double, z: Double, unit: LengthUnit) => + val perX = 100 + val perY = 200 + val perZ = 400 + val rawConfigData = + buildRawConfig(perX -> unit, perY -> unit, perZ -> unit) + ConfigSource.string(rawConfigData).load[Pixels3D] match { + case Left(errors) => fail(s"Errors: $errors") + case Right(scaling) => + (scaling.liftX(x) in unit).value shouldEqual x * perX + (scaling.liftY(y) in unit).value shouldEqual y * perY + (scaling.liftZ(z) in unit).value shouldEqual z * perZ + } + } + + test("Pixels3D parse requires the correct combination (x, y, z) of keys."): + val legitKeys = Set("x", "y", "z") + + def genSubstitutions: Gen[List[(String, String)]] = + Gen + .containerOf[Set, String](Gen.oneOf(legitKeys)) + .map(_.toList) + .flatMap { toSwapOut => + Gen.listOfN(toSwapOut.length, Gen.alphaNumStr).map(toSwapOut.zip) + } + + val MyUnit = Nanometers + def genInputAndExpectation: Gen[(String, Boolean)] = + val base = buildRawConfig(100 -> MyUnit, 200 -> MyUnit, 300 -> MyUnit) + genSubstitutions.map { subs => + val updated = subs.foldLeft(base) { case (acc, (oldKey, newKey)) => + acc.replace(oldKey ++ ":", newKey ++ ":") + } + val expSuccess = + val newKeys = + legitKeys -- subs.map(_._1).toSet ++ subs.map(_._2).toSet + subs.isEmpty || legitKeys === newKeys + updated -> expSuccess + } + + given [A] => Shrink[A] = Shrink.shrinkAny // no shrinking whatsoever + + forAll(genInputAndExpectation, minSuccessful(1000)) { + (rawConfigData, shouldSucceed) => + ConfigSource.string(rawConfigData).load[Pixels3D] match { + case Left(errors) => + if !shouldSucceed then succeed + else + fail( + s"Expected succeess with $rawConfigData but failed: ${errors.prettyPrint}" + ) + case Right(_) => + if shouldSucceed then succeed + else fail(s"Expected failure with $rawConfigData but succeeded") + } + } + + test( + "Pixels3D parse requires proper length units with strictly positive values." + ): + val inputsAndExpectations = Table( + ("rawConfigData", "shouldSucceed"), + ("{ x: 100 nm, y: 200 nm, z: 300 nm }", true), // good example + ("{ x: 100 nm, y: 200, z: 300 nm }", false), // missing a units on y + ("{ x: 100 nm, y: 200 nm, z: -300 nm }", false) // negative z + ) + forAll(inputsAndExpectations) { (rawConfigData, shouldSucceed) => + ConfigSource.string(rawConfigData).load[Pixels3D] match { + case Left(errors) => + if !shouldSucceed then succeed + else + fail( + s"Expected succeess with $rawConfigData but failed: ${errors.prettyPrint}" + ) + case Right(_) => + if shouldSucceed then succeed + else fail(s"Expected failure with $rawConfigData but succeeded") + } + + } +end TestPixelDefinitionConfiguration diff --git a/modules/imaging/src/test/scala/TestPixelDefinition.scala b/modules/imaging/src/test/scala/TestPixelDefinition.scala new file mode 100644 index 0000000..9a88bf4 --- /dev/null +++ b/modules/imaging/src/test/scala/TestPixelDefinition.scala @@ -0,0 +1,58 @@ +package at.ac.oeaw.imba.gerlich.gerlib.imaging + +import cats.* +import cats.syntax.all.* +import squants.space.{Length, LengthUnit, Microns, Nanometers} +import org.scalacheck.* +import org.scalactic.{Equality, TolerantNumerics} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should +import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks + +/** Tests for the parsing of pixel definitions */ +class TestPixelDefinition + extends AnyFunSuite, + ScalaCheckPropertyChecks, + should.Matchers: + + private type MicroOrNano = Microns.type | Nanometers.type + + private def maxSize: Double = 1e6 + + private def genMicroOrNano: Gen[MicroOrNano] = Gen.oneOf(Microns, Nanometers) + + test( + "With squants input, PixelDefinition.tryToDefine works exactly when the value is (strictly) positive." + ): + def genLength = genMicroOrNano.flatMap { buildLength => + Gen.choose(-1e3, 1e3).map(buildLength.apply) + } + def genPixelCount: Gen[Int] = Gen.choose(0, 1e6.toInt) + + forAll(genLength, genPixelCount, minSuccessful(10000)) { + (length, numPixels) => + PixelDefinition.tryToDefine(length) match { + case Left(_) if length.value <= 0 => succeed + case Left(msg) => + fail(s"Pixel definition with length ${length} failed: $msg") + case Right(_) if length.value <= 0 => + fail(s"Pixel definition with length ${length} succeded") + case Right(pxDef) => + import PixelDefinition.syntax.lift + given Equality[Double] = + TolerantNumerics.tolerantDoubleEquality(1 / maxSize) + + val obs: Length = (pxDef.lift(numPixels) in length.unit) + val exp: Length = + val x = numPixels * length.value + val u = length.unit.symbol + Length(x, u).toEither + .leftMap { e => + s"Error bulding length from ($x, $u: ${e.getMessage})" + } + .fold(msg => throw new Exception(msg), identity) + obs.value shouldEqual exp.value + obs.unit shouldEqual exp.unit + } + } +end TestPixelDefinition diff --git a/project/Dependencies.scala b/project/Dependencies.scala index ed12aa0..4b29fb5 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -21,10 +21,18 @@ object Dependencies { "org.typelevel" %% s"cats-$name" % "2.13.0" } + object PureConfig { + def getModuleId(name: String): ModuleID = "com.github.pureconfig" %% s"pureconfig-$name" % "0.17.10" + } + /* Versions */ lazy val scalatestVersion = "3.2.19" - /* Core dependencies */ + /* config dependencies */ + lazy val pureconfigCore = PureConfig.getModuleId("core") + lazy val pureconfigGeneric = PureConfig.getModuleId("generic-scala3") + + /* core dependencies */ lazy val catsCore = Cats.getModuleId("core") lazy val kittens = "org.typelevel" %% "kittens" % "3.5.0" lazy val mouse = "org.typelevel" %% "mouse" % "1.3.2" From 557162e133128f36232c817b3e752a653c2b94db Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Sun, 5 Jul 2026 21:29:44 +0200 Subject: [PATCH 05/11] feat: add Show and SimpleShow instances for Distance --- CHANGELOG.md | 2 ++ .../scala/instances/DistanceInstances.scala | 16 +++++++++ .../src/main/scala/instances/package.scala | 2 +- .../src/test/scala/TestDistance.scala | 35 +++++++++++++++---- 4 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 modules/geometry/src/main/scala/instances/DistanceInstances.scala diff --git a/CHANGELOG.md b/CHANGELOG.md index c2bc11e..80504e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `EuclideanDistance.unsafe` method, wrapping a `squants.space.Length` value as a `EuclideanDistance`, performing the necessary validation as nonnegative and erroring otherwise. * `configuration` module, with support for parsing definition of pixel lengths for `scopt` or `pureconfig`. +* Instances of `Show` and `SimpleShow` for `geometry.Distance`, with an implementation which can be guaranteed to roundtrip through `Distance.parse` ### Changed * Changed the error message for refinement of `squants.space.Length` as a `Distance` value from "Allegedly nonnegative length must actually be nonnegative." to "Allegedly nonnegative length is actually negative." + ## [v0.5.2] - 2026-03-18 ### Fixed diff --git a/modules/geometry/src/main/scala/instances/DistanceInstances.scala b/modules/geometry/src/main/scala/instances/DistanceInstances.scala new file mode 100644 index 0000000..8467bb1 --- /dev/null +++ b/modules/geometry/src/main/scala/instances/DistanceInstances.scala @@ -0,0 +1,16 @@ +package at.ac.oeaw.imba.gerlich.gerlib.geometry +package instances + +import cats.Show +import at.ac.oeaw.imba.gerlich.gerlib.SimpleShow + +/** Typeclass instances related to distance values */ +trait DistanceInstances: + /** Show the quantity and units with a space in between */ + given Show[Distance] = Show.show { d => + s"${d.value} ${d.unit.symbol}" + } + + /** Use .show to derive .show_ */ + given Show[Distance] => SimpleShow[Distance] = SimpleShow.fromShow +end DistanceInstances diff --git a/modules/geometry/src/main/scala/instances/package.scala b/modules/geometry/src/main/scala/instances/package.scala index 0871549..865fa1b 100644 --- a/modules/geometry/src/main/scala/instances/package.scala +++ b/modules/geometry/src/main/scala/instances/package.scala @@ -8,5 +8,5 @@ package object instances: object all extends AllGeometryInstances /** Aggregation of all the geometry-related data types' typeclass instances */ - trait AllGeometryInstances extends CoordinateInstances, PointInstances + trait AllGeometryInstances extends CoordinateInstances, DistanceInstances, PointInstances end instances diff --git a/modules/geometry/src/test/scala/TestDistance.scala b/modules/geometry/src/test/scala/TestDistance.scala index cf6aaa3..f3c931c 100644 --- a/modules/geometry/src/test/scala/TestDistance.scala +++ b/modules/geometry/src/test/scala/TestDistance.scala @@ -1,16 +1,19 @@ package at.ac.oeaw.imba.gerlich.gerlib.geometry +import cats.syntax.all.* + import org.scalacheck.{Arbitrary, Gen} import org.scalacheck.Arbitrary.arbitrary import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks -import io.github.iltotore.iron.{:|, autoRefine} +import io.github.iltotore.iron.{:|, autoRefine, refineUnsafe} import io.github.iltotore.iron.constraint.any.Not import io.github.iltotore.iron.constraint.numeric.Negative import squants.space.* +import at.ac.oeaw.imba.gerlich.gerlib.geometry.instances.all.given import at.ac.oeaw.imba.gerlich.gerlib.refinement.IllegalRefinement /** Tests for the refinement of a [[squants.space.Length]] value as a distance */ @@ -64,19 +67,39 @@ class TestDistance extends AnyFunSuite, should.Matchers, ScalaCheckPropertyCheck test("EuclideanDistance.unsafe requires a length, not just int or nonnegative int."): assertTypeError("EuclideanDistance.unsafe(2)") // plain Int prohibited assertCompiles("2: Int :| Not[Negative]") // building nonnegative int works - assertTypeError("EuclideanDistance.unsafe(2: Int :| Not[Negative])") // nonnegative Int prohibited + assertTypeError( + "EuclideanDistance.unsafe(2: Int :| Not[Negative])" + ) // nonnegative Int prohibited assertCompiles("Nanometers(2)") // building Length works assertCompiles("EuclideanDistance.unsafe(Nanometers(2))") // Length argument works test("EuclideanDistance.unsafe is correct for a nonnegative length."): - forAll (Gen.choose(0, Int.MaxValue).map(Nanometers.apply)) { l => - EuclideanDistance.unsafe(l).get shouldEqual Distance.applyUnsafe(l) + forAll(Gen.choose(0, Int.MaxValue).map(Nanometers.apply)) { l => + EuclideanDistance.unsafe(l).get shouldEqual Distance.applyUnsafe(l) } test("EuclideanDistance.unsafe fails properly for a negative length."): - forAll (Gen.choose(Int.MinValue, -1).map(Nanometers.apply)) { l => - val obsErr = intercept[IllegalArgumentException]{ EuclideanDistance.unsafe(l) } + forAll(Gen.choose(Int.MinValue, -1).map(Nanometers.apply)) { l => + val obsErr = intercept[IllegalArgumentException] { EuclideanDistance.unsafe(l) } obsErr.getMessage shouldEqual "Allegedly nonnegative length is actually negative." } + test("Distance roundtrips through .show"): + import ArbitraryDistance.given + given Arbitrary[Double] = Arbitrary(Gen.choose(0, Double.MaxValue)) + forAll { (d: Distance) => Distance.parse(d.show) shouldEqual d.asRight } + + test("Distance roundtrips through .simpleShow"): + import at.ac.oeaw.imba.gerlich.gerlib.syntax.all.* // for .show_ + import ArbitraryDistance.given + given Arbitrary[Double] = Arbitrary(Gen.choose(0, Double.MaxValue)) + forAll { (d: Distance) => Distance.parse(d.show_) shouldEqual d.asRight } + + object ArbitraryDistance: + given Arbitrary[Length] => Arbitrary[Distance] = + Arbitrary( + arbitrary[Length] + .map(l => Distance.either(l).fold(msg => throw IllegalRefinement(l, msg), identity)) + ) + end ArbitraryDistance end TestDistance From c0aea0dd5cbf782b977d12ca662ebd0d82934c69 Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Sun, 5 Jul 2026 21:31:03 +0200 Subject: [PATCH 06/11] bump sbt to latest 1.x release version --- CHANGELOG.md | 2 +- project/build.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80504e7..79c8103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ performing the necessary validation as nonnegative and erroring otherwise. ### Changed * Changed the error message for refinement of `squants.space.Length` as a `Distance` value from "Allegedly nonnegative length must actually be nonnegative." to "Allegedly nonnegative length is actually negative." - +* Updated `sbt` to version 1.12.13 ## [v0.5.2] - 2026-03-18 diff --git a/project/build.properties b/project/build.properties index e97b272..7c95fc1 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.10.10 +sbt.version=1.12.13 From d980ae4b4680c8e6e405a2319f3c52d2eb081d4c Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Sun, 5 Jul 2026 21:32:17 +0200 Subject: [PATCH 07/11] chore: apply formatter --- .../PureConfigImagingInstances.scala | 12 ++-- .../src/main/scala/instances/package.scala | 4 +- .../TestPixelDefinitionConfiguration.scala | 70 +++++++++---------- .../src/test/scala/TestPixelDefinition.scala | 54 +++++++------- 4 files changed, 67 insertions(+), 73 deletions(-) diff --git a/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala b/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala index 3559854..9536bcb 100644 --- a/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala +++ b/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala @@ -14,10 +14,12 @@ trait PureConfigImagingInstances: given (ConfigReader[PixelDefinition]) => ConfigReader[Pixels3D] = deriveReader[Pixels3D] - given (readLength: ConfigReader[Length]) => ConfigReader[PixelDefinition] = - readLength.emap(l => - PixelDefinition.tryToDefine(l).leftMap(msg => - CannotConvert(value = l.toString, toType = "PixelDefinition", because = msg) - ) + given (readLength: ConfigReader[Length]) => ConfigReader[PixelDefinition] = + readLength.emap(l => + PixelDefinition + .tryToDefine(l) + .leftMap(msg => + CannotConvert(value = l.toString, toType = "PixelDefinition", because = msg) + ) ) end PureConfigImagingInstances diff --git a/modules/configuration/src/main/scala/instances/package.scala b/modules/configuration/src/main/scala/instances/package.scala index b7ee344..972787e 100644 --- a/modules/configuration/src/main/scala/instances/package.scala +++ b/modules/configuration/src/main/scala/instances/package.scala @@ -4,7 +4,5 @@ package at.ac.oeaw.imba.gerlich.gerlib.configuration package object instances: object all extends AllConfigurationInstances - trait AllConfigurationInstances - extends PureConfigImagingInstances, - PureConfigSquantsInstances + trait AllConfigurationInstances extends PureConfigImagingInstances, PureConfigSquantsInstances end instances diff --git a/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala b/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala index ad6fc1e..cb2c1b6 100644 --- a/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala +++ b/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala @@ -47,20 +47,19 @@ class TestPixelDefinitionConfiguration given Arbitrary[LengthUnit] = Arbitrary { genMicroOrNano } - forAll(minSuccessful(10000)) { - (x: Double, y: Double, z: Double, unit: LengthUnit) => - val perX = 100 - val perY = 200 - val perZ = 400 - val rawConfigData = - buildRawConfig(perX -> unit, perY -> unit, perZ -> unit) - ConfigSource.string(rawConfigData).load[Pixels3D] match { - case Left(errors) => fail(s"Errors: $errors") - case Right(scaling) => - (scaling.liftX(x) in unit).value shouldEqual x * perX - (scaling.liftY(y) in unit).value shouldEqual y * perY - (scaling.liftZ(z) in unit).value shouldEqual z * perZ - } + forAll(minSuccessful(10000)) { (x: Double, y: Double, z: Double, unit: LengthUnit) => + val perX = 100 + val perY = 200 + val perZ = 400 + val rawConfigData = + buildRawConfig(perX -> unit, perY -> unit, perZ -> unit) + ConfigSource.string(rawConfigData).load[Pixels3D] match { + case Left(errors) => fail(s"Errors: $errors") + case Right(scaling) => + (scaling.liftX(x) in unit).value shouldEqual x * perX + (scaling.liftY(y) in unit).value shouldEqual y * perY + (scaling.liftZ(z) in unit).value shouldEqual z * perZ + } } test("Pixels3D parse requires the correct combination (x, y, z) of keys."): @@ -90,19 +89,18 @@ class TestPixelDefinitionConfiguration given [A] => Shrink[A] = Shrink.shrinkAny // no shrinking whatsoever - forAll(genInputAndExpectation, minSuccessful(1000)) { - (rawConfigData, shouldSucceed) => - ConfigSource.string(rawConfigData).load[Pixels3D] match { - case Left(errors) => - if !shouldSucceed then succeed - else - fail( - s"Expected succeess with $rawConfigData but failed: ${errors.prettyPrint}" - ) - case Right(_) => - if shouldSucceed then succeed - else fail(s"Expected failure with $rawConfigData but succeeded") - } + forAll(genInputAndExpectation, minSuccessful(1000)) { (rawConfigData, shouldSucceed) => + ConfigSource.string(rawConfigData).load[Pixels3D] match { + case Left(errors) => + if !shouldSucceed then succeed + else + fail( + s"Expected succeess with $rawConfigData but failed: ${errors.prettyPrint}" + ) + case Right(_) => + if shouldSucceed then succeed + else fail(s"Expected failure with $rawConfigData but succeeded") + } } test( @@ -116,15 +114,15 @@ class TestPixelDefinitionConfiguration ) forAll(inputsAndExpectations) { (rawConfigData, shouldSucceed) => ConfigSource.string(rawConfigData).load[Pixels3D] match { - case Left(errors) => - if !shouldSucceed then succeed - else - fail( - s"Expected succeess with $rawConfigData but failed: ${errors.prettyPrint}" - ) - case Right(_) => - if shouldSucceed then succeed - else fail(s"Expected failure with $rawConfigData but succeeded") + case Left(errors) => + if !shouldSucceed then succeed + else + fail( + s"Expected succeess with $rawConfigData but failed: ${errors.prettyPrint}" + ) + case Right(_) => + if shouldSucceed then succeed + else fail(s"Expected failure with $rawConfigData but succeeded") } } diff --git a/modules/imaging/src/test/scala/TestPixelDefinition.scala b/modules/imaging/src/test/scala/TestPixelDefinition.scala index 9a88bf4..79f83fd 100644 --- a/modules/imaging/src/test/scala/TestPixelDefinition.scala +++ b/modules/imaging/src/test/scala/TestPixelDefinition.scala @@ -10,10 +10,7 @@ import org.scalatest.matchers.should import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks /** Tests for the parsing of pixel definitions */ -class TestPixelDefinition - extends AnyFunSuite, - ScalaCheckPropertyChecks, - should.Matchers: +class TestPixelDefinition extends AnyFunSuite, ScalaCheckPropertyChecks, should.Matchers: private type MicroOrNano = Microns.type | Nanometers.type @@ -29,30 +26,29 @@ class TestPixelDefinition } def genPixelCount: Gen[Int] = Gen.choose(0, 1e6.toInt) - forAll(genLength, genPixelCount, minSuccessful(10000)) { - (length, numPixels) => - PixelDefinition.tryToDefine(length) match { - case Left(_) if length.value <= 0 => succeed - case Left(msg) => - fail(s"Pixel definition with length ${length} failed: $msg") - case Right(_) if length.value <= 0 => - fail(s"Pixel definition with length ${length} succeded") - case Right(pxDef) => - import PixelDefinition.syntax.lift - given Equality[Double] = - TolerantNumerics.tolerantDoubleEquality(1 / maxSize) - - val obs: Length = (pxDef.lift(numPixels) in length.unit) - val exp: Length = - val x = numPixels * length.value - val u = length.unit.symbol - Length(x, u).toEither - .leftMap { e => - s"Error bulding length from ($x, $u: ${e.getMessage})" - } - .fold(msg => throw new Exception(msg), identity) - obs.value shouldEqual exp.value - obs.unit shouldEqual exp.unit - } + forAll(genLength, genPixelCount, minSuccessful(10000)) { (length, numPixels) => + PixelDefinition.tryToDefine(length) match { + case Left(_) if length.value <= 0 => succeed + case Left(msg) => + fail(s"Pixel definition with length ${length} failed: $msg") + case Right(_) if length.value <= 0 => + fail(s"Pixel definition with length ${length} succeded") + case Right(pxDef) => + import PixelDefinition.syntax.lift + given Equality[Double] = + TolerantNumerics.tolerantDoubleEquality(1 / maxSize) + + val obs: Length = (pxDef.lift(numPixels) in length.unit) + val exp: Length = + val x = numPixels * length.value + val u = length.unit.symbol + Length(x, u).toEither + .leftMap { e => + s"Error bulding length from ($x, $u: ${e.getMessage})" + } + .fold(msg => throw new Exception(msg), identity) + obs.value shouldEqual exp.value + obs.unit shouldEqual exp.unit + } } end TestPixelDefinition From dc1597cdf59cba58775b0ca0d6768f133e19d6c4 Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Sun, 5 Jul 2026 23:22:50 +0200 Subject: [PATCH 08/11] chore: update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c8103..b6ebc4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 performing the necessary validation as nonnegative and erroring otherwise. * `configuration` module, with support for parsing definition of pixel lengths for `scopt` or `pureconfig`. * Instances of `Show` and `SimpleShow` for `geometry.Distance`, with an implementation which can be guaranteed to roundtrip through `Distance.parse` +* Tests for `PixelDefinition` and `Pixels3D` ### Changed * Changed the error message for refinement of `squants.space.Length` as a `Distance` value from "Allegedly nonnegative length must actually be nonnegative." to "Allegedly nonnegative length is actually negative." From a63a7697d63a310bbc11d364db1a26546d538f8f Mon Sep 17 00:00:00 2001 From: Christoph Langer Date: Tue, 7 Jul 2026 13:29:33 +0200 Subject: [PATCH 09/11] ci: restrict Java matrix to Temurin 17 and 21 Drop Java 11 and 19 from the CI build matrix, keeping only the LTS releases Temurin 17 and 21. Exclude the macOS + Temurin 17 combination so macOS only runs on Java 21, matching the looptrace CI setup. Java 19 (non-LTS, EOL March 2023) was the source of the SSLHandshakeException when resolving edu.ucar:cdm-core from Unidata's artifact server: its bundled cacerts predates the Sectigo Root R46 trust anchor. Java 17 and 21 already trust that root, so no truststore workaround is needed. Also bump primaryJavaVersion to 21 so the scalafmt/scalafix checks run on the new primary JVM, and add .DS_Store to .gitignore. --- .github/workflows/ci.yml | 29 ++++++----------------------- .gitignore | 2 ++ build.sbt | 7 ++++--- 3 files changed, 12 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b235bfd..9b98333 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,11 +23,10 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] scala: [3.6.4] - java: - - temurin@11 - - temurin@17 - - temurin@19 - - temurin@21 + java: [temurin@17, temurin@21] + exclude: + - os: macos-latest + java: temurin@17 runs-on: ${{ matrix.os }} steps: - name: Checkout current branch (full) @@ -35,14 +34,6 @@ jobs: with: fetch-depth: 0 - - name: Setup Java (temurin@11) - if: matrix.java == 'temurin@11' - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 11 - cache: sbt - - name: Setup Java (temurin@17) if: matrix.java == 'temurin@17' uses: actions/setup-java@v5 @@ -51,14 +42,6 @@ jobs: java-version: 17 cache: sbt - - name: Setup Java (temurin@19) - if: matrix.java == 'temurin@19' - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 19 - cache: sbt - - name: Setup Java (temurin@21) if: matrix.java == 'temurin@21' uses: actions/setup-java@v5 @@ -74,11 +57,11 @@ jobs: run: brew install sbt - name: Check formatting with scalafmt - if: runner.os == 'ubuntu-latest' && runner.java-version == '11' + if: runner.os == 'ubuntu-latest' && runner.java-version == '21' run: sbt '++ ${{ matrix.scala }}; scalafmtCheckAll' - name: Lint with scalafix - if: runner.os == 'ubuntu-latest' && runner.java-version == '11' + if: runner.os == 'ubuntu-latest' && runner.java-version == '21' run: sbt '++ ${{ matrix.scala }}; scalafixAll --check' - name: Check that workflows are up to date diff --git a/.gitignore b/.gitignore index 58948c5..866e98e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ project/* !project/Dependencies.scala !project/build.properties !project/plugins.sbt + +.DS_Store \ No newline at end of file diff --git a/build.sbt b/build.sbt index bbff376..7682e15 100644 --- a/build.sbt +++ b/build.sbt @@ -7,7 +7,7 @@ val projectName = "gerlib" val rootPkg = s"at.ac.oeaw.imba.gerlich.$projectName" val gitHubOwner = "gerlichlab" val gitPubUrl = s"https://github.com/$gitHubOwner/$projectName.git" -val primaryJavaVersion = "11" +val primaryJavaVersion = "21" val primaryOs = "ubuntu-latest" val isPrimaryOsAndPrimaryJavaTest = s"runner.os == '$primaryOs' && runner.java-version == '$primaryJavaVersion'" @@ -24,10 +24,11 @@ ThisBuild / scalafixDependencies ++= Seq( ThisBuild / githubWorkflowOSes := Set(primaryOs, "macos-latest", "ubuntu-latest").toSeq ThisBuild / githubWorkflowTargetBranches := Seq("main") ThisBuild / githubWorkflowPublishTargetBranches := Seq() -ThisBuild / githubWorkflowJavaVersions := Seq(primaryJavaVersion, "17", "19", "21").map(JavaSpec.temurin) +ThisBuild / githubWorkflowJavaVersions := Seq("17", primaryJavaVersion).map(JavaSpec.temurin) +ThisBuild / githubWorkflowBuildMatrixExclusions += MatrixExclude(Map("os" -> "macos-latest", "java" -> "temurin@17")) ThisBuild / githubWorkflowBuildPreamble ++= Seq( // Account for the absence of sbt in newer versions of the setup-java GitHub Action. - WorkflowStep.Run(commands = List("brew install sbt"), cond = Some("contains(runner.os, 'macos')")), + WorkflowStep.Run(commands = List("brew install sbt"), cond = Some("contains(runner.os, 'macos')")), /* Add linting and formatting checks, but only limit to a single platform + Java combo. */ WorkflowStep.Sbt( List("scalafmtCheckAll"), From b6b73e0df0e4b1f38807959483793a3a60ca7417 Mon Sep 17 00:00:00 2001 From: Christoph Langer Date: Tue, 7 Jul 2026 13:39:55 +0200 Subject: [PATCH 10/11] chore: update version to 0.6.0 and update changelog --- CHANGELOG.md | 3 ++- build.sbt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6ebc4b..9825a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [v0.6.0] - 2026-07-07 ### Added * `EuclideanDistance.unsafe` method, wrapping a `squants.space.Length` value as a `EuclideanDistance`, @@ -16,6 +16,7 @@ performing the necessary validation as nonnegative and erroring otherwise. ### Changed * Changed the error message for refinement of `squants.space.Length` as a `Distance` value from "Allegedly nonnegative length must actually be nonnegative." to "Allegedly nonnegative length is actually negative." * Updated `sbt` to version 1.12.13 +* Restricted the CI Java version matrix to the LTS releases Temurin 17 and 21 (dropping 11 and 19). ## [v0.5.2] - 2026-03-18 diff --git a/build.sbt b/build.sbt index 7682e15..7e00a0d 100644 --- a/build.sbt +++ b/build.sbt @@ -201,7 +201,7 @@ lazy val compileSettings = Def.settings( Test / console / scalacOptions := (Compile / console / scalacOptions).value, ) -lazy val versionNumber = "0.6.0-SNAPSHOT" +lazy val versionNumber = "0.6.0" lazy val metadataSettings = Def.settings( name := projectName, From 3f4529129c048af61e3484751a51a1c9ad662c70 Mon Sep 17 00:00:00 2001 From: Christoph Langer Date: Tue, 7 Jul 2026 13:53:56 +0200 Subject: [PATCH 11/11] docs: clarify distance scaladoc and tidy .gitignore Correct EuclideanDistance.unsafe scaladoc to state it interprets (not "tries to interpret") a length and throws IllegalArgumentException on a negative value, matching its actual behavior. Document that the Show[Distance] format is chosen to roundtrip through Distance.parse via squants' Length parser. Remove a stray blank line and add a trailing newline in .gitignore. --- .gitignore | 3 +-- modules/geometry/src/main/scala/Distance.scala | 6 +++++- .../src/main/scala/instances/DistanceInstances.scala | 6 +++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 866e98e..3c39dde 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,4 @@ project/* !project/Dependencies.scala !project/build.properties !project/plugins.sbt - -.DS_Store \ No newline at end of file +.DS_Store diff --git a/modules/geometry/src/main/scala/Distance.scala b/modules/geometry/src/main/scala/Distance.scala index a1a5ec4..e117499 100644 --- a/modules/geometry/src/main/scala/Distance.scala +++ b/modules/geometry/src/main/scala/Distance.scala @@ -97,6 +97,10 @@ object EuclideanDistance: def parse: String => Either[String, EuclideanDistance] = s => Distance.parse(s).map(EuclideanDistance.apply) - /** Try to interpret the given length value as a distance in Euclidean space. */ + /** Interpret the given length as a distance in Euclidean space. + * + * @throws java.lang.IllegalArgumentException + * if the given length is negative + */ def unsafe: Length => EuclideanDistance = Distance.applyUnsafe `andThen` EuclideanDistance.apply end EuclideanDistance diff --git a/modules/geometry/src/main/scala/instances/DistanceInstances.scala b/modules/geometry/src/main/scala/instances/DistanceInstances.scala index 8467bb1..cd550b5 100644 --- a/modules/geometry/src/main/scala/instances/DistanceInstances.scala +++ b/modules/geometry/src/main/scala/instances/DistanceInstances.scala @@ -6,7 +6,11 @@ import at.ac.oeaw.imba.gerlich.gerlib.SimpleShow /** Typeclass instances related to distance values */ trait DistanceInstances: - /** Show the quantity and units with a space in between */ + /** Show the quantity and units with a space in between. + * + * This format is chosen so that the rendered value roundtrips through `Distance.parse`, which + * delegates to squants' `Length` parser. + */ given Show[Distance] = Show.show { d => s"${d.value} ${d.unit.symbol}" }