Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 7 additions & 19 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,46 +22,34 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
scala: [3.6.4]
java: [temurin@17, temurin@21]
exclude:
- os: macos-latest
java: temurin@17
scala: [3.9.0]
java: [temurin@21]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout current branch (full)
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Setup Java (temurin@17)
if: matrix.java == 'temurin@17'
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
cache: sbt

- name: Setup Java (temurin@21)
if: matrix.java == 'temurin@21'
uses: actions/setup-java@v5
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
cache: sbt

- name: Setup sbt
uses: sbt/setup-sbt@v1

- if: contains(runner.os, 'macos')
run: brew install sbt
with:
sbt-runner-version: 1.13.1

- name: Check formatting with scalafmt
if: runner.os == 'ubuntu-latest' && runner.java-version == '21'
if: runner.os == 'Linux' && matrix.java == 'temurin@21'
run: sbt '++ ${{ matrix.scala }}; scalafmtCheckAll'

- name: Lint with scalafix
if: runner.os == 'ubuntu-latest' && runner.java-version == '21'
if: runner.os == 'Linux' && matrix.java == 'temurin@21'
run: sbt '++ ${{ matrix.scala }}; scalafixAll --check'

- name: Check that workflows are up to date
Expand Down
2 changes: 1 addition & 1 deletion .scalafmt.conf
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version = 3.9.3
version = 3.11.5
runner.dialect = scala3

maxColumn = 100
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ 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]

### Changed
* CI tests on Java 21 only, on Linux and macOS, and no longer on Java 17: looptrace builds and runs this library with Java 21.
* CI installs the sbt launcher of the version `project/build.properties` names, 1.13.1, rather than setup-sbt's default, the newest one, so a new sbt release can't change how CI starts the build.
* CI no longer installs sbt with Homebrew on macOS: setup-sbt installs it on every runner, and Homebrew's sbt was shadowed by it.

## [v0.7.0] - 2026-11-08

### Changed
* **Scala 3.6.4 -> 3.9.0,** the long-term support line, where 3.6.4 is no longer patched. A library compiled with 3.9 can't be read by an older Scala 3 compiler, so a project depending on this release needs Scala 3.9 too. Under `-source:future`, 3.9 no longer accepts `implicit val`, and wants implicit arguments passed with `using`: each test suite's `PropertyCheckConfiguration` is now a `given` of the suite, which is preferred over the inherited one. Its stricter unused-symbol checks, errors under `-Werror`, removed unused imports and parameters, among them the `A: Order` bound of `max` on coordinates, which orders with `Order[C]`, so it asks for less.
* sbt 1.12.13 -> 1.13.1, the newest release of sbt 1.
* **The libraries at their newest release within their major version:** iron 3.0.0 -> 3.4.1, upickle and ujson 4.1.0 -> 4.4.3, pureconfig 0.17.10 -> 0.17.11, mouse 1.3.2 -> 1.4.0, scala-graph 2.0.2 -> 2.0.3, fs2-data-csv 1.11.2 -> 1.14.1, fs2-io 3.11.0 -> 3.14.0 and os-lib 0.11.4 -> 0.11.10; for the tests, scalatest and scalactic 3.2.19 -> 3.2.20, and scalacheck 1.18.1 -> 1.20.0 with scalatestplus' `scalacheck-1-20`. cats, kittens, squants and discipline-scalatest are at their newest already.
* **`NonnegativeInt`, `NonnegativeReal`, `PositiveInt`, `PositiveReal` and `Distance` extend iron's `RefinedSubtype`,** where they extended `RefinedType`. Since iron 3.1.0, a `RefinedType` is a type of its own, no longer a subtype of the type it refines; `RefinedSubtype`, from iron 3.2.0, keeps them as they were, so a `PositiveInt` is still an `Int`.
* **upickle traces `read` by default since 4.4.0:** an error while reading comes wrapped in `upickle.core.TraceVisitor.TraceException`, whose message is the JSON path, with the reader's own error as its cause, unless `read` is given `trace = false`. The codec of `DistanceLike` reads its value and metric type untraced, so its message still gives the value's own error, where it would have given only `$`; a test pins it.
* **The sbt plugins at their newest releases:** sbt-assembly 2.3.1 -> 2.5.0, sbt-github-actions 0.31.0 -> 0.32.2, which moves the workflow to `actions/setup-java` v6, sbt-scalafmt 2.6.1 -> 2.6.2, sbt-buildinfo 0.13.1 -> 0.13.2 and sbt-scalafix 0.14.7 -> 0.14.9; and typelevel's scalafix rules 0.2.0 -> 0.6.0, which the code passes as it is.
* scalafmt 3.9.3 -> 3.11.5, which aligns six `case` arrows anew.

### Removed
* **Breaking: the `zarr` module, and `readRoiData` and its helper `getOriginZYX` from `roi`** ([#30](https://github.com/gerlichlab/gerlib/issues/30)). The module read format-2 Zarr stores with jzarr 0.4.2, which brings Unidata's netCDF library (`edu.ucar:cdm-core`) and jblosc. cdm-core is not on Maven Central, so every build had to name Unidata's repository, and in November 2026 Java on GitHub's macOS runner rejected that repository's certificate, which failed looptrace's CI while it published this library. `roi` depended on `zarr` only for `readRoiData`, so `roi` and `io`, and whatever depends on them, brought all three. Nothing else in this library used them, no test covered them, and jzarr can't read format-3 stores. A project that reads ROI data with them can stay on v0.6.0. `roi` now names `imaging`, which it used and had through `zarr`.

### Fixed
* **CI runs the scalafmt and scalafix checks,** on Linux with Java 21. Their condition was never true, so no job ran them: it compared the runner's OS with the label `ubuntu-latest`, where the runner names its OS `Linux`, and tested `runner.java-version`, which does not exist. It now tests the matrix's Java entry, as looptrace's build does.

## [v0.6.0] - 2026-07-07

### Added
Expand Down
37 changes: 20 additions & 17 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,34 +1,40 @@
import Dependencies.*

/* Core settings */
ThisBuild / scalaVersion := "3.6.4"
ThisBuild / scalaVersion := "3.9.0"
val groupId = "com.github.gerlichlab"
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 = "21"
val primaryOs = "ubuntu-latest"
val isPrimaryOsAndPrimaryJavaTest = s"runner.os == '$primaryOs' && runner.java-version == '$primaryJavaVersion'"

// Needed for ZARR (jzarr) (?)
ThisBuild / resolvers += "Unidata UCAR" at "https://artifacts.unidata.ucar.edu/repository/unidata-all/"
// Run the lint and format checks on one matrix job only. Use the runner's OS name
// ('Linux'/'macOS', not the 'ubuntu-latest' label) and the matrix's Java entry: a
// `runner.java-version` property does not exist, and the previous condition built on
// it was never true, so scalafmt and scalafix were silently skipped on every job.
val isPrimaryOsAndPrimaryJavaTest =
s"runner.os == 'Linux' && matrix.java == '${JavaSpec.temurin(primaryJavaVersion).render}'"

/* scalafix */
ThisBuild / semanticdbEnabled := true
ThisBuild / scalafixDependencies ++= Seq(
"cats", "cats-effect", "fs2"
).map(prj => "org.typelevel" %% s"typelevel-scalafix-${prj}" % "0.2.0")
).map(prj => "org.typelevel" %% s"typelevel-scalafix-${prj}" % "0.6.0")

/* sbt-github-actions settings */
ThisBuild / githubWorkflowOSes := Set(primaryOs, "macos-latest", "ubuntu-latest").toSeq
ThisBuild / githubWorkflowTargetBranches := Seq("main")
ThisBuild / githubWorkflowPublishTargetBranches := Seq()
ThisBuild / githubWorkflowJavaVersions := Seq("17", primaryJavaVersion).map(JavaSpec.temurin)
ThisBuild / githubWorkflowBuildMatrixExclusions += MatrixExclude(Map("os" -> "macos-latest", "java" -> "temurin@17"))
// Java 21 only, which looptrace builds and runs this library with.
ThisBuild / githubWorkflowJavaVersions := Seq(JavaSpec.temurin(primaryJavaVersion))
// The sbt launcher CI installs is the version project/build.properties asks for, where
// setup-sbt would install the newest sbt.
ThisBuild / githubWorkflowJobSetup := (ThisBuild / githubWorkflowJobSetup).value.map {
case step if step.name.contains("Setup sbt") => WorkflowStep.SetupSbt(Some(sbtVersion.value))
case step => step
}
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')")),
/* Add linting and formatting checks, but only limit to a single platform + Java combo. */
WorkflowStep.Sbt(
List("scalafmtCheckAll"),
Expand All @@ -53,7 +59,7 @@ ThisBuild / testOptions += Tests.Argument("-oF") // full stack traces

lazy val root = project
.in(file("."))
.aggregate(cell, configuration, geometry, graph, imaging, io, json, numeric, pan, refinement, roi, testing, zarr)
.aggregate(cell, configuration, geometry, graph, imaging, io, json, numeric, pan, refinement, roi, testing)
.enablePlugins(BuildInfoPlugin)
.settings(commonSettings)
.settings(noPublishSettings)
Expand Down Expand Up @@ -152,7 +158,7 @@ lazy val refinement = defineModule("refinement")(project)
)

lazy val roi = defineModule("roi")(project)
.dependsOn(geometry, numeric, zarr)
.dependsOn(geometry, imaging, numeric)

lazy val testing = defineModule("testing", false)(project)
.dependsOn(geometry, imaging, io, numeric)
Expand All @@ -163,10 +169,6 @@ lazy val testing = defineModule("testing", false)(project)
scalatestScalacheck % Test,
))

lazy val zarr = defineModule("zarr")(project)
.dependsOn(imaging, numeric)
.settings(libraryDependencies ++= Seq(jzarr))

lazy val commonSettings = Def.settings(
compileSettings,
metadataSettings,
Expand All @@ -193,6 +195,7 @@ lazy val compileSettings = Def.settings(
// 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. */
"-Wconf:msg=unused import&src=./modules/geometry/src/test/scala/TestCoordinates.scala:silent",
"-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",
Expand All @@ -201,7 +204,7 @@ lazy val compileSettings = Def.settings(
Test / console / scalacOptions := (Compile / console / scalacOptions).value,
)

lazy val versionNumber = "0.6.0"
lazy val versionNumber = "0.7.0"

lazy val metadataSettings = Def.settings(
name := projectName,
Expand Down
1 change: 0 additions & 1 deletion modules/cell/src/main/scala/NuclearDesignation.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package at.ac.oeaw.imba.gerlich.gerlib.cell

import cats.*
import cats.instances.int.*
import cats.derived.*
import cats.syntax.all.*

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class TestPixelDefinitionConfiguration
val rawConfigData =
buildRawConfig(perX -> unit, perY -> unit, perZ -> unit)
ConfigSource.string(rawConfigData).load[Pixels3D] match {
case Left(errors) => fail(s"Errors: $errors")
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
Expand Down
1 change: 0 additions & 1 deletion modules/geometry/src/main/scala/Distance.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package at.ac.oeaw.imba.gerlich.gerlib.geometry

import scala.util.NotGiven
import scala.util.chaining.* // for pipe
import cats.*
import cats.data.Validated
import cats.syntax.all.*
Expand Down
4 changes: 2 additions & 2 deletions modules/geometry/src/main/scala/package.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package at.ac.oeaw.imba.gerlich.gerlib

import cats.Order
import io.github.iltotore.iron.{RefinedType, RuntimeConstraint}
import io.github.iltotore.iron.{RefinedSubtype, RuntimeConstraint}
import io.github.iltotore.iron.constraint.any.Not
import io.github.iltotore.iron.constraint.numeric.Negative
import squants.space.Length
Expand Down Expand Up @@ -46,7 +46,7 @@ package object geometry:
*/
type Distance = Distance.T

object Distance extends RefinedType[Length, Not[Negative]]:
object Distance extends RefinedSubtype[Length, Not[Negative]]:
given Order[Distance] = Order.fromOrdering

def parse(s: String): Either[String, Distance] =
Expand Down
2 changes: 1 addition & 1 deletion modules/geometry/src/main/scala/syntax/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ package object syntax:
ZCoordinate(a.z.value - b.z.value)
)

extension [A: Order, C <: Coordinate[A]: [C] =>> NotGiven[C =:= Coordinate[A]]](c1: C)(using
extension [A, C <: Coordinate[A]: [C] =>> NotGiven[C =:= Coordinate[A]]](c1: C)(using
ord: Order[C]
) infix def max(c2: C): C = ord.max(c1, c2)

Expand Down
4 changes: 2 additions & 2 deletions modules/geometry/src/test/scala/TestCoordinates.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import at.ac.oeaw.imba.gerlich.gerlib.geometry.instances.coordinate.given

/** Tests for the geometric coordinate abstractions */
class TestCoordinates extends AnyFunSuite, should.Matchers, ScalaCheckPropertyChecks:
override implicit val generatorDrivenConfig: PropertyCheckConfiguration =
given PropertyCheckConfiguration =
PropertyCheckConfiguration(minSuccessful = 100)

test("Ordering coordinates works.") {
Expand All @@ -29,7 +29,7 @@ class TestCoordinates extends AnyFunSuite, should.Matchers, ScalaCheckPropertyCh
val builtFirst = Random
.shuffle(rawValues)
.map(build)
.sorted(summon[Order[C[A]]].toOrdering)
.sorted(using summon[Order[C[A]]].toOrdering)
orderedFirst shouldEqual builtFirst

enum CoordinateKey:
Expand Down
4 changes: 2 additions & 2 deletions modules/graph/src/test/scala/TestGraph.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class TestGraph
should.Matchers:

// needed since we're in AnyFunSuiteLike land
override implicit val generatorDrivenConfig: PropertyCheckConfiguration =
given PropertyCheckConfiguration =
PropertyCheckConfiguration(minSuccessful = 100)

private val maxOrder: Int = 10
Expand Down Expand Up @@ -53,7 +53,7 @@ class TestGraph
)
Arbitrary { Gen.frequency(1 -> genEmpty, (maxOrder - 1) -> genNonEmpty) }

given [N: Eq] => Eq[SimplestGraph[N]] =
given [N] => Eq[SimplestGraph[N]] =
Eq.by: g =>
(g.nodes.toOuter, g.edges.toOuter)

Expand Down
1 change: 0 additions & 1 deletion modules/imaging/src/main/scala/ImagingChannel.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import io.github.iltotore.iron.constraint.any.Not
import io.github.iltotore.iron.constraint.numeric.Negative

import at.ac.oeaw.imba.gerlich.gerlib.numeric.parseThroughNonnegativeInt
import at.ac.oeaw.imba.gerlich.gerlib.syntax.all.*

/** Semantic wrapper around value representing 0-based imaging channel */
final case class ImagingChannel(private[gerlib] get: Int :| Not[Negative]) derives Order
Expand Down
2 changes: 1 addition & 1 deletion modules/imaging/src/test/scala/TestImagingInstances.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import at.ac.oeaw.imba.gerlich.gerlib.syntax.all.*
/** Tests for imaging-related types' typeclass instances */
class TestImagingInstances extends AnyFunSuite, ScalaCheckPropertyChecks, should.Matchers:

override implicit val generatorDrivenConfig: PropertyCheckConfiguration =
given PropertyCheckConfiguration =
// Boost up the minimum number of successes since the tests are fast, but the
// space of cases is complex.
PropertyCheckConfiguration(minSuccessful = 10000)
Expand Down
4 changes: 2 additions & 2 deletions modules/imaging/src/test/scala/TestPixelDefinition.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package at.ac.oeaw.imba.gerlich.gerlib.imaging

import cats.*
import cats.syntax.all.*
import squants.space.{Length, LengthUnit, Microns, Nanometers}
import squants.space.{Length, Microns, Nanometers}
import org.scalacheck.*
import org.scalactic.{Equality, TolerantNumerics}
import org.scalatest.funsuite.AnyFunSuite
Expand All @@ -29,7 +29,7 @@ class TestPixelDefinition extends AnyFunSuite, ScalaCheckPropertyChecks, should.
forAll(genLength, genPixelCount, minSuccessful(10000)) { (length, numPixels) =>
PixelDefinition.tryToDefine(length) match {
case Left(_) if length.value <= 0 => succeed
case Left(msg) =>
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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ trait JsonInstancesForCollections:
given [A: Reader] => Reader[AtLeast2[List, A]] =
reader[ujson.Value].map(json =>
json.arrOpt match
case None => throw IncompleteParseException(s"Cannot read value as array-like: $json")
case None => throw IncompleteParseException(s"Cannot read value as array-like: $json")
case Some(vs) =>
Alternative[List].separate(
vs.toList.map { v =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,21 @@ trait JsonInstancesForGeometry:
.get(valueKey)
.toRight(s"Missing value key ($valueKey) for distance")
.flatMap(s =>
Try(read[Distance](s)).toEither
// Untraced, so the message is the value's own error: since upickle 4.4.0,
// a traced read's message is only the path, here always "$".
Try(read[Distance](s, trace = false)).toEither
.leftMap(e => s"Cannot decode distance value ($s): ${e.getMessage}")
)
.toValidatedNel
val builderNel: ValidatedNel[String, Distance => DistanceLike] = data
.get(typeKey)
.toRight(s"Missing type key ($typeKey) for distance")
.flatMap(s =>
Try(read[String](s)).toEither
Try(read[String](s, trace = false)).toEither
.leftMap(_.getMessage)
.flatMap {
case `euclideanSpecifier` => EuclideanDistance.apply.asRight
case unknownSpecifier =>
case unknownSpecifier =>
s"Unrecognized distance metric specifier ($unknownSpecifier)".asLeft
}
)
Expand Down
16 changes: 15 additions & 1 deletion modules/json/src/test/scala/TestDistanceJsonCodec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ class TestDistanceJsonCodec extends AnyFunSuite, should.Matchers, ScalaCheckProp
)
)
) { (data, expectedException, check) =>
val observedException = intercept[expectedException.type] { read[Distance](ujson.Str(data)) }
// The codec's own exception: since upickle 4.4.0, a traced read wraps it with the JSON path.
val observedException = intercept[expectedException.type] {
read[Distance](ujson.Str(data), trace = false)
}
val isGood: Boolean = check.apply(observedException)
isGood shouldEqual true
}
Expand All @@ -71,6 +74,17 @@ class TestDistanceJsonCodec extends AnyFunSuite, should.Matchers, ScalaCheckProp
reparsed shouldEqual original
}

test("An illegal distance-like value is reported with the value's own error."):
import geometry.given // for the ReadWriter instance

// Not just the JSON path, which is what a traced read of the value would give.
val data = ujson.Obj("value" -> ujson.Str("70"), "metricType" -> ujson.Str("Euclidean"))
val error = intercept[IncompleteParseException] {
read[at.ac.oeaw.imba.gerlich.gerlib.geometry.DistanceLike](data, trace = false)
}
error.getMessage shouldEqual
"Error(s): Cannot decode distance value (\"70\"): Unable to parse Length:70"

private def unsafeBuildLength(value: Int | Double, units: LengthUnit): Length =
Length(s"$value ${units.symbol}").fold(throw _, identity)
end TestDistanceJsonCodec
Loading
Loading