The CFG API for Kotlin / Java¶
The CFG reference implementation for the Java Virtual Machine (JVM) is written in the
Kotlin programming language. The implementation assumes a
JVM version of 8 or later. The Kotlin version tested with is 1.3.61 or later. The jar
is usually named config-X.Y.Z.jar
where X.Y.Z
is the version number.
Installation¶
You can install the jar from the Maven repository at https://dl.bintray.com/reddove/public and configure Maven, Gradle etc. appropriately. The jar will be located at
https://repo1.maven.org/maven2/com/red-dove/config/X.Y.Z/config-X.Y.Z.jar
There’s a minimal example of a program and project that uses CFG here.
Exploration¶
To explore CFG functionality for the JVM, we use the Kotlin compiler in interactive mode to act as a Read-Eval-Print-Loop (REPL). Once installed, you can invoke a shell using
$ export CP=config-X.Y.Z.jar:commons-math3-3.6.1.jar:kotlin-reflect-1.3.61.jar:kotlin-stdlib-1.3.61.jar
$ kotlinc-jvm -cp $CP
The additional jar files are runtime dependencies of this JVM implmentation.
Getting Started with CFG in Kotlin / Java¶
A configuration is represented by an instance of the Config
class.
The constructor for this class can be passed a filename or a stream which
contains the text for the configuration. The text is read in, parsed and
converted to an object that you can then query. A simple example:
a: 'Hello, '
b: 'world!'
c: {
d: 'e'
}
'f.g': 'h'
christmas_morning: `2019-12-25 08:39:49`
today: `java.time.LocalDate:now`
output: `java.lang.System:out`
home: `$HOME`
foo: `$FOO|bar`
Loading a configuration¶
The configuration above can be loaded as shown below. In the REPL shell:
>>> import com.reddove.config.*
>>> val cfg = Config("path/to/test0.cfg")
Access elements with keys¶
Accessing elements of the configuration with a simple key is just like using a
HashMap<String, Any>
:
>>> cfg["a"]
res3: kotlin.Any? = Hello,
>>> cfg["b"]
res4: kotlin.Any? = world!
Access elements with paths¶
As well as simple keys, elements can also be accessed using path strings:
>>> cfg["c.d"]
res5: kotlin.Any = e
Here, the desired value is obtained in a single step, by (under the hood)
walking the path c.d
– first getting the mapping at key c
, and then
the value at d
in the resulting mapping.
Note that you can have simple keys which look like paths:
>>> cfg["f.g"]
res6: kotlin.Any = h
If a key is given that exists in the configuration, it is used as such, and if
it is not present in the configuration, an attempt is made to interpret it as
a path. Thus, f.g
is present and accessed via key, whereas c.d
is not
an existing key, so is interpreted as a path.
Access to date/time objects¶
You can also get native Java java.time.LocalDate
and
java.time.OffsetDateTime
objects from a configuration, by using an ISO
date/time pattern in a backtick-string:
>>> cfg["christmas_morning"]
res7: kotlin.Any = 2019-12-25T08:39:49
>>> cfg["christmas_morning"] as java.time.LocalDateTime
res8: java.time.LocalDateTime = 2019-12-25T08:39:49
The last statement shows that the returned object is actually an instance of
java.time.LocalDateTime
.
Access to other JVM objects¶
Access to other JVM objects is also possible using the backtick-string syntax, provided that they are one of:
- Environment variables
- Public static fields of public classes
- Public static methods without parameters of public classes
>>> import java.time.LocalDate
>>> cfg["today"] == LocalDate.now()
res9: kotlin.Boolean = true
>>> import java.lang.System
>>> cfg["output"] === System.out
res10: kotlin.Boolean = true
Accessing the “today” element of the above configuration invokes the static
method java.time.LocalDate.now()
and returns its value.
Access to environment variables¶
To access an environment variable, use a backtick-string of the form
`$VARNAME`
:
>>> import java.lang.System
>>> cfg["home"] == System.getenv("HOME")
res11: kotlin.Boolean = true
You can specify a default value to be used if an environment variable isn’t
present using the `$VARNAME|default-value`
form. Whatever string follows
the pipe character (including the empty string) is returned if VARNAME
is
not a variable in the environment.
>>> cfg["foo"]
res12: kotlin.Any = bar
Access to computed values¶
Sometimes, it’s useful to have values computed declaratively in the configuration, rather than imperatively in the code that processes the configuration. For example, an overall time period may be specified and other configuration values are fractions thereof. It may also be desirable to perform other simple calculations declaratively, e.g. concatenation of numerous file names to a base directory to get a final pathname.
total_period : 100
header_time: 0.3 * ${total_period}
steady_time: 0.5 * ${total_period}
trailer_time: 0.2 * ${total_period}
base_prefix: '/my/app/'
log_file: ${base_prefix} + 'test.log'
When this file is read in, the computed values can be accessed directly:
>>> import com.reddove.config.*
>>> val cfg = Config("test0a.cfg")
>>> cfg["header_time"]
res3: kotlin.Any = 30.0
>>> cfg["steady_time"]
res4: kotlin.Any = 50.0
>>> cfg["trailer_time"]
res5: kotlin.Any = 20.0
>>> cfg["log_file"]
res6: kotlin.Any = /my/app/test.log
Including one configuration inside another¶
There are times when it’s useful to include one configuration inside another. For example, consider the following configuration files:
layouts: {
brief: {
class: `org.apache.logging.log4j.core.layout.PatternLayout`,
pattern: '%d [%t] %p %c - %m%n'
}
},
appenders: {
file: {
level: 'INFO',
class: `org.apache.logging.log4j.core.appender.FileAppender`,
layout: 'brief',
filename: 'run/server.log',
append: false,
charset: 'UTF-8'
},
error: {
level: 'ERROR',
class: `org.apache.logging.log4j.core.appender.FileAppender`,
layout: 'brief',
filename: 'run/server-errors.log',
append: false,
charset: 'UTF-8'
},
debug: {
level: 'DEBUG',
class: `org.apache.logging.log4j.core.appender.FileAppender`,
layout: 'brief',
filename: 'run/server-debug.log',
append: false,
charset: 'UTF-8'
}
},
loggers: {
mylib: {
level: 'INFO'
}
'mylib.detail': {
level: 'DEBUG'
}
},
root: {
handlers: ['file', 'error', 'debug'],
level: 'WARNING'
}
cookies: {
url: 'http://www.allaboutcookies.org/',
permanent: false
},
freeotp: {
url: 'https://freeotp.github.io/',
permanent: false
},
'google-auth': {
url: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2',
permanent: false
}
secret: 'some random application secret',
port: 8000,
sitename: 'My Test Site',
default_access: 'public',
ignore_trailing_slashes: true,
site_options: {
want_ipinfo: false,
show_form: true,
cookie_bar: true
},
debug: true,
captcha_length: 4,
captcha_timeout: 5,
session_timeout: 7 * 24 * 60 * 60, # 7 days in seconds
redirects: @'redirects.cfg',
email: {
sender: 'no-reply@my-domain.com',
host: 'smtp.my-domain.com:587',
user: 'smtp-user',
password: 'smtp-pwd'
}
logging: @'logging.cfg'
The main.cfg
contents have been kept to the highest-level values,
within logging and redirection configuration relegated to other files
logging.cfg
and redirects.cfg
which are then included in
main.cfg
. This allows the high-level configuration to be more readable
at a glance, and even allows the separate configuration files to be e.g.
maintained by different people.
The contents of the sub-configurations are easily accessible from the main configuration just as if they had been defined in the same file:
>>> import com.reddove.config.*
>>> val cfg = Config("main.cfg")
>>> cfg["logging.layouts.brief.class"]
res3: kotlin.Any = class org.apache.logging.log4j.core.layout.PatternLayout
>>> cfg["logging.appenders.file.class"]
res4: kotlin.Any = class org.apache.logging.log4j.core.appender.FileAppender
>>> cfg["redirects.freeotp.url"]
res5: kotlin.Any = https://freeotp.github.io/
>>> cfg["redirects.freeotp.permanent"]
res6: kotlin.Any = false
Avoiding unnecessary repetition¶
Don’t Repeat Yourself (DRY) is a useful principle to follow. CFG can help
with this. You may have noticed that the logging.cfg
file above has
some repetitive elements:
appenders: {
file: {
level: 'INFO',
class: `org.apache.logging.log4j.core.appender.FileAppender`,
layout: 'brief',
filename: 'run/server.log',
append: false,
charset: 'UTF-8'
},
error: {
level: 'ERROR',
class: `org.apache.logging.log4j.core.appender.FileAppender`,
layout: 'brief',
filename: 'run/server-errors.log',
append: false,
charset: 'UTF-8'
},
debug: {
level: 'DEBUG',
class: `org.apache.logging.log4j.core.appender.FileAppender`,
layout: 'brief',
filename: 'run/server-debug.log',
append: false,
charset: 'UTF-8'
}
},
This portion could be rewritten as:
defs: {
base_appender: {
class: `org.apache.logging.log4j.core.appender.FileAppender`,
layout: 'brief',
append: false,
charset: 'UTF-8'
}
},
appenders: {
file: ${defs.base_appender} + {
level: 'INFO',
filename: 'run/server.log',
append: true,
},
error: ${defs.base_appender} + {
level: 'ERROR',
filename: 'run/server-errors.log',
},
debug: ${defs.base_appender} + {
level: 'DEBUG',
filename: 'run/server-debug.log',
}
},
where the common elements are separated out and just referenced where they are
needed. We find it useful to put all things which will be reused like this in
one place in the condiguration, so we always know where to go to make changes.
The key used is conventionally defs
or base
, though it can be anything
you like.
Access is just as before, and provides the same results:
>>> import com.reddove.config.*
>>> val cfg = Config("main.cfg")
>>> cfg["logging.appenders.file.append"]
res3: kotlin.Any = true
>>> cfg["logging.appenders.file.filename"]
res4: kotlin.Any = run/server.log
>>> cfg["logging.appenders.file.level"]
res5: kotlin.Any = INFO
>>> cfg["logging.appenders.debug.level"]
res6: kotlin.Any = DEBUG
>>> cfg["logging.appenders.debug.filename"]
res7: kotlin.Any = run/server-debug.log
>>> cfg["logging.appenders.debug.append"]
res8: kotlin.Any = false
The definition of logging.appenders.file
as ${defs.base_appender} + {
level: 'INFO', filename: 'run/server.log', append: true }
has resulted in an
evaluation which first fetches the defs.base_appender
value, which is a
mapping, and “adds” to that the literal mapping which defines the level
,
filename
and append
keys. The +
operation for mappings is
implemented as a copy of the left-hand side merged with the right-hand side.
Note that the append
value for logging.appenders.file
is overridden by
the right-hand side to true
, whereas that for e.g.
logging.appenders.error
is unchanged as false
.
We could do some further refinement by factoring out the common location for the log files:
defs: {
base_appender: {
class: `org.apache.logging.log4j.core.appender.FileAppender`,
layout: 'brief',
append: false,
charset: 'UTF-8'
}
log_prefix: 'run/',
},
appenders: {
file: ${defs.base_appender} + {
level: 'INFO',
filename: ${defs.log_prefix} + 'server.log',
append: true,
},
error: ${defs.base_appender} + {
level: 'ERROR',
filename: ${defs.log_prefix} + 'server-errors.log',
},
debug: ${defs.base_appender} + {
level: 'DEBUG',
filename: ${defs.log_prefix} + 'server-debug.log',
}
}
with the same result as before. It is slightly more verbose than before, but the location of all files can be changed in just one place now, as opposed to three, as it was before.
Classes¶
The Config
class¶
This class implements a CFG configuration. You’ll generally interface to CFG
files using this class. When you pass in a stream or file path to the
constructor or the Config.load
/ Config.loadFile
methods, the
CFG source in the stream or file is parsed and converted into an internal form
that can be queried.
-
class
Config
¶ Variables
-
var
noDuplicates
: Boolean= true
¶ Whether duplicates keys are allowed when parsing a mapping or mapping body. If not allowed and duplicates are seen, a
ConfigException
is thrown.
-
var
strictConversions
= true
¶ If
true
, aConfigException
is thrown if a string can’t be converted. It’s intended to help catch typos in backtick-strings.
-
var
context
: Map<String, Any>= hashMapOf()
¶ A mapping within which to look up identifiers during evaluation of AST nodes.
-
var
includePath
: ArrayList<String>= arrayListOf()
¶ A list of directories which is searched for included sub-configurations if the parent directory of
path
(or the current directory, if path isn’t set) doesn’t contain them.
-
var
path
: String= null
¶ The path to the file from which the configuration has been read. You won’t usually need to set this, unless you want to load a configuration from text that references included files. In that case, set the path to a value whose parent directory contains the included files, or specify relevant directories using
includePath
. When a configuration is loaded from a file, this attribute is automatically set.
Constructors
-
fun
Config
()¶ Constructs an instance with no actual configuration loaded. A call to
load
orloadFile
would be needed to actually make a usable instance.
-
fun
Config
(reader: java.io.Reader)¶ Construct this instance with the configuration read from the provided reader.
-
fun
Config
(path: String)¶ Construct this instance with the configuration read from a file named by the provided path.
Methods
-
fun
load
(reader: java.io.Reader)¶ Load this instance with the configuration read from the provided reader.
Parameters: - reader – A
java.io.Reader
instance for the configuration source.
Throws: ConfigException
– If there is an I/O error when reading the source or an error in the configuration syntax.- reader – A
-
fun
loadFile
(path: String)¶ Load this instance with the configuration read from the file at the provided path.
Parameters: - path – A String specifying the location of the file which contains the configuration.
Throws: ConfigException
– If there is an I/O error when reading the source or an error in the configuration syntax.
-
fun
get
(key: String) : Any¶ This method implements the key access operator, e.g.
mapping[key]
. The value of key can be a simple key (identifier) or a valid path string.Parameters: - key – A string describing a simple key or a path in the configuration.
Throws: InvalidPathException
– If key is not present in the data and it cannot be parsed as a path.BadIndexException
– If, while traversing a path, a key or index is not of the appropriate type for its container, or if it isn’t in the required range.ConfigException
– If a key is not found or some other semantic error occurs (for example, an operation involving incompatible types).
-
fun
get
(key: String, defaultValue: Any) : Any¶ This works similarly to the method above, except that if a key isn’t found, the
defaultValue
is returned.Parameters: - key – A string describing a simple key or a path in the configuration.
- defaultValue – A value to return if the key or path is not available in a configuration.
Throws: InvalidPathException
– If key is not present in the data and it cannot be parsed as a path.BadIndexException
– If, while traversing a path, a key or index is not of the appropriate type for its container, or if it isn’t in the required range.ConfigException
– If some other semantic error occurs (for example, an operation involving incompatible types).
-