From a0d831615306ccbd396ffe9d6e6d027d0a6956c3 Mon Sep 17 00:00:00 2001 From: Frank Thomas Date: Wed, 8 Mar 2023 08:53:47 +0100 Subject: [PATCH] Add fs2-cron-cron-utils module (#412) --- .github/workflows/ci.yml | 4 +- build.sbt | 51 +++++++------ .../fs2cron/calev/CalevSchedulerTest.scala | 23 +++--- .../cronutils/CronUtilsScheduler.scala | 41 ++++++++++ .../cronutils/CronUtilsSchedulerTest.scala | 75 +++++++++++++++++++ .../fs2cron/cron4s/Cron4sSchedulerTest.scala | 23 +++--- project/Dependencies.scala | 5 +- 7 files changed, 172 insertions(+), 50 deletions(-) create mode 100644 modules/cron-utils/src/main/scala/eu/timepit/fs2cron/cronutils/CronUtilsScheduler.scala create mode 100644 modules/cron-utils/src/test/scala/eu/timepit/fs2cron/cronutils/CronUtilsSchedulerTest.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d58c587..84344c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,11 +97,11 @@ jobs: - name: Make target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master') - run: mkdir -p target .js/target modules/cron4s/.jvm/target .jvm/target .native/target modules/readme/target modules/core/.jvm/target modules/calev/.jvm/target project/target + run: mkdir -p target .js/target modules/cron4s/.jvm/target .jvm/target .native/target modules/cron-utils/.jvm/target modules/readme/target modules/core/.jvm/target modules/calev/.jvm/target project/target - name: Compress target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master') - run: tar cf targets.tar target .js/target modules/cron4s/.jvm/target .jvm/target .native/target modules/readme/target modules/core/.jvm/target modules/calev/.jvm/target project/target + run: tar cf targets.tar target .js/target modules/cron4s/.jvm/target .jvm/target .native/target modules/cron-utils/.jvm/target modules/readme/target modules/core/.jvm/target modules/calev/.jvm/target project/target - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master') diff --git a/build.sbt b/build.sbt index 8468fae..586b75a 100644 --- a/build.sbt +++ b/build.sbt @@ -1,5 +1,4 @@ import sbtcrossproject.{CrossProject, CrossType, Platform} -import org.typelevel.sbt.gha.JavaSpec.Distribution.Temurin /// variables @@ -13,9 +12,10 @@ val Scala_2_13 = "2.13.10" val Scala_3 = "3.2.2" val moduleCrossPlatformMatrix: Map[String, List[Platform]] = Map( + "calev" -> List(JVMPlatform), "core" -> List(JVMPlatform), "cron4s" -> List(JVMPlatform), - "calev" -> List(JVMPlatform) + "cron-utils" -> List(JVMPlatform) ) /// global settings @@ -66,7 +66,7 @@ ThisBuild / mergifyPrRules := { /// projects lazy val root = tlCrossRootProject - .aggregate(calev, core, cron4s, readme) + .aggregate(calev, core, cron4s, cronUtils, readme) lazy val core = myCrossProject("core") .settings( @@ -75,53 +75,60 @@ lazy val core = myCrossProject("core") ) ) -lazy val coreJVM = core.jvm - -lazy val cron4s = myCrossProject("cron4s") +lazy val calev = myCrossProject("calev") .dependsOn(core) .settings( - crossScalaVersions := List(Scala_2_12, Scala_2_13), libraryDependencies ++= Seq( - Dependencies.cron4s, - Dependencies.fs2Core, - Dependencies.scalaTest % Test + Dependencies.calevCore, + Dependencies.munitCatsEffect % Test ), initialCommands := s""" import $rootPkg._ - import cats.effect.unsafe.implicits.global + import $rootPkg.calev._ import cats.effect.IO - import _root_.cron4s.Cron + import cats.effect.unsafe.implicits.global + import com.github.eikek.calev._ import fs2.Stream import scala.concurrent.ExecutionContext """ ) -lazy val cron4sJVM = cron4s.jvm - -lazy val calev = myCrossProject("calev") +lazy val cron4s = myCrossProject("cron4s") .dependsOn(core) .settings( + crossScalaVersions := List(Scala_2_12, Scala_2_13), libraryDependencies ++= Seq( - Dependencies.calevCore, - Dependencies.scalaTest % Test + Dependencies.cron4s, + Dependencies.munitCatsEffect % Test ), initialCommands := s""" import $rootPkg._ - import $rootPkg.calev._ - import cats.effect.IO import cats.effect.unsafe.implicits.global - import com.github.eikek.calev._ + import cats.effect.IO + import _root_.cron4s.Cron import fs2.Stream import scala.concurrent.ExecutionContext """ ) -lazy val calevJVM = calev.jvm +lazy val cronUtils = myCrossProject("cron-utils") + .dependsOn(core) + .settings( + libraryDependencies ++= Seq( + Dependencies.cronUtils, + Dependencies.munitCatsEffect % Test + ), + tlVersionIntroduced := Map( + "2.12" -> "0.8.2", + "2.13" -> "0.8.2", + "3" -> "0.8.2" + ) + ) lazy val readme = project .in(file("modules/readme")) .enablePlugins(MdocPlugin, NoPublishPlugin) - .dependsOn(calevJVM, cron4sJVM) + .dependsOn(calev.jvm, cron4s.jvm) .settings(commonSettings) .settings( crossScalaVersions := List(Scala_2_12, Scala_2_13), diff --git a/modules/calev/src/test/scala/eu/timepit/fs2cron/calev/CalevSchedulerTest.scala b/modules/calev/src/test/scala/eu/timepit/fs2cron/calev/CalevSchedulerTest.scala index f44c572..7a5a82a 100644 --- a/modules/calev/src/test/scala/eu/timepit/fs2cron/calev/CalevSchedulerTest.scala +++ b/modules/calev/src/test/scala/eu/timepit/fs2cron/calev/CalevSchedulerTest.scala @@ -17,16 +17,16 @@ package eu.timepit.fs2cron.calev import cats.effect.IO -import cats.effect.unsafe.implicits.global import com.github.eikek.calev.CalEvent import fs2.Stream -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers +import munit.CatsEffectSuite import java.time.{Instant, ZoneId, ZoneOffset} -class CalevSchedulerTest extends AnyFunSuite with Matchers { +class CalevSchedulerTest extends CatsEffectSuite { + private val everySecond: CalEvent = CalEvent.unsafe("*-*-* *:*:*") private val evenSeconds: CalEvent = CalEvent.unsafe("*-*-* *:*:0/2") + private def isEven(i: Long): Boolean = i % 2 == 0 private def instantSeconds(i: Instant): Long = i.getEpochSecond private val evalInstantNow: Stream[IO, Instant] = Stream.eval(IO(Instant.now())) @@ -37,26 +37,25 @@ class CalevSchedulerTest extends AnyFunSuite with Matchers { test("awakeEvery") { val s1 = schedulerSys.awakeEvery(evenSeconds) >> evalInstantNow val s2 = s1.map(instantSeconds).take(2).forall(isEven) - s2.compile.last.map(_ should be(Option(true))).unsafeRunSync() + assertIO(s2.compile.last, Some(true)) } test("sleep") { val s1 = schedulerUtc.sleep(evenSeconds) >> evalInstantNow val s2 = s1.map(instantSeconds).forall(isEven) - s2.compile.last.map(_ should be(Option(true))).unsafeRunSync() + assertIO(s2.compile.last, Some(true)) } test("schedule") { - val everySecond: CalEvent = CalEvent.unsafe("*-*-* *:*:*") val s1 = schedulerSys .schedule(List(everySecond -> evalInstantNow, evenSeconds -> evalInstantNow)) .map(instantSeconds) - (for { + for { seconds <- s1.take(3).compile.toList - _ <- IO(seconds.count(isEven) shouldBe 2) - _ <- IO(seconds.count(!isEven(_)) shouldBe 1) - } yield ()).unsafeRunSync() + _ = assertEquals(seconds.count(isEven), 2) + _ = assertEquals(seconds.count(!isEven(_)), 1) + } yield () } test("timezones") { @@ -65,6 +64,6 @@ class CalevSchedulerTest extends AnyFunSuite with Matchers { val s1 = scheduler.awakeEvery(evenSeconds) >> evalInstantNow val s2 = s1.map(instantSeconds).take(2).forall(!isEven(_)) - s2.compile.last.map(_ should be(Option(true))).unsafeRunSync() + assertIO(s2.compile.last, Some(true)) } } diff --git a/modules/cron-utils/src/main/scala/eu/timepit/fs2cron/cronutils/CronUtilsScheduler.scala b/modules/cron-utils/src/main/scala/eu/timepit/fs2cron/cronutils/CronUtilsScheduler.scala new file mode 100644 index 0000000..ccd23fa --- /dev/null +++ b/modules/cron-utils/src/main/scala/eu/timepit/fs2cron/cronutils/CronUtilsScheduler.scala @@ -0,0 +1,41 @@ +/* + * Copyright 2018-2021 fs2-cron contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package eu.timepit.fs2cron.cronutils + +import cats.effect.{Sync, Temporal} +import com.cronutils.model.time.ExecutionTime +import eu.timepit.fs2cron.{Scheduler, ZonedDateTimeScheduler} + +import java.time.{ZoneId, ZoneOffset, ZonedDateTime} + +object CronUtilsScheduler { + def systemDefault[F[_]](implicit temporal: Temporal[F], F: Sync[F]): Scheduler[F, ExecutionTime] = + from(F.delay(ZoneId.systemDefault())) + + def utc[F[_]](implicit F: Temporal[F]): Scheduler[F, ExecutionTime] = + from(F.pure(ZoneOffset.UTC)) + + def from[F[_]](zoneId: F[ZoneId])(implicit F: Temporal[F]): Scheduler[F, ExecutionTime] = + new ZonedDateTimeScheduler[F, ExecutionTime](zoneId) { + override def next(from: ZonedDateTime, schedule: ExecutionTime): F[ZonedDateTime] = + schedule.nextExecution(from).map[F[ZonedDateTime]](zdt => F.pure(zdt)).orElse { + val msg = s"Could not calculate the next date-time from $from " + + s"given the cron expression '$schedule'." + F.raiseError(new Throwable(msg)) + } + } +} diff --git a/modules/cron-utils/src/test/scala/eu/timepit/fs2cron/cronutils/CronUtilsSchedulerTest.scala b/modules/cron-utils/src/test/scala/eu/timepit/fs2cron/cronutils/CronUtilsSchedulerTest.scala new file mode 100644 index 0000000..ec864cc --- /dev/null +++ b/modules/cron-utils/src/test/scala/eu/timepit/fs2cron/cronutils/CronUtilsSchedulerTest.scala @@ -0,0 +1,75 @@ +/* + * Copyright 2018-2021 fs2-cron contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package eu.timepit.fs2cron.cronutils + +import cats.effect.IO +import com.cronutils.model.CronType +import com.cronutils.model.definition.CronDefinitionBuilder +import com.cronutils.model.time.ExecutionTime +import com.cronutils.parser.CronParser +import fs2.Stream +import munit.CatsEffectSuite + +import java.time.{Instant, ZoneId, ZoneOffset} + +class CronUtilsSchedulerTest extends CatsEffectSuite { + private val cronDef = CronDefinitionBuilder.instanceDefinitionFor(CronType.SPRING) + private val parser = new CronParser(cronDef) + + private val everySecond = ExecutionTime.forCron(parser.parse("* * * ? * *")) + private val evenSeconds = ExecutionTime.forCron(parser.parse("*/2 * * ? * *")) + + private def isEven(i: Long): Boolean = i % 2 == 0 + private def instantSeconds(i: Instant): Long = i.getEpochSecond + private val evalInstantNow: Stream[IO, Instant] = Stream.eval(IO(Instant.now())) + + private val schedulerSys = CronUtilsScheduler.systemDefault[IO] + private val schedulerUtc = CronUtilsScheduler.utc[IO] + + test("awakeEvery") { + val s1 = schedulerSys.awakeEvery(evenSeconds) >> evalInstantNow + val s2 = s1.map(instantSeconds).take(2).forall(isEven) + assertIO(s2.compile.last, Some(true)) + } + + test("sleep") { + val s1 = schedulerUtc.sleep(evenSeconds) >> evalInstantNow + val s2 = s1.map(instantSeconds).forall(isEven) + assertIO(s2.compile.last, Some(true)) + } + + test("schedule") { + val s1 = schedulerSys + .schedule(List(everySecond -> evalInstantNow, evenSeconds -> evalInstantNow)) + .map(instantSeconds) + + for { + seconds <- s1.take(3).compile.toList + _ = assertEquals(seconds.count(isEven), 2) + _ = assertEquals(seconds.count(!isEven(_)), 1) + } yield () + } + + test("timezones") { + val zoneId: ZoneId = ZoneOffset.ofTotalSeconds(1) + val scheduler = CronUtilsScheduler.from(IO.pure(zoneId)) + + val s1 = scheduler.awakeEvery(evenSeconds) >> evalInstantNow + val s2 = s1.map(instantSeconds).take(2).forall(!isEven(_)) + assertIO(s2.compile.last, Some(true)) + } +} diff --git a/modules/cron4s/src/test/scala-2/eu/timepit/fs2cron/cron4s/Cron4sSchedulerTest.scala b/modules/cron4s/src/test/scala-2/eu/timepit/fs2cron/cron4s/Cron4sSchedulerTest.scala index 26b7625..c3f455c 100644 --- a/modules/cron4s/src/test/scala-2/eu/timepit/fs2cron/cron4s/Cron4sSchedulerTest.scala +++ b/modules/cron4s/src/test/scala-2/eu/timepit/fs2cron/cron4s/Cron4sSchedulerTest.scala @@ -17,17 +17,17 @@ package eu.timepit.fs2cron.cron4s import cats.effect.IO -import cats.effect.unsafe.implicits.global import cron4s.Cron import cron4s.expr.CronExpr import fs2.Stream -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers +import munit.CatsEffectSuite import java.time.{Instant, ZoneId, ZoneOffset} -class Cron4sSchedulerTest extends AnyFunSuite with Matchers { +class Cron4sSchedulerTest extends CatsEffectSuite { + private val everySecond: CronExpr = Cron.unsafeParse("* * * ? * *") private val evenSeconds: CronExpr = Cron.unsafeParse("*/2 * * ? * *") + private def isEven(i: Long): Boolean = i % 2 == 0 private def instantSeconds(i: Instant): Long = i.getEpochSecond private val evalInstantNow: Stream[IO, Instant] = Stream.eval(IO(Instant.now())) @@ -38,26 +38,25 @@ class Cron4sSchedulerTest extends AnyFunSuite with Matchers { test("awakeEvery") { val s1 = schedulerSys.awakeEvery(evenSeconds) >> evalInstantNow val s2 = s1.map(instantSeconds).take(2).forall(isEven) - s2.compile.last.map(_ should be(Option(true))).unsafeRunSync() + assertIO(s2.compile.last, Some(true)) } test("sleep") { val s1 = schedulerUtc.sleep(evenSeconds) >> evalInstantNow val s2 = s1.map(instantSeconds).forall(isEven) - s2.compile.last.map(_ should be(Option(true))).unsafeRunSync() + assertIO(s2.compile.last, Some(true)) } test("schedule") { - val everySecond: CronExpr = Cron.unsafeParse("* * * ? * *") val s1 = schedulerSys .schedule(List(everySecond -> evalInstantNow, evenSeconds -> evalInstantNow)) .map(instantSeconds) - (for { + for { seconds <- s1.take(3).compile.toList - _ <- IO(seconds.count(isEven) shouldBe 2) - _ <- IO(seconds.count(!isEven(_)) shouldBe 1) - } yield ()).unsafeRunSync() + _ = assertEquals(seconds.count(isEven), 2) + _ = assertEquals(seconds.count(!isEven(_)), 1) + } yield () } test("timezones") { @@ -66,6 +65,6 @@ class Cron4sSchedulerTest extends AnyFunSuite with Matchers { val s1 = scheduler.awakeEvery(evenSeconds) >> evalInstantNow val s2 = s1.map(instantSeconds).take(2).forall(!isEven(_)) - s2.compile.last.map(_ should be(Option(true))).unsafeRunSync() + assertIO(s2.compile.last, Some(true)) } } diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 5c1333e..22436fe 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -1,8 +1,9 @@ import sbt._ object Dependencies { + val calevCore = "com.github.eikek" %% "calev-core" % "0.6.4" val cron4s = "com.github.alonsodomin.cron4s" %% "cron4s-core" % "0.6.1" + val cronUtils = "com.cronutils" % "cron-utils" % "9.2.0" val fs2Core = "co.fs2" %% "fs2-core" % "3.6.1" - val scalaTest = "org.scalatest" %% "scalatest" % "3.2.15" - val calevCore = "com.github.eikek" %% "calev-core" % "0.6.4" + val munitCatsEffect = "org.typelevel" %% "munit-cats-effect-3" % "1.0.7" }