harg: Haskell program configuration using higher kinded data
This is a package candidate release! Here you can preview how this package release will appear once published to the main package index (which can be accomplished via the 'maintain' link below). Please note that once a package has been published to the main package index it cannot be undone! Please consult the package uploading documentation for more information.
Properties
Versions | 0.1.0.0, 0.1.0.1, 0.1.3.0, 0.2.0.0, 0.3.0.0, 0.3.0.0, 0.4.0.0, 0.4.1.0, 0.4.2.0, 0.4.2.1, 0.5.0.0 |
---|---|
Change log | CHANGELOG.md |
Dependencies | aeson (>=1.4.2 && <1.5), barbies (>=1.1.0 && <1.2), base (>=4.7 && <5), bytestring (>=0.10.8 && <0.11), directory (>=1.3.3 && <1.4), higgledy (>=0.3.0 && <0.4), optparse-applicative (>=0.14.3 && <0.15), split (>=0.2.3 && <0.3), text (>=1.2.3 && <1.3), yaml (>=0.11.0 && <0.12) [details] |
License | BSD-3-Clause |
Copyright | Copyright (c) 2019 Alex Peitsinis |
Author | Alex Peitsinis |
Maintainer | alexpeitsinis@gmail.com |
Category | System, CLI, Options, Parsing, HKD |
Home page | https://github.com/alexpeits/harg |
Bug tracker | https://github.com/alexpeits/harg/issues |
Source repo | head: git clone https://github.com/alexpeits/harg |
Uploaded | by alexpeits at 2019-09-16T12:42:17Z |
Modules
[Index] [Quick Jump]
Downloads
- harg-0.3.0.0.tar.gz [browse] (Cabal source package)
- Package description (as included in the package)
Maintainer's Corner
Package maintainers
For package maintainers and hackage trustees
Readme for harg-0.3.0.0
[back to package description]harg
harg
is a library for configuring programs by scanning command line arguments, environment
variables, default values and more. Under the hood, it uses a subset of
optparse-applicative
to expose regular
arguments, switch arguments and subcommands. The library relies heavily on the use of higher kinded
data (HKD) thanks to the barbies
library. Using
higgledy
also allows to have significantly less
boilerplate code.
The main goal while developing harg
was to not have to go through the usual pattern of manually
mappend
ing the results of command line parsing, env vars and defaults.
Usage
tl;dr: Take a look at the example.
Here are some different usage scenarios. Let's first enable some language extensions and add some imports:
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeOperators #-}
import Data.Functor.Identity (Identity (..))
import Data.Kind (Type)
import GHC.Generics (Generic)
import qualified Data.Barbie as B
import Data.Aeson (FromJSON)
import Data.Generic.HKD (HKD, build, construct)
import Options.Harg
main :: IO ()
main = putStrLn "this is a literate haskell file"
One flat (non-nested) datatype
The easiest scenario is when the target configuration type is one single record with no levels of nesting:
data FlatConfig
= FlatConfig
{ _fcDbHost :: String
, _fcDbPort :: Int
, _fcDir :: String
, _fcLog :: Bool -- whether to log or not
}
deriving (Show, Generic)
(The Generic
instance is required for section 3
later on)
Let's first create the Opt
s for each value in FlatConfig
. Opt
is the description for each
component of the configuration.
dbHostOpt :: Opt String
dbHostOpt
= option strParser
( long "host"
. short 'h'
. metavar "DB_HOST"
. help "The database host"
)
dbPortOpt :: Opt Int
dbPortOpt
= option readParser
( long "port"
. help "The database port"
. envVar "DB_PORT"
. defaultVal 5432
)
dirOpt :: Opt String
dirOpt
= argument strParser
( help "Some directory"
. defaultVal "/home/user/something"
)
logOpt :: Opt Bool
logOpt
= switch
( long "log"
. help "Whether to log or not"
)
Here, we use option
to define a command line argument that expects a value after it, argument
to
define a standalone argument, not prefixed by a long or short indicator, and switch
to define a
boolean command line flag that, if present, sets the target value to True
. The opt*
functions
(here applied using &
to make things look more declarative) modify the option configuration.
help
adds help text, defaultVal
adds a default value, short
adds a short command line
option as an alternative to the long one (the string after option
or switch
), envVar
sets
the associated environment variable and metavar
sets the metavariable to be shown in the help
text generated by optparse-applicative
.
The first argument (strParser
or readParser
) is the parser for the argument, be it from the
command line or from an environment variable. The type of this function should be
String -> Either String a
, which produces an error message or the parsed value. strParser
is
equivalent to pure
and always succeeds. readParser
requires the type to have a Read
constraint.
In order to use it with newtypes that wrap a type that has a Read
constraint, using the Functor
instance for Opt
should be sufficient. E.g. for the newtype:
newtype Port = Port Int
we can define the following option:
dbPortOpt' :: Opt Port
dbPortOpt'
= Port <$> dbPortOpt
Of course, any user-defined function works as well. In addition, to use a function of type String -> Maybe a
use parseWith
, which runs the parser and in case of failure uses a default error
message. For example, readParser
is defined as parseWith readMaybe
.
Finally, an optional option with type a
can be specified by setting its type to Maybe a
. The
declaration is exactly the same as it would be for a
, and adding optional
to the modifiers
turns turns the parser from String -> Either String a
to String -> Either String (Maybe a)
but
without using the Read
instance for Maybe
:
someOpt :: Opt (Maybe Int)
someOpt
= option readParser
( long "something"
. optional
)
Note that optional
can't be used with defaultVal
. Using them together raises a type error at
compile time, to ensure there's no ambiguous behaviour (e.g. the order of declaration of modifiers
should not influence the resulting option).
There are 3 ways to configure this datatype.
1. Using a barbie
type
barbie
types are types of kind (Type -> Type) -> Type
. The barbie
type for FlatConfig
looks like this:
data FlatConfigB f
= FlatConfigB
{ _fcDbHostB :: f String
, _fcDbPortB :: f Int
, _fcDirB :: f String
, _fcLogB :: f Bool
}
deriving (Generic, B.FunctorB, B.TraversableB, B.ProductB)
I also derived some required instances that come from the barbies
package. These instances allow
us to change the f
(bmap
from FunctorB
) and traverse all types in the record producing side
effects (btraverse
from TraversableB
).
Now let's define the value of this datatype, which holds our option configuration. The type
constructor needed for the options is Opt
:
flatConfigOpt1 :: FlatConfigB Opt
flatConfigOpt1
= FlatConfigB dbHostOpt dbPortOpt dirOpt logOpt
Because dbHostOpt
, dbPortOpt
and logOpt
all have type Opt <actual type>
, flatConfigOpt1
has the correct type according to FlatConfigB Opt
.
Now to actually run things:
getFlatConfig1 :: IO ()
getFlatConfig1 = do
FlatConfigB host port dir log <- execOptDef flatConfigOpt1
print $ runIdentity (FlatConfig <$> host <*> port <*> dir <*> log)
execOpt
returns an Identity x
where x
is the type of the options we are configuring, in this
case FlatConfigB
. Here, we pattern match on the barbie-type, and then use the Applicative
instance of Identity
to get back an Identity FlatConfig
.
This is still a bit boilerplate-y. Let's look at another way.
2. Using a product type
Looking at FlatConfigB
, it's only used because of it's barbie
-like capabilities. Other than that
it's just a simple product type with the additional f
before all its sub-types.
harg
defines a type almost similar to Product
(from Data.Functor.Product
), which works in a
similar fashion as servant's :<|>
type. This type is defined in Options.Harg.Het.Prod
and is
called :*
(the *
stands for product). This type stores barbie-like types and also keeps the f
handy: data (a :* b) f = a f :* b f
. This is also easily made an instance of Generic
,
FunctorB
, TraversableB
and ProductB
. With all that, let's rewrite the options value and the
function to get the configuration:
flatConfigOpt2 :: (Single String :* Single Int :* Single String :* Single Bool) Opt
flatConfigOpt2
= single dbHostOpt :* single dbPortOpt :* single dirOpt :* single logOpt
getFlatConfig2 :: IO ()
getFlatConfig2 = do
host :* port :* dir :* log <- execOptDef flatConfigOpt2
print $ runIdentity
(FlatConfig <$> getSingle host <*> getSingle port <*> getSingle dir <*> getSingle log)
This looks aufully similar to the previous version, but without having to write another datatype and
derive all the instances. :*
is both a type-level constructor and a value-level function that acts
like list's :
. It is also right-associative, so for example a :* b :* c
is the same as
a :* (b :* c)
.
The Single
type constructor is used when talking about a single value, rather than a nested
datatype. Single a f
is a simple newtype over f a
. The reason for using that is simply to switch
the order of application, so that we can later apply the f
(here Opt
) to the compound type
(:*
). This makes type definitions look more similar to datatype definitions:
type FlatConfigOpt2
= Single String
:* Single Int
:* Single Bool
In addition, single
is used to wrap an f a
into a Single a f
, and getSingle
is used
to unwrap it. Later on we'll see how to construct nested configurations using Nested
.
However, the real value when having flat datatypes comes from the ability to use higgledy
.
3. Using HKD
from higgledy
flatConfigOpt3 :: HKD FlatConfig Opt
flatConfigOpt3
= build @FlatConfig dbHostOpt dbPortOpt dirOpt logOpt
getFlatConfig3 :: IO ()
getFlatConfig3 = do
result <- execOptDef flatConfigOpt3
print $ runIdentity (construct result)
This is the most straightforward way to work with flat configuration types. The build
function
takes as arguments the options (Opt a
where a
is each type in FlatConfig
) in the order they
appear in the datatype, and returns the generic representation of a type that's exactly the same
as FlatConfigB
. This means that we get all the barbie
instances for free.
To go back from the HKD
representation of a datatype to the base one, we use construct
.
construct
uses the applicative instance of the f
which wraps each type in FlatConfig
to give
back an f FlatConfig
(in our case an Identity FlatConfig
).
Nested datatypes
Let's say now that we have these two datatypes:
data DbConfig
= DbConfig
{ _dcHost :: String
, _dcPort :: Int
}
deriving (Show, Generic)
data ServiceConfig
= ServiceConfig
{ _scPort :: Int
, _scLog :: Bool
}
deriving (Show, Generic)
And the datatype to be configured is this:
data Config
= Config
{ _cDb :: DbConfig
, _cService :: ServiceConfig
, _cDir :: String
}
deriving (Show, Generic)
And a new option required for the service port:
portOpt :: Opt Int
portOpt
= option readParser
( long "port"
. help "The service port"
. defaultVal 8080
)
Again, there are several ways to configure these options.
1. Using barbie
types
Since we now have 3 types, there's a bit more boilerplate to write:
data ConfigB f
= ConfigB
{ _cDbB :: DbConfigB f
, _cServiceB :: ServiceConfigB f
, _cDirB :: f String
}
deriving (Generic, B.FunctorB, B.TraversableB, B.ProductB)
data DbConfigB f
= DbConfigB
{ _dcHostB :: f String
, _dcPortB :: f Int
}
deriving (Generic, B.FunctorB, B.TraversableB, B.ProductB)
data ServiceConfigB f
= ServiceConfigB
{ _scPortB :: f Int
, _scLogB :: f Bool
}
deriving (Generic, B.FunctorB, B.TraversableB, B.ProductB)
To define the option parser, we need option parsers for every type inside it. This was true for
flat configs too, but we have to manually construct a DbConfigB Opt
and ServiceConfigB Opt
:
configOpt1 :: ConfigB Opt
configOpt1
= ConfigB dbOpt serviceOpt dirOpt
dbOpt :: DbConfigB Opt
dbOpt
= DbConfigB dbHostOpt dbPortOpt
serviceOpt :: ServiceConfigB Opt
serviceOpt
= ServiceConfigB portOpt logOpt
And to run the parser:
getConfig1 :: IO ()
getConfig1 = do
ConfigB (DbConfigB dbHost dbPort) (ServiceConfigB port log) dir <- execOptDef configOpt1
let
db = DbConfig <$> dbHost <*> dbPort
service = ServiceConfig <$> port <*> log
print $ runIdentity (Config <$> db <*> service <*> dir)
2. Using higgledy
higgledy
puts an f
before every type, so doing something like HKD Config f
doesn't make sense:
looking at ConfigB
it seems like the f
needs to go to the right hand side of the nested types.
We can, however, avoid the boilerplate of defining barbie
types for the nested datatypes:
data ConfigH f
= ConfigH
{ _cDbH :: HKD DbConfig f
, _cServiceH :: HKD ServiceConfig f
, _cDirH :: f String
}
deriving (Generic, B.FunctorB, B.TraversableB, B.ProductB)
configOpt2 :: ConfigH Opt
configOpt2
= ConfigH dbOptH serviceOptH dirOpt
dbOptH :: HKD DbConfig Opt
dbOptH
= build @DbConfig dbHostOpt dbPortOpt
serviceOptH :: HKD ServiceConfig Opt
serviceOptH
= build @ServiceConfig portOpt logOpt
And to run the parser:
getConfig2 :: IO ()
getConfig2 = do
ConfigH db service dir <- execOptDef configOpt2
print $ runIdentity (Config <$> construct db <*> construct service <*> dir)
2. Using products
Recall from previously that there's the Single
type which in general turns f b
into b f
. This
means that, by using Single
for the directory option, all f
s are after their types, so we can
just use :*
instead of having to declare a new datatype:
type ConfigP
= HKD DbConfig
:* HKD ServiceConfig
:* Single String
configOpt3 :: ConfigP Opt
configOpt3
= dbOptH :* serviceOptH :* single dirOpt
getConfig3 :: IO ()
getConfig3 = do
db :* service :* dir <- execOptDef configOpt3
print $ runIdentity (Config <$> construct db <*> construct service <*> getSingle dir)
And, to make things look more orthogonal, harg
defines a type called Nested
, which is exactly
the same as HKD
. There are functions that correspond to build
and construct
, too:
Nested <-> HKD
nested <-> build
getNested <-> construct
This means that the previous code block might as well be:
type ConfigP'
= Nested DbConfig
:* Nested ServiceConfig
:* Single String
configOpt4 :: ConfigP' Opt
configOpt4
= dbOptN :* serviceOptN :* single dirOpt
where
dbOptN
= nested @DbConfig dbHostOpt dbPortOpt
serviceOptN
= nested @ServiceConfig portOpt logOpt
getConfig4 :: IO ()
getConfig4 = do
db :* service :* dir <- execOptDef configOpt4
print $ runIdentity (Config <$> getNested db <*> getNested service <*> getSingle dir)
Pretty cool.
Subcommands
harg
also supports (somewhat limited) subcommands, again by using optparse-applicative
underneath.
Because of limitations with higher kinded data when it comes to sum types, harg
uses a different
way to define subcommands. optparse-applicative
allows defining subcommands that result to the
same type, which means the user needs to define a sum type, and each subcommand results in a
different constructor. In contrast, harg
defines subcommands that can return completely different
types. Instead of the result being a sum type, where the user has to pattern match on constructors,
the result is a Variant
, which is defined (almost) like this:
data Variant (xs :: [Type]) where
Here :: x -> Variant (x ': xs)
There :: Variant xs -> Variant (y ': xs)
Variant
is like a sum type which holds all the summands in a type-level list. Instead of pattern
matching in Left
or Right
like when using Either
, we pattern match on Here x
, There (Here x)
etc. For a pretty thorough introduction to Variant
and more heterogeneous types, check out
this repo by i-am-tom.
x :: Variant '[Int, Bool, Char]
x = There (Here True)
run :: Variant '[Int, Bool, Char] -> Maybe Bool
run (Here _) = Nothing
run (There (Here b)) = Just b
run (There (There (Here _))) = Nothing
-- > run x
-- Just True
harg
defines another kind of variant called VariantF
:
data VariantF (xs :: [(Type -> Type) -> Type]) (f :: Type -> Type) where
to hold a type-level list of barbie
types and the f
to wrap every type with.
To define a type to be used in a subcommand parser we need the target type and the subcommand name,
which is encoded as a type-level string Symbol
. There's a handy way to define this. Suppose that
the the Config
type above is the configuration type when the command is app
and another type,
e.g.TestConfig
is the configuration when the command is test
:
data TestConfig
= TestConfig
{ _tcFoo :: String
, _tcBar :: Int
}
deriving Show
fooOpt :: Opt String
fooOpt
= option strParser
( short 'f'
. help "Something foo"
. defaultVal "this is the default foo"
)
barOpt :: Opt Int
barOpt
= option readParser
( short 'b'
. help "Something bar"
. defaultVal 42
)
type TestConfigP
= Single String :* Single Int
testConfigOpt :: TestConfigP Opt
testConfigOpt
= single fooOpt :* single barOpt
The subcommand type looks like this:
type SubcommandConfig
= "app" :-> ConfigP'
:+ "test" :-> TestConfigP
The +
here stands for sum. The associated option type is:
subcommandOpt :: SubcommandConfig Opt
subcommandOpt
= configOpt4 :+ testConfigOpt :+ ANil
The ANil
here marks the end of the association list (which is a heterogeneous list that associates
symbols with types).
Here's how to run this parser:
getSubcommand :: IO ()
getSubcommand = do
result <- execCommandsDef subcommandOpt
case result of
HereF (db :* service :* dir)
-> print $ runIdentity
$ Config <$> getNested db <*> getNested service <*> getSingle dir
ThereF (HereF (foo :* bar))
-> print $ runIdentity
$ TestConfig <$> getSingle foo <*> getSingle bar
Or use fromVariantF
, which is similar to the either
function:
getSubcommand' :: IO ()
getSubcommand' = do
result <- execCommandsDef subcommandOpt
fromVariantF result
(\(db :* service :* dir)
-> print $ runIdentity
$ Config <$> getNested db <*> getNested service <*> getSingle dir
)
(\(foo :* bar)
-> print $ runIdentity
$ TestConfig <$> getSingle foo <*> getSingle bar
)
The type of fromVariantF
can be thought of as being:
fromVariantF
:: VariantF '[a, b, c, ...] f
-> (a f -> r)
-> (b f -> r)
-> (c f -> r)
-> ...
-> r
The signature will accept the appropriate number of functions depending on the length of the type level list.
More than just environment variables
You may have noticed the use of execOptDef
and execCommandsDef
in all of the examples up to now.
There are actually more configurable versions of these, called execOpt
and execCommands
respectively. With these functions the user can select where to get options from. For example,
execOptDef
is a shorthand for execOpt EnvSource
, which means that options will be fetched from
environment variables only (along with the command line, which is always required, and defaults,
which can be optionally provided by the user).
The sources currently supported are environment variables, json and yaml files.
Configuring using a json file
First of all, let's use FlatConfig
from the first example:
data FlatConfig
= FlatConfig
{ _fcDbHost :: String
, _fcDbPort :: Int
, _fcDir :: String
, _fcLog :: Bool -- whether to log or not
}
deriving (Show, Generic)
dbHostOpt :: Opt String
dbHostOpt
= option strParser
( long "host"
. short 'h'
. metavar "DB_HOST"
. help "The database host"
)
dbPortOpt :: Opt Int
dbPortOpt
= option readParser
( long "port"
. help "The database port"
. envVar "DB_PORT"
. defaultVal 5432
)
dirOpt :: Opt String
dirOpt
= argument strParser
( help "Some directory"
. defaultVal "/home/user/something"
)
logOpt :: Opt Bool
logOpt
= switch
( long "log"
. help "Whether to log or not"
)
flatConfigOpt3 :: HKD FlatConfig Opt
flatConfigOpt3
= build @FlatConfig dbHostOpt dbPortOpt dirOpt logOpt
To use the JSON source, a FromJSON
instance is required. Thankfully that's easy, since
FlatConfig
has Generic
instance:
instance FromJSON FlatConfig
In harg
, sources are defined as products (using :*
) of options, which means that the definition
of the sources is not very different than defining options! If we only needed the environment
variable source, the options would be:
envSource :: EnvSource Opt
envSource = EnvSource
There's no need to actually define an option for the environment because there's no meaningful
configuration for this. To use the EnvSource
along with a json config, we use the following
option:
sourceOpt :: (EnvSource :* JSONSource) Opt
sourceOpt
= EnvSource :* JSONSource jsonOpt
where
jsonOpt :: Opt ConfigFile
jsonOpt
= option strParser
( long "json"
. short 'j'
. help "JSON config filepath"
)
Here, the type of the option for the JSON source is ConfigFile
. This type is a wrapper around
FilePath
, which looks like this:
data ConfigFile
= ConfigFile FilePath
| NoConfigFile
This has the advantage that, if the user wants to specify an optional configuration file, they can simply say:
jsonOpt :: Opt ConfigFile
jsonOpt
= option strParser
( long "json"
. defaultVal NoConfigFile
)
Also, because ConfigFile
has an IsString
instance, there's no need to say
long (ConfigFile "json")
(if OverloadedStrings
is enabled).
There's a bit of a disconnect between ConfigFile
and the ability to make optional options using
Maybe
and optional
. The reason for it is that the type that JSONSource
wraps is not
polymorphic, since it needs to be a filepath specifically.
Roadmap
- Better errors using
optparse-applicative
's internals - Allow user to pass
optparse-applicative
preferences - Write tests
Be able to provide and get back the same type for multiple subcommandsIntegrate config files (e.g. JSON using aeson)