The CFG API for Elixir¶
The CFG reference implementation for the Elixir language assumes an Elixir version of 1.9.1 or later.
Installation¶
You can install the library for use by adding cfg_lib to your list of dependencies in mix.exs:
def deps do
[
{:cfg_lib, "~> 0.1.0"}
]
end
There’s a minimal example of a program that uses CFG here.
Exploration¶
To explore CFG functionality for Elixir, we use the iex
program, which is a
Read-Eval-Print-Loop (REPL). You can invoke a shell using
$ iex -S mix
This loads all the dependencies into the shell.
Getting Started with CFG in Elixir¶
A configuration is accessed through the CFG.Config
module. This has functions
which can be passed a filename or a string 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`
now: `Elixir.DateTime:utc_now`
home: `$HOME`
foo: `$FOO|bar`
Loading a configuration¶
The configuration above can be loaded as shown below. In the REPL shell:
iex(1)> alias CFG.Config
CFG.Config
iex(2)> {:ok, cfg} = Config.from_file("test0.cfg")
{:ok, #PID<0.218.0>}
Access elements with keys¶
Accessing elements of the configuration with a simple key is just like using a
Hash
:
iex(3)> Config.get(cfg, "a")
{:ok, "Hello, "}
iex(4)> Config.get(cfg, "b")
{:ok, "world!"}
Access elements with paths¶
As well as simple keys, elements can also be accessed using path strings:
iex(5)> Config.get(cfg, "c.d")
{:ok, "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:
iex(6)> Config.get(cfg, "f.g")
{:ok, "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 Elixir date/time objects from a configuration, by using an ISO date/time pattern in a backtick-string:
iex(7)> Config.get(cfg, "christmas_morning")
{:ok, ~U[2019-12-25 08:39:49.000000Z]}
Access to other Elixir objects¶
Access to other Elixir objects is also possible using the backtick-string syntax, provided that they are one of:
- Environment variables
- Public functions in Erlang or Elixir
iex(8)> {:ok, dt} = Config.get(cfg, "now")
{:ok, ~U[2021-10-16 12:37:37.781391Z]}
iex(9)> DateTime.diff(DateTime.utc_now, dt)
6
Accessing the “now” element of the above configuration invokes the
DateTime.utc_now
function and returns its value. You can see from the subtraction
that the times are practically equivalent (the difference is the time between the two
calls to DateTime.utc_now
- once when computing the configured value, and once in
the interactive session.
Access to environment variables¶
To access an environment variable, use a backtick-string of the form
`$VARNAME`
:
iex(10)> elem(Config.get(cfg, "home"), 1) == System.get_env("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.
iex(11)> Config.get(cfg, "foo")
{:ok, "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:
iex(12)> {:ok, cfg} = Config.from_file("test0a.cfg")
{:ok, #PID<0.218.0>}
iex(13)> Config.get(cfg, "header_time")
{:ok, 30.0}
iex(14)> Config.get(cfg, "steady_time")
{:ok, 50.0}
iex(15)> Config.get(cfg, "trailer_time")
{:ok, 20.0}
iex(16)> Config.get(cfg, "log_file")
{:ok, "/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: {
pattern: '%d [%t] %p %c - %m%n'
}
},
appenders: {
file: {
level: 'INFO',
layout: 'brief',
filename: 'run/server.log',
append: false,
charset: 'UTF-8'
},
error: {
level: 'ERROR',
layout: 'brief',
filename: 'run/server-errors.log',
append: false,
charset: 'UTF-8'
},
debug: {
level: 'DEBUG',
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:
iex(17)> {:ok, cfg}= Config.from_file("main.cfg")
{:ok, #PID<0.218.0>}
iex(18)> Config.get(cfg, "logging.layouts.brief.pattern")
{:ok, "%d [%t] %p %c - %m%n"}
iex(19)> Config.get(cfg, "redirects.freeotp.url")
{:ok, "https://freeotp.github.io/"}
iex(20)> Config.get(cfg, "redirects.freeotp.permanent")
{:ok, 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',
layout: 'brief',
filename: 'run/server.log',
append: true,
charset: 'UTF-8'
},
error: {
level: 'ERROR',
layout: 'brief',
filename: 'run/server-errors.log',
append: false,
charset: 'UTF-8'
},
debug: {
level: 'DEBUG',
layout: 'brief',
filename: 'run/server-debug.log',
append: false,
charset: 'UTF-8'
}
},
This portion could be rewritten as:
defs: {
base_appender: {
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:
iex(21)> Config.get(cfg, "logging.appenders.file.append")
{:ok, true}
iex(22)> Config.get(cfg, "logging.appenders.file.filename")
{:ok, "run/server.log"}
iex(23)> Config.get(cfg, "logging.appenders.file.level")
{:ok, "INFO"}
iex(24)> Config.get(cfg, "logging.appenders.debug.level")
{:ok, "DEBUG"}
iex(25)> Config.get(cfg, "logging.appenders.debug.filename")
{:ok, "run/server-debug.log"}
iex(26)> Config.get(cfg, "logging.appenders.debug.append")
{:ok, 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: {
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.
Modules¶
The CFG.Config
module implements code to model a CFG configuration. You’ll
generally interface to CFG files using this module. When you pass in a source string
or file path to the from_source
/ from_file
functions, the CFG source in
the source string or file is parsed and converted into an internal form that can be
queried. The CFG.RecognizerError
module represents errors returned from the API.
The CFG.Location
module represents a location in CFG source code.
CFG.Location¶
-
CFG.Location
This represents a location in CFG source.
Struct Members
-
line
: pos_integer = 1
The source line.
-
column
: non_negative_integer = 1
The source column. Newlines end with a zero column; the first character in the next line would be at column 1.
-
CFG.RecognizerError¶
-
CFG.RecognizerError
This represents an error which occurred when processing CFG.
Struct Members
-
reason
: atom
An indication of what the error is. The following values are currently in use:
:invalid_escape
- an invalid escape sequence was detected in a string.:unterminated_backtick
- a backtick-string is unterminated.:newlines_not_allowed
- newlines aren’t allowed in strings other than multi-line strings.:unterminated_string
- a quoted string is unterminated.:bad_number
- a number is badly formed.:bad_octal_constant
- a number which looks like an octal constant is badly formed.:unexpected_char
- an unexpected character was encountered.:unexpected_token
- an unexpected token was encountered.:unexpected_token_for_value
- an unexpected token was encountered when looking for a value.:unexpected_token_for_atom
- an unexpected token was encountered when looking for an atomic value.:bad_key_value_separator
- a bad key/value separator was encountered.:unexpected_for_key
- an unexpected token was encountered when looking for a key in a mapping.:unexpected_token_for_container
- an unexpected token was encountered when parsing a container.:text_after_container
- there is trailing text following text for a valid container.:invalid_index
- an array or slice index is invalid.:unexpected_token_for_expression
- an unexpected token was encountered when looking for an expression.:must_be_mapping
- a top-level configuration must be a mapping.:invalid_path
- a CFG path is invalid.:invalid_path_extra
- there is text following what looks like a valid CFG path.:no_configuration
- no configuration has been loaded.:not_found
- the specified key or path was not found in this configuration.:invalid_step
- an invalid step (zero) was specified.:unexpected_path_start
- a CFG path doesn’t begin as expected (with an identifier).:cannot_evaluate
- an expression cannot be evaluated.:string_expected
- a string was expected, but not found.:include_not_found
- an included configuration was not found.:cannot_add
- an addition cannot be performed.:cannot_negate
- a negation cannot be performed.:cannot_subtract
- a subtraction cannot be performed.:cannot_multiply
- a multiplication cannot be performed.:cannot_divide
- a division cannot be performed.:cannot_integer_divide
- an integer division cannot be performed.:cannot_compute_modulo
- a modulo operation cannot be performed.:cannot_left_shift
- a left shift cannot be performed.:cannot_right_shift
- a right shift cannot be performed.:cannot_raise_to_power
- raise to power operation cannot be performed.:cannot_bitwise_or
- a bitwise-or operation cannot be performed.:cannot_bitwise_and
- a bitwise-and operation cannot be performed.:cannot_bitwise_xor
- a bitwise-xor operation cannot be performed.:unknown_variable
- a variable is undefined or no context was provided.:conversion_failure
- a string conversion operation cannot be performed.:circular_reference
- a circular reference was detected when resolving references.:not_implemented
- a feature is not implemented.
-
location
: nil | Location
The optional location of the error in the source. Some errors may have no location, such as
:no_configuration
.
-
detail
: any
Optional additional information about the error. For example, an
:invalid_index
error would indicate the invalid index value in the detail.
-
CFG.Config¶
-
CFG.Config
This represents a single configuration (which may contain nested configurations).
Struct Members
-
no_duplicates
: boolean = true
Whether duplicates keys are allowed when parsing a mapping or mapping body. If not allowed and duplicates are seen, a
RecognizerError
is returned.
-
strict_conversions
: boolean = true
If
true
, conversion of backtick-strings returns an error if conversion fails for any reason. Iffalse
, the string itself is returned if it can’t be converted. It’s intended to help catch typos in backtick-strings.
-
context
: map
A mapping within which to look up identifiers during evaluation of AST nodes.
-
cached
: boolean = false
If
true
, an internal cache is used.
-
include_path
: list(binary)
A list of directories to be searched for included configurations when an include expression is seen. Note that the
include_path
is only needed if the included configuration file is not in the same place as the including configuration file.
-
path
: binary
The path to the file from which this instance’s configuration has been loaded.
Functions
Where a return type of
{atom, value}
is given, the return value is either one of{:ok, success_value}
or{:error, error_value}
where theerror_value
is aRecognizerError
. If a return type ofatom
is given, then the returned atom is usually:ok
, unless otherwise specified.-
is_config(v) : boolean
See if a value is a configuration.
Parameters: - v – The value to check.
-
from_source(s) : {atom, value}
Return a new instance with the configuration read from the provided string.
Parameters: - s – A string containing the configuration source.
Errors: RecognizerError
– If there is an error in the configuration syntax.
-
from_file(path) : {atom, value}
Return a new instance with the configuration read from the specified file.
Parameters: - path – A string containing the path to the configuration source.
Errors: RecognizerError
– If there is an error in the configuration syntax or an I/O error when reading the source.
-
load_file(this, path) : {atom, value}
Load this instance with the configuration read from a file, replacing any existing configuration.
Parameters: - this – The configuration to operate on.
- path – A string specifying the path to the file which contains the configuration.
Errors: RecognizerError
– If there is an I/O error when reading the source or an error in the configuration syntax.
-
get(this, key, default = :MISSING) : {atom, value}
This function gets a value from the configuration. The value of key can be a simple key (identifier) or a valid path string. If a default value is specified and the key or path not found in the configuration, the specified default is returned.
Parameters: - this – The configuration to operate on.
- key – A string describing a simple key or a path in the configuration.
Errors: invalid_path
– If key is not present in the data and it cannot be parsed as a path.invalid_path_extra
– If there is a valid path followed by extraneous data.invalid_container
– If, while traversing a path, a key or index is not of the appropriate type for its containerinvalid_index
– If the index is out of range for the container.
-
add_include(this, paths, append = true) : atom
Append or prepend a list of directories to the include path of a configuration.
Parameters: - this – The configuration to operate on.
- paths – A list of strings which represent directories, which are to be added to the include path.
- append – If
true
, the paths are appended, otherwise prepended, to the existing include path.
-
set_include(this, paths) : atom
Set the include path of a configuration to the specified list of directories.
Parameters: - this – The configuration to operate on.
- paths – A list of strings which represent directories, which are to become the include path.
-
get_include(this) : list(binary)
Get the include path of a configuration.
Parameters: - this – The configuration to operate on.
Returns: A list of strings which represent directories, which are the configuration’s include path.
-
as_dict(this) : {atom, value}
Get a configuration as a map, recursing into included configurations and nested maps.
Parameters: - this – The configuration to operate on.
-
is_cached(this) : boolean
See if a configuration is cached.
Parameters: - this – The configuration to operate on.
-
set_cached(this, cached) : boolean
Set if a configuration is cached.
Parameters: - this – The configuration to operate on.
- cached – If
true
, a cache will be created and populated. Iffalse
, any existing cache will be disabled.
-
is_no_duplicates(this) : boolean
See if a configuration disallows duplicate keys.
Parameters: - this – The configuration to operate on.
-
set_no_duplicates(this, no_dupes) : boolean
Set if a configuration disallows duplicate keys.
Parameters: - this – The configuration to operate on.
- no_dupes – If
true
, any subsequentload_file
will fail if the source has duplicate keys. Iffalse
, then duplicate keys are allowed and later values for duplicate keys will silently overwrite earlier values.
-
set_path(this, path) : atom
Set the path this configuration was loaded from. You normally don’t need to call this because it will be called by
load_file
orfrom_file
, but if you load a configuration from text which references included configurations, you might find this useful.Parameters: - this – The configuration to operate on.
- path – The path to set.
-