looper-0.2.0.1: src/Looper.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module Looper
( LooperDef (..),
seconds,
minutes,
hours,
LooperFlags (..),
getLooperFlags,
LooperEnvironment (..),
getLooperEnvironment,
readLooperEnvironment,
looperEnvironmentParser,
LooperConfiguration (..),
LooperSettings (..),
deriveLooperSettings,
mkLooperDef,
runLoopers,
runLoopersIgnoreOverrun,
runLoopersRaw,
waitNominalDiffTime,
)
where
import Autodocodec
import Control.Applicative
import Control.Monad
import Data.Aeson (FromJSON, ToJSON)
import Data.Maybe
import Data.Text (Text)
import Data.Time
import qualified Env
import GHC.Generics (Generic)
import Options.Applicative as OptParse
import qualified System.Environment as System (getEnvironment)
import UnliftIO
import UnliftIO.Concurrent
-- | A looper definition
data LooperDef m = LooperDef
{ -- | The name of the looper, can be useful for logging
looperDefName :: Text,
-- | Whether this looper is enabled
looperDefEnabled :: Bool,
-- | The time between the start of each run
looperDefPeriod :: NominalDiffTime,
-- | The time before the first run
looperDefPhase :: NominalDiffTime,
-- | The function to run
looperDefFunc :: m ()
}
deriving (Generic)
-- | Construct a 'NominalDiffTime' from a number of seconds
seconds :: Double -> NominalDiffTime
seconds = realToFrac
-- | Construct a 'NominalDiffTime' from a number of minutes
minutes :: Double -> NominalDiffTime
minutes = seconds . (* 60)
-- | Construct a 'NominalDiffTime' from a number of hours
hours :: Double -> NominalDiffTime
hours = minutes . (* 60)
-- | A structure to parse command-line flags for a looper into
data LooperFlags = LooperFlags
{ looperFlagEnabled :: Maybe Bool,
looperFlagPhase :: Maybe Word, -- Seconds
looperFlagPeriod :: Maybe Word -- Seconds
}
deriving (Show, Eq, Generic)
-- | An optparse applicative parser for 'LooperFlags'
getLooperFlags ::
-- | The name of the looper (best to make this all-lowercase)
String ->
OptParse.Parser LooperFlags
getLooperFlags name =
LooperFlags <$> doubleSwitch name (unwords ["enable the", name, "looper"]) mempty
<*> option
(Just <$> auto)
( mconcat
[ long $ name <> "-phase",
metavar "SECONDS",
value Nothing,
help $ unwords ["the phase for the", name, "looper in seconsd"]
]
)
<*> option
(Just <$> auto)
( mconcat
[ long $ name <> "-period",
metavar "SECONDS",
value Nothing,
help $ unwords ["the period for the", name, "looper in seconds"]
]
)
doubleSwitch :: String -> String -> Mod FlagFields Bool -> OptParse.Parser (Maybe Bool)
doubleSwitch name helpText mods =
let enabledValue = True
disabledValue = False
defaultValue = True
in ( last . map Just
<$> some
( ( flag'
enabledValue
(hidden <> internal <> long ("enable-" ++ name) <> help helpText <> mods)
<|> flag'
disabledValue
(hidden <> internal <> long ("disable-" ++ name) <> help helpText <> mods)
)
<|> flag'
disabledValue
( long ("(enable|disable)-" ++ name)
<> help ("Enable/disable " ++ helpText ++ " (default: " ++ show defaultValue ++ ")")
<> mods
)
)
)
<|> pure Nothing
-- | A structure to parse environment variables for a looper into
data LooperEnvironment = LooperEnvironment
{ looperEnvEnabled :: Maybe Bool,
looperEnvPhase :: Maybe Word, -- Seconds
looperEnvPeriod :: Maybe Word -- Seconds
}
deriving (Show, Eq, Generic)
-- | Get a 'LooperEnvironment' from the environment
getLooperEnvironment ::
-- | Prefix for each variable (best to make this all-caps)
String ->
-- | Name of the looper (best to make this all-caps too)
String ->
IO LooperEnvironment
getLooperEnvironment prefix name = readLooperEnvironment prefix name <$> System.getEnvironment
-- | Get a 'LooperEnvironment' from a pure environment
readLooperEnvironment ::
-- | Prefix for each variable (best to make this all-caps)
String ->
-- | Name of the looper (best to make this all-caps too)
String ->
[(String, String)] ->
LooperEnvironment
readLooperEnvironment prefix name env = case Env.parsePure (Env.prefixed prefix $ looperEnvironmentParser name) env of
Left _ -> error "This indicates a bug in looper because all environment variables are optional."
Right r -> r
-- | An 'envparse' parser for a 'LooperEnvironment'
looperEnvironmentParser ::
-- | Name of the looper (best to make this all-caps)
String ->
Env.Parser Env.Error LooperEnvironment
looperEnvironmentParser name =
Env.prefixed (name <> "_") $
LooperEnvironment
<$> Env.var (fmap Just . Env.auto) "ENABLED" (Env.def Nothing <> Env.help "Whether to enable this looper")
<*> Env.var (fmap Just . Env.auto) "PHASE" (Env.def Nothing <> Env.help "The amount of time to wait before starting the looper the first time, in seconds")
<*> Env.var (fmap Just . Env.auto) "PERIOD" (Env.def Nothing <> Env.help "The amount of time to wait between runs of the looper, in seconds")
-- | A structure to configuration for a looper into
data LooperConfiguration = LooperConfiguration
{ looperConfEnabled :: Maybe Bool,
looperConfPhase :: Maybe Word,
looperConfPeriod :: Maybe Word
}
deriving stock (Show, Eq, Generic)
deriving (FromJSON, ToJSON) via (Autodocodec LooperConfiguration)
instance HasCodec LooperConfiguration where
codec =
named "LooperConfiguration" $
object "LooperConfiguration" $
LooperConfiguration
<$> parseAlternative
(optionalFieldOrNull "enable" "Enable this looper")
(optionalFieldOrNull "enabled" "Enable this looper")
.= looperConfEnabled
<*> optionalFieldOrNull "phase" "The amount of time to wait before starting the looper the first time, in seconds" .= looperConfPhase
<*> optionalFieldOrNull "period" "The amount of time to wait between runs of the looper, in seconds" .= looperConfPeriod
-- | Settings that you might want to pass into a looper using 'mkLooperDef'
data LooperSettings = LooperSettings
{ looperSetEnabled :: Bool,
looperSetPhase :: NominalDiffTime,
looperSetPeriod :: NominalDiffTime
}
deriving (Show, Eq, Generic)
deriveLooperSettings ::
-- | Default phase
NominalDiffTime ->
-- | Default period
NominalDiffTime ->
LooperFlags ->
LooperEnvironment ->
Maybe LooperConfiguration ->
LooperSettings
deriveLooperSettings defaultPhase defaultPeriod LooperFlags {..} LooperEnvironment {..} mlc =
let looperSetEnabled =
fromMaybe True $ looperFlagEnabled <|> looperEnvEnabled <|> (mlc >>= looperConfEnabled)
looperSetPhase =
maybe defaultPhase fromIntegral $
looperFlagPhase <|> looperEnvPhase <|> (mlc >>= looperConfPhase)
looperSetPeriod =
maybe defaultPeriod fromIntegral $
looperFlagPeriod <|> looperEnvPeriod <|> (mlc >>= looperConfPeriod)
in LooperSettings {..}
mkLooperDef ::
-- | Name
Text ->
LooperSettings ->
-- | The function to loop
m () ->
LooperDef m
mkLooperDef name LooperSettings {..} func =
LooperDef
{ looperDefName = name,
looperDefEnabled = looperSetEnabled,
looperDefPeriod = looperSetPeriod,
looperDefPhase = looperSetPhase,
looperDefFunc = func
}
-- | Simply run loopers
--
-- > runLoopers = runLoopersIgnoreOverrun looperDefFunc
--
-- see 'runLoopersIgnoreOverrun'
--
-- Note that this function will loop forever, you need to wrap it using 'async' yourself.
runLoopers :: MonadUnliftIO m => [LooperDef m] -> m ()
runLoopers = runLoopersIgnoreOverrun looperDefFunc
-- | Run loopers with a custom runner, ignoring any overruns
--
-- > runLoopersIgnoreOverrun = runLoopersRaw (pure ())
--
-- see 'runLoopersRaw'
--
-- Note that this function will loop forever, you need to wrap it using 'async' yourself.
runLoopersIgnoreOverrun ::
(MonadUnliftIO m, MonadUnliftIO n) =>
-- | Custom runner
(LooperDef m -> n ()) ->
-- | Loopers
[LooperDef m] ->
n ()
runLoopersIgnoreOverrun = runLoopersRaw (const $ pure ())
-- | Run loopers, with a custom runner and overrun handler
--
-- * The overrun handler is run when the looper function takes longer than its period.
-- You can use this to log a warning, for example.
--
-- * The runner function is used to run the looper function
-- You can use 'looperDefFunc' @ :: LooperDef m -> m ()@ to run a 'LooperDef', and you
-- can wrap this function in some custom logic before you pass it into 'runLoopersRaw'
-- In this manner you can add logging or metrics, for example.
--
-- Note that this function will loop forever, you need to wrap it using 'async' yourself.
runLoopersRaw ::
(MonadUnliftIO m, MonadUnliftIO n) =>
-- | Overrun handler
(LooperDef m -> n ()) ->
-- | Runner
(LooperDef m -> n ()) ->
-- | Loopers
[LooperDef m] ->
n ()
runLoopersRaw onOverrun runLooper =
mapConcurrently_ $ \ld@LooperDef {..} ->
when looperDefEnabled $ do
waitNominalDiffTime looperDefPhase
let loop = do
start <- liftIO getCurrentTime
runLooper ld
end <- liftIO getCurrentTime
let elapsed = diffUTCTime end start
let nextWait = looperDefPeriod - elapsed
if nextWait < 0
then onOverrun ld
else waitNominalDiffTime nextWait
loop
loop
-- | Wait for a given 'NominalDiffTime'
--
-- This takes care of the conversion to microseconds to pass to 'threadDelay' for you.
--
-- > waitNominalDiffTime ndt = liftIO $ threadDelay $ round (toRational ndt * (1000 * 1000))
waitNominalDiffTime :: MonadIO m => NominalDiffTime -> m ()
waitNominalDiffTime ndt = liftIO $ threadDelay $ round (toRational ndt * (1000 * 1000))