#
##
## SPDX-FileCopyrightText: © 2007-2024 Benedict Verhegghe <bverheg@gmail.com>
## SPDX-License-Identifier: GPL-3.0-or-later
##
## This file is part of pyFormex 3.5 (Thu Feb 8 19:11:13 CET 2024)
## pyFormex is a tool for generating, manipulating and transforming 3D
## geometrical models by sequences of mathematical operations.
## Home page: https://pyformex.org
## Project page: https://savannah.nongnu.org/projects/pyformex/
## Development: https://gitlab.com/bverheg/pyformex
## Distributed under the GNU General Public License version 3 or later.
##
## This program is free software: you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation, either version 3 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program. If not, see http://www.gnu.org/licenses/.
##
"""A configuration class with Python syntax.
Why
I wrote this simple class because I wanted to use Python
expressions in my configuration files. This is much more fun
than using .INI style config files. While there are some other
Python config modules available on the web, I couldn't find one
that suited my needs and my taste: either they are intended for
more complex configuration needs than mine, or they do not work
with the simple Python syntax I expected.
What
Our :class:`Config` is Python dictionary which can hold any values.
The keys however should be strings and be valid Python
identifiers not starting with an underscore.
Values are usually simple types, but can also be
containers likes lists and dicts. A special dict subclass
:class:`Section` can be used to define sections inside the Config.
Again, all the keys in a Section must be valid Python identifiers
not starting with a an underscore. Sections provide a shortcut key
access: instead of ``cfg['section']['key']`` one can simply use the
combined section/key: ``cfg[``section/key]``. This works for getting
as well as for setting values. See Examples. (Actually, the shortkey
lookup also works for items in a plain dict, except for directly
setting values within non-existent dict: the created dict will always
be a Section.
Config files
The most important feature of a configuration class is to be able to
store the values in a file and load it back from there. To make the
file easily readable and editable, Config files use a very Python-like
syntax.
"""
from types import ModuleType
from pyformex.path import Path # noqa: F401
[docs]class Section(dict):
"""A class for storing a section in a :class:`Config`.
This only exists to give the sections another class name than plain dict.
"""
def __repr__(self):
"""String representation of a Section."""
return f"{self.__class__.__name__}({dict.__repr__(self)})"
[docs]class Config(Section):
"""A Python-style configuration class.
The configuration can be initialized with a dictionary, or
with a variable that can be passed to the read() function.
The latter includes the name of a config file, or a multiline string
holding the contents of a configuration file.
Parameters
----------
data: dict or multiline string, optional
Data to initialize the Config. If a dict, all keys should follow
the rules for valid config keys formulated above.
If a multiline string, it should be an executable Python source
text, with the limitations and exceptions outlined in the Notes
below.
default: :class:`Config` object, optional
If provided, this object will be used as default lookup for
missing keys.
Notes
-----
The configuration object can be initialized from a dict or from
a multiline string. Using a dict is obvious: one only has to obey
the restriction that keys should be valid Python variable names.
The format of the multiline config text is described hereafter.
This is also the format in which config files are written and
can be loaded.
All config lines should have the format: key = value, where key is a
string and value is a Python expression The first '=' character on the
line is the delimiter between key and value. Blanks around both the
key and the value are stripped. The value is then evaluated as a
Python expression and stored in a variable with name specified by the
key. This variable is available for use in subsequent configuration
lines. It is an error to use a variable before it is defined. The
key,value pair is also stored in the Config dictionary, unless the key
starts with an underscore ('_'): this provides for local variables.
Lines starting with '#' are comments and are ignored, as are empty
and blank lines. Lines ending with '\' are continued on the next
line. A line starting with '[' starts a new section. A section is
nothing more than a Python dictionary inside the Config
dictionary. The section name is delimited by '['and ']'. All
subsequent lines will be stored in the section dictionary instead
of the toplevel dictionary.
All other lines are executed as Python statements. This allows
e.g. for importing modules.
Whole dictionaries can be inserted at once in the Config with the
update() function.
All defined variables while reading config files remain available
for use in the config file statements, even over multiple calls to
the read() function. Variables inserted with addSection() will not
be available as individual variables though, but can be accessed as
``self['name']``.
As far as the resulting Config contents is concerned, the following are
equivalent::
C.update({'key':'value'})
C.read("key='value'\\n")
There is an important difference though: the second line will make a
variable key (with value 'value') available in subsequent Config read()
method calls.
Examples
--------
>>> C = Config('''# A simple config example
... aa = 'bb'
... bb = aa
... [cc]
... aa = 'aa' # yes ! comments are allowed)
... _n = 3 # local: will get stripped
... rng = list(range(_n))
... ''')
>>> C
Config({'aa': 'bb', 'bb': 'bb', 'cc': Section({'aa': 'aa', 'rng': [0, 1, 2]})})
>>> C['aa']
'bb'
>>> C['cc']
Section({'aa': 'aa', 'rng': [0, 1, 2]})
>>> C['cc/aa']
'aa'
>>> C.keys()
['aa', 'bb', 'cc', 'cc/aa', 'cc/rng']
Create a new Config with default lookup in C
>>> D = Config(default=C)
>>> D
Config({})
>>> D['aa'] # Get from C
'bb'
>>> D['cc'] # Get from C
Section({'aa': 'aa', 'rng': [0, 1, 2]})
>>> D['cc/aa'] # Get from C
'aa'
>>> D.get('cc/aa','zorro') # but get method does not cascade!
'zorro'
>>> D.keys()
[]
Setting values in D will store them in D while C remains unchanged.
>>> D['aa'] = 'wel'
>>> D['dd'] = 'hoe'
>>> D['cc/aa'] = 'ziedewel'
>>> D
Config({'aa': 'wel', 'dd': 'hoe', 'cc': Section({'aa': 'ziedewel'})})
>>> C
Config({'aa': 'bb', 'bb': 'bb', 'cc': Section({'aa': 'aa', 'rng': [0, 1, 2]})})
>>> print(C)
aa = 'bb'
bb = 'bb'
<BLANKLINE>
[cc]
aa = 'aa'
rng = [0, 1, 2]
<BLANKLINE>
>>> D['cc/aa']
'ziedewel'
>>> D['cc']
Section({'aa': 'ziedewel'})
>>> D['cc/rng']
[0, 1, 2]
>>> 'bb' in D
False
>>> 'cc/aa' in D
True
>>> 'cc/ee' in D
False
>>> D['cc/bb'] = 'ok'
>>> D.keys()
['aa', 'dd', 'cc', 'cc/aa', 'cc/bb']
>>> del D['aa']
>>> del D['cc/aa']
>>> D.keys()
['dd', 'cc', 'cc/bb']
>>> D.sections()
['cc']
>>> del D['cc']
>>> D.keys()
['dd']
"""
def __init__(self, data={}, default=None):
"""Creates a new Config instance."""
super().__init__()
self._default = default
if isinstance(data, dict):
self.update(data) # allows to use __repr__ output
elif isinstance(data, str):
if '\n' in data:
self.fromtext(data) # it is Config file text
else:
self.load(data) # it is a Config filename
def __missing__(self, key):
"""What to do on a missing key
If the Dict has a default_factory, that is called with the key
as argument and the result returned.
Else, a KeyError for the given key is raised.
"""
if self._default:
return self._default[key]
else:
raise KeyError(key)
[docs] def update(self, data={}, name=None):
"""Add a dictionary to the Config object.
The data, if specified, should be a valid Python dict.
If no name is specified, the data are added to the top dictionary
and will become attributes.
If a name is specified, the data are added to the named attribute,
which should be a dictionary. If the name does not specify a
dictionary, an empty one is created, deleting the existing attribute.
If a name is specified, but no data, the effect is to add a new
empty dictionary (section) with that name.
"""
# remove locals and modules:
for k in list(data.keys()):
if k[0] == '_' or isinstance(data[k], ModuleType):
del data[k]
if name:
if name not in self or not isinstance(self[name], dict):
self[name] = Section()
self[name].update(data)
else:
super().update(data)
[docs] def load(self, filename, debug=False):
"""Read a configuration from a file in Config format.
Parameters
----------
filename: :term:`path_like`
The path of a text file in Config format.
Returns
-------
Config
Returns the Config self, updated with the settings read from
the specified file.
"""
with open(filename, 'r') as fil:
self.fromtext(fil, filename=filename, debug=debug)
return self
[docs] def fromtext(self, txt, filename=None, debug=False):
"""Update a Config from a text in Config file format.
Parameters
----------
txt: str|iterable(str)
A multiline string or an iterable of strings. This could be any
object that allows iteratation over its lines, such as an
open text file. If it is a string, it will first be splitted on
newlines.
filename: str
The filename to be shown in read error messages.
"""
def _read_error(self, lineno, line):
if filename:
where = f" file {filename}"
else:
where = ''
raise RuntimeError(f"Error in config{where} line {lineno}:\n{line}")
if isinstance(txt, str):
txt = txt.split('\n')
section = None
contents = {}
lineno = 0
continuation = False
comments = False
for line in txt:
lineno += 1
ls = line.strip()
if comments:
comments = ls[-3:] != '"""'
ls = ''
else:
comments = ls[:3] == '"""'
if comments or len(ls) == 0 or ls[0] == '#':
continue
if continuation:
s += ls # noqa: F821
else:
s = ls
continuation = s[-1] == '\\'
if s[-1] == '\\':
s = s[:-1]
if continuation:
continue
if s[0] == '[':
if contents:
self.update(name=section, data=contents)
contents = {}
i = s.find(']')
if i < 0:
self.read_error(lineno, line)
section = s[1:i]
if debug:
print(f"Starting new section '{section}'")
continue
else:
if debug:
print("READ: "+line)
i = s.find('=')
if i >= 0:
key = s[:i].strip()
if len(key) == 0:
self.read_error(lineno, line)
# TODO: filter illegal keys (not Python identifier)
if '/' in key:
continue
contents[key] = eval(s[i+1:].strip(), globals() | self, contents)
else:
if debug:
print("EXEC: "+s)
# this is to allow imports in the config file
# but is insecure
exec(s, globals(), contents)
if contents:
self.update(name=section, data=contents)
return self
def __setitem__(self, key, val):
"""Allows items to be set as self[section/key] = val.
"""
i = key.rfind('/')
if i == -1:
self.update({key: val})
else:
self.update({key[i+1:]: val}, key[:i])
def __getitem__(self, key):
"""Allows items to be addressed as self[key].
This is equivalent to the Dict lookup, except that items in
subsections can also be retrieved with a single key of the format
section/key.
While this lookup mechanism works for nested subsections, the syntax
for config files allows for only one level of sections!
Also beware that because of this functions, no '/' should be used
inside normal keys and sections names.
"""
i = key.rfind('/')
if i == -1:
return super().__getitem__(key)
else:
try:
d = self[key[:i]]
if d is None:
raise KeyError(key[:i])
return d[key[i+1:]]
except KeyError:
return self.__missing__(key)
def __delitem__(self, key):
"""Allows items to be deleted with del self[section/key].
"""
i = key.rfind('/')
if i == -1:
if key in self:
super().__delitem__(key)
else:
try:
del self[key[:i]][key[i+1:]]
except Exception:
pass
def __contains__(self, key):
"""Allows to test for a key or section/key"""
i = key.rfind('/')
if i == -1:
return super().__contains__(key)
else:
section = key[:i]
return (super().__contains__(section) and
isinstance(self[section], Section) and
key[i+1:] in self[section])
def __str__(self):
"""Format the Config in a way that can be read back.
This function is mostly used to format the data for writing it to
a configuration file. See the write() method.
The return value is a multiline string with Python statements that can
be read back through Python to recreate the Config data. Usually
this is done with the Config.read() method.
"""
s = ''
for k, v in self.items():
if not isinstance(v, Section):
if '/' not in k:
s += f"{k} = {v!r}\n"
for k, v in self.items():
if isinstance(v, Section):
s += f"\n[{k}]\n"
for ki, vi in v.items():
s += f"{ki} = {vi!r}\n"
return s
[docs] def write(self, filename,
header="# Config written by pyFormex -*- PYTHON -*-\n\n",
trailer="\n# End of config\n"):
"""Write the config to the given file
The configuration data will be written to the file with the given name
in a text format that is both readable by humans and by the
Config.read() method.
The header and trailer arguments are strings that will be added at
the start and end of the outputfile. Make sure they are valid
Python statements (or comments) and that they contain the needed
line separators, if you want to be able to read it back.
"""
with open(filename, 'w') as fil:
fil.write(header)
fil.write(f"{self}")
fil.write(trailer)
[docs] def keys(self, descend=True):
"""Return the keys in the config.
Parameters
----------
descend: bool, optional
If True (default) also reports the keys in Section objects with
the combined section/item keys. This is the proper use for a
Config with optional sections.
If False, no keys in sections are reported, only the section names.
"""
keys = list(super().keys())
if descend:
for k, v in self.items():
if isinstance(v, Section):
keys += [f"{k}/{ki}" for ki in v]
return keys
[docs] def sections(self):
"""Return the sections"""
return [k for k in self if isinstance(self[k], Section)]
if __name__ == '__main__':
import doctest
print(doctest.testmod(
optionflags=doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS))
# End