The CFG API for .NET

The CFG reference implementation for the .NET Common Language Runtime (CLR) is written in C#. The implementation assumes a recent version of .NET - either .NET Framework >= 4.8 or .NET Core >= 3.0.0. It’s implemented using the namespace RedDove.Config and packaged in RedDove.Config.dll.

Installation

The library can be installed using nuget and the package name RedDove.Config.

There’s a minimal example of a program and project that uses CFG here.

Exploration

To explore CFG functionality for .NET, we use the dotnet-script Read-Eval-Print-Loop (REPL), which is available from here. Once installed, you can invoke a shell using

$ dotnet dotnet-script

Getting Started with CFG in C#

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:

test0.cfg
a: 'Hello, '
b: 'world!'
c: {
  d: 'e'
}
'f.g': 'h'
christmas_morning: `2019-12-25 08:39:49`
today: `mscorlib,System.DateTime:Today`
access: `mscorlib,System.IO.FileAccess:ReadWrite`
home: `$HOME`
foo: `$FOO|bar`

Loading a configuration

The configuration above can be loaded as shown below. In the REPL shell:

> #r "RedDove.Config.dll"
> using RedDove.Config;
> var cfg = new Config("test0a.cfg");
> cfg["a"]
"Hello, "
> cfg["b"]
"world!"

Access elements with keys

Accessing elements of the configuration with a simple key is just like using a Dictionary<string, object>:

> cfg["a"]
"Hello, "
> cfg["b"]
"world!"

Access elements with paths

As well as simple keys, elements can also be accessed using path strings:

> cfg["c.d"]
"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"]
"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 CLR System.DateTime and System.DateTimeOffset objects from a configuration, by using an ISO date/time pattern in a backtick-string:

> cfg["christmas_morning"]
[25/12/2019 08:39:49]

Access to other CLR objects

Access to other CLR objects is also possible using the backtick-string syntax, provided that they are either environment values or objects accessible via public static fields, properties or methods which take no arguments:

> cfg["access"]
ReadWrite
> cfg["today"]
[15/01/2020 00:00:00]

Accessing the “access” element of the above configuration accesses the named value in the System.IO.FileAccess enumeration.

Accessing the “today” element of the above configuration invokes the static method System.DateTime.Today() and returns its value.

Access to environment variables

To access an environment variable, use a backtick-string of the form `$VARNAME`:

> cfg["home"].Equals(Environment.GetEnvironmentVariable("HOME"))
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"]
"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.

test0a.cfg
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:

> cfg["header_time"]
30
> cfg["steady_time"]
50
> cfg["trailer_time"]
20
> cfg["log_file"]
"/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:

logging.cfg
layouts: {
  brief: {
    class: `log4net,log4net.Layout.PatternLayout`,
    pattern: '%d [%t] %p %c - %m%n'
  }
},
appenders: {
  file: {
    class: `log4net,log4net.Appender.FileAppender`,
    layout: 'brief',
    append: false,
    charset: 'UTF-8'
    level: 'INFO',
    filename: 'run/server.log',
    append: true,
  },
  error: {
    class: `log4net,log4net.Appender.FileAppender`,
    layout: 'brief',
    append: false,
    charset: 'UTF-8'
    level: 'ERROR',
    filename: 'run/server-errors.log',
  },
  debug: {
    class: `log4net,log4net.Appender.FileAppender`,
    layout: 'brief',
    append: false,
    charset: 'UTF-8'
    level: 'DEBUG',
    filename: 'run/server-debug.log',
  }
}
loggers: {
  mylib: {
    level: 'INFO'
  }
  'mylib.detail': {
    level: 'DEBUG'
  }
},
root: {
  handlers: ['file', 'error', 'debug'],
  level: 'WARNING'
}
redirects.cfg
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
}
main.cfg
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
},
connection: 'postgres+pool://db_user:db_pwd@localhost:5432/db_name',
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:

$ dotnet dotnet-script
> #r "RedDove.Config.dll"
> #r "log4net.dll"
> using RedDove.Config;
> var cfg = new Config("main.cfg");
> cfg["logging.appenders.file.class"]
[log4net.Appender.FileAppender]

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:

logging.cfg (partial)
appenders: {
  file: {
    class: `log4net,log4net.Appender.FileAppender`,
    layout: 'brief',
    append: false,
    charset: 'UTF-8'
    level: 'INFO',
    filename: 'run/server.log',
    append: true,
  },
  error: {
    class: `log4net,log4net.Appender.FileAppender`,
    layout: 'brief',
    append: false,
    charset: 'UTF-8'
    level: 'ERROR',
    filename: 'run/server-errors.log',
  },
  debug: {
    class: `log4net,log4net.Appender.FileAppender`,
    layout: 'brief',
    append: false,
    charset: 'UTF-8'
    level: 'DEBUG',
    filename: 'run/server-debug.log',
  }
}

This portion could be rewritten as:

logging.cfg (partial)
defs: {
  base_appender: {
    class: `log4net,log4net.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:

$ dotnet dotnet-script
> #r "RedDove.Config.dll"
> #r "log4net.dll"
> using RedDove.Config;
> var cfg = new Config("main.cfg");
> cfg["logging.appenders.file.class"]
[log4net.Appender.FileAppender]
> cfg["logging.appenders.file.level"]
"INFO"
> cfg["logging.appenders.file.layout"]
"brief"
> cfg["logging.appenders.file.append"]
true
> cfg["logging.appenders.file.filename"]
"run/server.log"
> cfg["logging.appenders.error.append"]
false
> cfg["logging.appenders.error.filename"]
"run/server-errors.log"

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:

logging.cfg (partial)
defs: {
  base_appender: {
    class: `log4net,log4net.Appender.FileAppender`,
    layout: 'brief',
    append: false,
    charset: 'UTF-8'
  }
  log_prefix: 'run/',
},
layouts: {
  brief: {
    class: `log4net,log4net.Layout.PatternLayout`,
    pattern: '%d [%t] %p %c - %m%n'
  }
},
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.

Namespaces

Everything is defined in the RedDove.Config namespace.

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 path to the constructor or the Config.Load / Config.LoadFile methods, the CFG source in the stream is parsed and converted into an internal form using Abstract Syntax Tree (AST) nodes.

class Config

Properties

Boolean NoDuplicates { get; set; }

Whether this instance allows duplicate keys. If it doesn’t, later values with the same key overwrite earlier values. If it does, a ConfigException is raised.

Boolean StrictConversions { get; set; }

Whether string conversions by this instance are strict. If not strict, a string which cannot be converted is returned as is. If strict, a string which cannot be converted will cause a ConfigException to be thrown.

IDictionary<String, Object> Context { get; set; }

A mapping within which to look up identifiers during evaluation of AST nodes.

Boolean Cached { get; set; }

If true, an internal cache is used. The default is false.

String Path { get; set; }

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.

IList<String> IncludePath { get; set; }

A list of directories which is searched for included sub-configurations if the parent directory of Path (or the current directory, if that isn’t set) doesn’t contain them.

Constructors

Config ()

Constructs an instance with no actual configuration loaded. A call to Load or LoadFile would be needed to actually make a usable instance.

Config (TextReader reader)

Construct this instance with the configuration read from the provided reader.

Config (String path)

Construct this instance with the configuration read from a file named by the provided path.

Methods

void Load (TextReader reader)

Load this instance with the configuration read from the provided reader.

Parameters:reader – A TextReader 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.
void LoadFile (String path)

Load this instance with the configuration read from the file at the provided path. The Path property is set from 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.
Object Get (String key)

This method implements the key access operator. 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.

Returns:

The object found at key.

Throws:
  • InvalidPathException – If there is an error trying to interpret a non-existent key as a path.
  • ConfigException – If a key isn’t found.
Object Get (String key, Object defaultValue)

This method implements the key access operator. The value of key can be a simple key (identifier) or a valid path string. If a key isn’t found, defaultValue is returned.

Parameters:
  • key – A string describing a simple key or a path in the configuration.
  • defaultValue – The default value to use if a key isn’t present in the configuration.
Returns:

The object found at key, or defaultValue.

Object this[String key] { get; }

This acts the same way as the Get method without a default value.

Returns:

The object found at key.

Throws:
  • InvalidPathException – If there is an error trying to interpret a non-existent key as a path.
  • ConfigException – If a key isn’t found.