RFL.settings 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.1
2
+ Name: RFL.settings
3
+ Version: 1.0.0
4
+ Summary: Rackslab Foundation Library: settings package
5
+ Author-email: Rémi Palancher <remi@rackslab.io>
6
+ License: GPLv3+
7
+ Project-URL: Homepage, https://github.com/rackslab/RFL
8
+ Project-URL: Bug Tracker, https://github.com/rackslab/RFL/issues
9
+ Keywords: utility,basic
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: System :: Systems Administration
16
+ Requires-Python: >=3.6
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: RFL.core
19
+ Requires-Dist: PyYAML
20
+
21
+ # RFL: settings package
22
+
23
+ Manage configuration files settings.
@@ -0,0 +1,8 @@
1
+ rfl/settings/__init__.py,sha256=Jge5Y06IjO2aki9gtmTZAiwk8hAUxtdUvWUh6W1tiI0,6074
2
+ rfl/settings/definition.py,sha256=p8vdXRPh9H0HUFsZzYuFOeOziq0Ans9TCFKG642loNA,7077
3
+ rfl/settings/errors.py,sha256=Zaoe_iIN4OT4J6u81X0J7BBQTGm67D9LWJcaNbC-wXU,355
4
+ rfl/settings/loaders.py,sha256=3j3hBAiX0bvkRbgiyfp5BgTqy8foxL6Xd32XytZLgak,1152
5
+ RFL.settings-1.0.0.dist-info/METADATA,sha256=ALFOW8AjwyagCUZLPz6VOYr0VFqmJcyMEauCreK0s5Y,827
6
+ RFL.settings-1.0.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
7
+ RFL.settings-1.0.0.dist-info/top_level.txt,sha256=EIloiMrXuRz8n0vqkCE6NM9Ze1S2mrbCKBcdtWaM_og,4
8
+ RFL.settings-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.43.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ rfl
@@ -0,0 +1,151 @@
1
+ # Copyright (c) 2023 Rackslab
2
+ #
3
+ # This file is part of RFL.
4
+ #
5
+ # SPDX-License-Identifier: GPL-3.0-or-later
6
+
7
+ from pathlib import Path
8
+ import urllib
9
+
10
+ import logging
11
+
12
+ from .definition import (
13
+ SettingsDefinition,
14
+ SettingsSectionDefinition,
15
+ SettingsParameterDefinition,
16
+ SettingsDefinitionLoaderYaml,
17
+ )
18
+ from .loaders import RuntimeSettingsSiteLoader, RuntimeSettingsSiteLoaderIni
19
+ from .errors import SettingsOverrideError
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class RuntimeSettingsSection:
25
+ def __init__(self, section: SettingsSectionDefinition):
26
+ self._name = section.name
27
+ for parameter in section.parameters:
28
+ setattr(self, parameter.name, parameter.default)
29
+
30
+
31
+ class RuntimeSettings:
32
+ def __init__(self, definition: SettingsDefinition):
33
+ # set to hold all sections names
34
+ self._sections = set()
35
+ self._definition = definition
36
+ for section in definition.sections:
37
+ o_section = type(
38
+ f"{self.__class__.__name__}{section.name.capitalize()}",
39
+ (RuntimeSettingsSection,),
40
+ dict(),
41
+ )(section)
42
+ setattr(self, o_section._name, o_section)
43
+ self._sections.add(o_section._name)
44
+
45
+ def override(self, loader: RuntimeSettingsSiteLoader):
46
+ # load site ini file and check it matches definition
47
+ for section_name in loader.content.sections():
48
+ if not self._definition.has_section(section_name):
49
+ raise SettingsOverrideError(
50
+ f"Section {section_name} loaded in settings overrides is not "
51
+ "defined in settings definition"
52
+ )
53
+ section = self._definition.section(section_name)
54
+ for parameter_name in loader.content[section.name]:
55
+ if not section.has_parameter(parameter_name):
56
+ raise SettingsOverrideError(
57
+ f"Parameter {parameter_name} loaded in settings overrides is "
58
+ f"not defined in section {section.name} of settings definition"
59
+ )
60
+ parameter = section.parameter(parameter_name)
61
+ self._override_parameter(loader, section, parameter)
62
+
63
+ # check required parameters
64
+ self._check_required()
65
+
66
+ def _check_required(self):
67
+ """Verify that all parameters declared as required in settings definition are
68
+ properly defined with a real value or raise SettingsOverrideError."""
69
+ for section in self._definition.sections:
70
+ for parameter in section.parameters:
71
+ if (
72
+ parameter.required
73
+ and getattr(getattr(self, parameter.section), parameter.name)
74
+ is None
75
+ ):
76
+ raise SettingsOverrideError(
77
+ f"Parameter {str(parameter)} is missing but required in "
78
+ "settings overrides"
79
+ )
80
+
81
+ def _override_parameter(
82
+ self,
83
+ loader: RuntimeSettingsSiteLoader,
84
+ section: SettingsSectionDefinition,
85
+ parameter: SettingsParameterDefinition,
86
+ ) -> None:
87
+ """Load value from INI file, convert it to expected type, verify it is a valid
88
+ choice and set corresponding object attribute. Special logic is applied for list
89
+ to iterate over the items."""
90
+ raw = loader.content[section.name].get(parameter.name)
91
+ if parameter._type == "list":
92
+ value = [
93
+ self._load_parameter_value(parameter, parameter.content, raw)
94
+ for raw in raw.strip().split("\n")
95
+ ]
96
+ for item in value:
97
+ self._validate_choice(parameter, item)
98
+ else:
99
+ value = self._load_parameter_value(parameter, parameter._type, raw)
100
+ self._validate_choice(parameter, value)
101
+ setattr(getattr(self, section.name), parameter.name, value)
102
+
103
+ def _load_parameter_value(self, parameter, _type: str, raw: str):
104
+ """Try to convert raw value to expected type or raise SettingsOverrideError."""
105
+ if _type == "path":
106
+ # Converting a string to a pathlib.Path basically never fails.
107
+ return Path(raw)
108
+ elif _type == "uri":
109
+ # Converting a string to a urllib.ParseResult basically never fails.
110
+ return urllib.parse.urlparse(raw)
111
+ elif _type == "int":
112
+ try:
113
+ return int(raw)
114
+ except ValueError as err:
115
+ raise SettingsOverrideError(
116
+ f"Invalid integer value '{raw}' for {parameter} in site overrides"
117
+ ) from err
118
+ elif _type == "float":
119
+ try:
120
+ return float(raw)
121
+ except ValueError as err:
122
+ raise SettingsOverrideError(
123
+ f"Invalid float value '{raw}' for {parameter} in site overrides"
124
+ ) from err
125
+ elif _type == "bool":
126
+ if raw.lower() in ["1", "yes", "true", "on"]:
127
+ return True
128
+ elif raw.lower() in ["0", "no", "false", "off"]:
129
+ return False
130
+ else:
131
+ raise SettingsOverrideError(
132
+ f"Invalid boolean value '{raw}' for {parameter} in site overrides"
133
+ )
134
+ else:
135
+ # Return unmodified value
136
+ return raw
137
+
138
+ def _validate_choice(self, parameter, value):
139
+ """Validate value is in parameter choices or raise SettingsOverrideError."""
140
+ if parameter.choices is not None and value not in parameter.choices:
141
+ raise SettingsOverrideError(
142
+ f"Value {value} for parameter {parameter} in site overrides is "
143
+ f"not one of possible choices {parameter.choices}"
144
+ )
145
+
146
+ def override_ini(self, path: Path) -> None:
147
+ self.override(RuntimeSettingsSiteLoaderIni(path=path))
148
+
149
+ @classmethod
150
+ def yaml_definition(cls, path: Path):
151
+ return cls(SettingsDefinition(SettingsDefinitionLoaderYaml(path=path)))
@@ -0,0 +1,201 @@
1
+ # Copyright (c) 2023 Rackslab
2
+ #
3
+ # This file is part of RFL.
4
+ #
5
+ # SPDX-License-Identifier: GPL-3.0-or-later
6
+
7
+ from pathlib import Path
8
+ import urllib
9
+
10
+ import yaml
11
+
12
+ from .errors import SettingsDefinitionError
13
+
14
+
15
+ class SettingsDefinitionLoader:
16
+ pass
17
+
18
+
19
+ class SettingsDefinitionLoaderYaml(SettingsDefinitionLoader):
20
+ def __init__(self, raw: str = None, path: Path = None):
21
+ try:
22
+ if raw is not None:
23
+ self.content = yaml.safe_load(raw)
24
+ elif path is not None:
25
+ try:
26
+ with open(path) as fh:
27
+ self.content = yaml.safe_load(fh)
28
+ except FileNotFoundError as err:
29
+ raise SettingsDefinitionError(
30
+ f"Settings definition file {path} not found"
31
+ ) from err
32
+ else:
33
+ raise SettingsDefinitionError(
34
+ "Either a raw string value or a path must be given to load YAML "
35
+ "settings definition"
36
+ )
37
+ except yaml.parser.ParserError as err:
38
+ raise SettingsDefinitionError(
39
+ f"Invalid YAML settings definition: {str(err)}"
40
+ ) from err
41
+ except yaml.scanner.ScannerError as err:
42
+ raise SettingsDefinitionError(f"YAML scanner error: {str(err)}") from err
43
+
44
+
45
+ class SettingsParameterDefinition:
46
+ # Map properties that can be found in definition file with corresponding
47
+ # object attributes names.
48
+ POSSIBLE_PROPERTIES = {
49
+ "type": "_type",
50
+ "content": "content",
51
+ "default": "default",
52
+ "choices": "choices",
53
+ "doc": "doc",
54
+ "ex": "example",
55
+ "required": "required",
56
+ }
57
+ EXPECTED_TYPES = {
58
+ "str": str,
59
+ "int": int,
60
+ "float": float,
61
+ "path": Path,
62
+ "bool": bool,
63
+ "uri": urllib.parse.ParseResult,
64
+ "list": list,
65
+ }
66
+
67
+ def __init__(self, section: str, name: str, properties: dict):
68
+ self.section = section
69
+ self.name = name
70
+
71
+ # Check for unsupported properties
72
+ unsupported_properties = list(
73
+ set(properties.keys()).difference(self.POSSIBLE_PROPERTIES.keys())
74
+ )
75
+ if len(unsupported_properties):
76
+ raise SettingsDefinitionError(
77
+ f"Unsupported properties found for {str(self)}: "
78
+ f"{','.join(unsupported_properties)}"
79
+ )
80
+
81
+ for _property, attribute in self.POSSIBLE_PROPERTIES.items():
82
+ value = None
83
+ if _property in properties:
84
+ value = properties[_property]
85
+ setattr(self, attribute, value)
86
+
87
+ # Check the type of defined parameter is actually supported
88
+ if self._type not in self.EXPECTED_TYPES:
89
+ raise SettingsDefinitionError(
90
+ f"Unsupported type {self._type} for {str(self)}"
91
+ )
92
+
93
+ # Check content property is defined for list and check content type is actually
94
+ # supported.
95
+ if self._type == "list":
96
+ if "content" not in properties:
97
+ raise SettingsDefinitionError(
98
+ f"List content type for parameter {str(self)} must be defined"
99
+ )
100
+ elif self.content not in set(self.EXPECTED_TYPES.keys()) - {"list"}:
101
+ raise SettingsDefinitionError(
102
+ f"Unsupported list content type {self.content} for {str(self)}"
103
+ )
104
+ # Check content property is not defined if not list
105
+ if "content" in properties and self._type != "list":
106
+ raise SettingsDefinitionError(
107
+ f"Content property is forbidden for parameter {str(self)} with type "
108
+ f"{self._type}"
109
+ )
110
+
111
+ # If the default value is defined, convert to the expected type and validate it
112
+ # is a valid choice.
113
+ if self.default is not None:
114
+ if self._type == "list":
115
+ if not isinstance(self.default, list):
116
+ raise SettingsDefinitionError(
117
+ f"Default value {self.default} for parameter {str(self)} is "
118
+ "not a valid list"
119
+ )
120
+ self.default = [
121
+ self._load_default(self.content, default)
122
+ for default in self.default
123
+ ]
124
+ for item in self.default:
125
+ self._validate_choice(item)
126
+ else:
127
+ self.default = self._load_default(self._type, self.default)
128
+ self._validate_choice(self.default)
129
+
130
+ if self.required is None:
131
+ self.required = False
132
+ elif not isinstance(self.required, bool):
133
+ raise SettingsDefinitionError(
134
+ f"Invalid boolean value of {str(self)} required property"
135
+ )
136
+
137
+ def _load_default(self, _type, default):
138
+ """Verify default has the expected type and convert to advanded type if
139
+ needed."""
140
+ if _type == "path":
141
+ return Path(default)
142
+ elif _type == "uri":
143
+ return urllib.parse.urlparse(default)
144
+ if not isinstance(default, self.EXPECTED_TYPES[_type]):
145
+ raise SettingsDefinitionError(
146
+ f"Default value {default} for parameter {str(self)} has not "
147
+ f"the expected type {_type}"
148
+ )
149
+ return default
150
+
151
+ def _validate_choice(self, default):
152
+ """Check default is a valid choice or raise SettingsDefinitionError."""
153
+ if self.choices is not None and default not in self.choices:
154
+ raise SettingsDefinitionError(
155
+ f"Default value {default} for parameter {str(self)} is not "
156
+ f"one of possible choices {self.choices}"
157
+ )
158
+
159
+ def __str__(self):
160
+ return f"[{self.section}]>{self.name}"
161
+
162
+
163
+ class SettingsSectionDefinition:
164
+ def __init__(self, section: str, parameters: dict):
165
+ self.name = section
166
+ self.parameters = [
167
+ SettingsParameterDefinition(section, parameter, properties)
168
+ for parameter, properties in parameters.items()
169
+ ]
170
+
171
+ def has_parameter(self, name: str) -> bool:
172
+ for parameter in self.parameters:
173
+ if parameter.name == name:
174
+ return True
175
+ return False
176
+
177
+ def parameter(self, name: str):
178
+ for parameter in self.parameters:
179
+ if parameter.name == name:
180
+ return parameter
181
+ return None
182
+
183
+
184
+ class SettingsDefinition:
185
+ def __init__(self, loader: SettingsDefinitionLoader):
186
+ self.sections = [
187
+ SettingsSectionDefinition(section, parameters)
188
+ for section, parameters in loader.content.items()
189
+ ]
190
+
191
+ def has_section(self, name: str) -> bool:
192
+ for section in self.sections:
193
+ if section.name == name:
194
+ return True
195
+ return False
196
+
197
+ def section(self, name: str):
198
+ for section in self.sections:
199
+ if section.name == name:
200
+ return section
201
+ return None
rfl/settings/errors.py ADDED
@@ -0,0 +1,23 @@
1
+ # Copyright (c) 2023 Rackslab
2
+ #
3
+ # This file is part of RFL.
4
+ #
5
+ # SPDX-License-Identifier: GPL-3.0-or-later
6
+
7
+ from rfl.core.errors import RFLError
8
+
9
+
10
+ class SettingsError(RFLError):
11
+ pass
12
+
13
+
14
+ class SettingsDefinitionError(SettingsError):
15
+ pass
16
+
17
+
18
+ class SettingsSiteLoaderError(SettingsError):
19
+ pass
20
+
21
+
22
+ class SettingsOverrideError(SettingsError):
23
+ pass
@@ -0,0 +1,38 @@
1
+ # Copyright (c) 2023 Rackslab
2
+ #
3
+ # This file is part of RFL.
4
+ #
5
+ # SPDX-License-Identifier: GPL-3.0-or-later
6
+
7
+ import configparser
8
+ from pathlib import Path
9
+ import logging
10
+
11
+ from .errors import SettingsSiteLoaderError
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class RuntimeSettingsSiteLoader:
17
+ pass
18
+
19
+
20
+ class RuntimeSettingsSiteLoaderIni(RuntimeSettingsSiteLoader):
21
+ def __init__(self, raw: str = None, path: Path = None):
22
+ self.content = configparser.ConfigParser(
23
+ interpolation=configparser.ExtendedInterpolation()
24
+ )
25
+ try:
26
+ if raw is not None:
27
+ logger.debug("Loading site settings in INI format from raw value")
28
+ self.content.read_string(raw)
29
+ elif path is not None:
30
+ logger.debug("Loading site settings file %s", path)
31
+ self.content.read(path)
32
+ else:
33
+ raise SettingsSiteLoaderError(
34
+ "Either a raw string value or a path must be given to load site "
35
+ "settings"
36
+ )
37
+ except configparser.Error as error:
38
+ raise SettingsSiteLoaderError(error)