RFL.settings 1.0.0__tar.gz
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.
- RFL.settings-1.0.0/PKG-INFO +23 -0
- RFL.settings-1.0.0/README.md +3 -0
- RFL.settings-1.0.0/RFL.settings.egg-info/PKG-INFO +23 -0
- RFL.settings-1.0.0/RFL.settings.egg-info/SOURCES.txt +13 -0
- RFL.settings-1.0.0/RFL.settings.egg-info/dependency_links.txt +1 -0
- RFL.settings-1.0.0/RFL.settings.egg-info/requires.txt +2 -0
- RFL.settings-1.0.0/RFL.settings.egg-info/top_level.txt +1 -0
- RFL.settings-1.0.0/pyproject.toml +35 -0
- RFL.settings-1.0.0/rfl/settings/__init__.py +151 -0
- RFL.settings-1.0.0/rfl/settings/definition.py +201 -0
- RFL.settings-1.0.0/rfl/settings/errors.py +23 -0
- RFL.settings-1.0.0/rfl/settings/loaders.py +38 -0
- RFL.settings-1.0.0/rfl/tests/__init__.py +0 -0
- RFL.settings-1.0.0/rfl/tests/test_settings.py +364 -0
- RFL.settings-1.0.0/setup.cfg +4 -0
|
@@ -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,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,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
RFL.settings.egg-info/PKG-INFO
|
|
4
|
+
RFL.settings.egg-info/SOURCES.txt
|
|
5
|
+
RFL.settings.egg-info/dependency_links.txt
|
|
6
|
+
RFL.settings.egg-info/requires.txt
|
|
7
|
+
RFL.settings.egg-info/top_level.txt
|
|
8
|
+
rfl/settings/__init__.py
|
|
9
|
+
rfl/settings/definition.py
|
|
10
|
+
rfl/settings/errors.py
|
|
11
|
+
rfl/settings/loaders.py
|
|
12
|
+
rfl/tests/__init__.py
|
|
13
|
+
rfl/tests/test_settings.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rfl
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "RFL.settings"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Rackslab Foundation Library: settings package"
|
|
9
|
+
license = {text = "GPLv3+"}
|
|
10
|
+
requires-python = ">=3.6"
|
|
11
|
+
keywords = ["utility", "basic"]
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Rémi Palancher", email = "remi@rackslab.io"},
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"RFL.core",
|
|
17
|
+
"PyYAML",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 5 - Production/Stable",
|
|
21
|
+
"Environment :: Console",
|
|
22
|
+
"Intended Audience :: System Administrators",
|
|
23
|
+
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Topic :: System :: Systems Administration",
|
|
26
|
+
]
|
|
27
|
+
readme = "README.md"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
"Homepage" = "https://github.com/rackslab/RFL"
|
|
32
|
+
"Bug Tracker" = "https://github.com/rackslab/RFL/issues"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
include = ["rfl.settings*"]
|
|
@@ -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
|
|
@@ -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)
|
|
File without changes
|
|
@@ -0,0 +1,364 @@
|
|
|
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 unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rfl.settings.definition import SettingsDefinition, SettingsDefinitionLoaderYaml
|
|
11
|
+
from rfl.settings.loaders import RuntimeSettingsSiteLoaderIni
|
|
12
|
+
from rfl.settings import RuntimeSettings, RuntimeSettingsSection
|
|
13
|
+
|
|
14
|
+
from rfl.settings.errors import SettingsDefinitionError, SettingsOverrideError
|
|
15
|
+
|
|
16
|
+
VALID_DEFINITION = """
|
|
17
|
+
---
|
|
18
|
+
section1:
|
|
19
|
+
param_str:
|
|
20
|
+
type: str
|
|
21
|
+
default: value1
|
|
22
|
+
|
|
23
|
+
section2:
|
|
24
|
+
param_int:
|
|
25
|
+
type: int
|
|
26
|
+
default: 10
|
|
27
|
+
doc: documentation of param2
|
|
28
|
+
ex: 500
|
|
29
|
+
choices:
|
|
30
|
+
- 10
|
|
31
|
+
- 100
|
|
32
|
+
- 500
|
|
33
|
+
param_path:
|
|
34
|
+
type: path
|
|
35
|
+
default: /path/to/file
|
|
36
|
+
doc: documentation of param3
|
|
37
|
+
ex: /other/path/to/file
|
|
38
|
+
param_uri:
|
|
39
|
+
type: uri
|
|
40
|
+
default: https://localhost:5900/resources
|
|
41
|
+
param_float:
|
|
42
|
+
type: float
|
|
43
|
+
param_bool:
|
|
44
|
+
type: bool
|
|
45
|
+
default: yes
|
|
46
|
+
param_list:
|
|
47
|
+
type: list
|
|
48
|
+
content: str
|
|
49
|
+
default:
|
|
50
|
+
- value1
|
|
51
|
+
- value2
|
|
52
|
+
param_required:
|
|
53
|
+
type: str
|
|
54
|
+
required: true
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
VALID_SITE = """
|
|
58
|
+
[section1]
|
|
59
|
+
param_str = site_value1
|
|
60
|
+
|
|
61
|
+
[section2]
|
|
62
|
+
param_path = /site/path/to/file
|
|
63
|
+
param_list =
|
|
64
|
+
value3
|
|
65
|
+
#value4
|
|
66
|
+
value5
|
|
67
|
+
param_required = required_value
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestSettingsDefinition(unittest.TestCase):
|
|
72
|
+
def test_valid_content(self):
|
|
73
|
+
loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
74
|
+
definition = SettingsDefinition(loader)
|
|
75
|
+
self.assertTrue(definition.has_section("section1"))
|
|
76
|
+
self.assertFalse(definition.has_section("unknown"))
|
|
77
|
+
self.assertTrue(definition.section("section1").has_parameter("param_str"))
|
|
78
|
+
self.assertFalse(definition.section("section1").has_parameter("unknown"))
|
|
79
|
+
self.assertEqual(
|
|
80
|
+
definition.section("section1").parameter("param_str").default, "value1"
|
|
81
|
+
)
|
|
82
|
+
self.assertEqual(
|
|
83
|
+
definition.section("section2").parameter("param_int").example, 500
|
|
84
|
+
)
|
|
85
|
+
self.assertEqual(
|
|
86
|
+
len(definition.section("section2").parameter("param_int").choices), 3
|
|
87
|
+
)
|
|
88
|
+
self.assertEqual(
|
|
89
|
+
definition.section("section2").parameter("param_uri")._type, "uri"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def test_invalid_yaml(self):
|
|
93
|
+
with self.assertRaisesRegex(
|
|
94
|
+
SettingsDefinitionError, "^Invalid YAML settings definition: "
|
|
95
|
+
):
|
|
96
|
+
SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION + "\n fail")
|
|
97
|
+
with self.assertRaisesRegex(
|
|
98
|
+
SettingsDefinitionError,
|
|
99
|
+
"^YAML scanner error: mapping values are not allowed here.*",
|
|
100
|
+
):
|
|
101
|
+
SettingsDefinitionLoaderYaml(raw="fail: again: ")
|
|
102
|
+
|
|
103
|
+
def test_unsupported_property(self):
|
|
104
|
+
loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
105
|
+
loader.content["section2"]["param_int"]["unknown"] = "fail"
|
|
106
|
+
with self.assertRaisesRegex(
|
|
107
|
+
SettingsDefinitionError,
|
|
108
|
+
"^Unsupported properties found for \[section2\]>param_int: unknown$",
|
|
109
|
+
):
|
|
110
|
+
SettingsDefinition(loader)
|
|
111
|
+
|
|
112
|
+
def test_unsupported_type(self):
|
|
113
|
+
loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
114
|
+
loader.content["section2"]["param_int"]["type"] = "fail"
|
|
115
|
+
with self.assertRaisesRegex(
|
|
116
|
+
SettingsDefinitionError,
|
|
117
|
+
"^Unsupported type fail for \[section2\]>param_int$",
|
|
118
|
+
):
|
|
119
|
+
SettingsDefinition(loader)
|
|
120
|
+
|
|
121
|
+
def test_required_not_bool(self):
|
|
122
|
+
loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
123
|
+
loader.content["section2"]["param_required"]["required"] = "fail"
|
|
124
|
+
with self.assertRaisesRegex(
|
|
125
|
+
SettingsDefinitionError,
|
|
126
|
+
"^Invalid boolean value of \[section2\]>param_required required property$",
|
|
127
|
+
):
|
|
128
|
+
SettingsDefinition(loader)
|
|
129
|
+
|
|
130
|
+
def test_default_invalid_type_int(self):
|
|
131
|
+
loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
132
|
+
loader.content["section2"]["param_int"]["default"] = "fail"
|
|
133
|
+
with self.assertRaisesRegex(
|
|
134
|
+
SettingsDefinitionError,
|
|
135
|
+
"^Default value fail for parameter \[section2\]>param_int has not the "
|
|
136
|
+
"expected type int$",
|
|
137
|
+
):
|
|
138
|
+
SettingsDefinition(loader)
|
|
139
|
+
|
|
140
|
+
def test_default_invalid_type_bool(self):
|
|
141
|
+
loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
142
|
+
loader.content["section2"]["param_bool"]["default"] = "fail"
|
|
143
|
+
with self.assertRaisesRegex(
|
|
144
|
+
SettingsDefinitionError,
|
|
145
|
+
"^Default value fail for parameter \[section2\]>param_bool has not the "
|
|
146
|
+
"expected type bool$",
|
|
147
|
+
):
|
|
148
|
+
SettingsDefinition(loader)
|
|
149
|
+
|
|
150
|
+
def test_default_invalid_choice(self):
|
|
151
|
+
loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
152
|
+
loader.content["section2"]["param_int"]["default"] = 12
|
|
153
|
+
with self.assertRaisesRegex(
|
|
154
|
+
SettingsDefinitionError,
|
|
155
|
+
"^Default value 12 for parameter \[section2\]>param_int is not one of "
|
|
156
|
+
"possible choices \[10, 100, 500\]$",
|
|
157
|
+
):
|
|
158
|
+
SettingsDefinition(loader)
|
|
159
|
+
|
|
160
|
+
def test_default_invalid_type_list(self):
|
|
161
|
+
loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
162
|
+
loader.content["section2"]["param_list"]["default"] = "fail"
|
|
163
|
+
with self.assertRaisesRegex(
|
|
164
|
+
SettingsDefinitionError,
|
|
165
|
+
"^Default value fail for parameter \[section2\]>param_list is not a valid "
|
|
166
|
+
"list$",
|
|
167
|
+
):
|
|
168
|
+
SettingsDefinition(loader)
|
|
169
|
+
|
|
170
|
+
def test_type_list_without_content(self):
|
|
171
|
+
loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
172
|
+
del loader.content["section2"]["param_list"]["content"]
|
|
173
|
+
with self.assertRaisesRegex(
|
|
174
|
+
SettingsDefinitionError,
|
|
175
|
+
"^List content type for parameter \[section2\]>param_list must be defined$",
|
|
176
|
+
):
|
|
177
|
+
SettingsDefinition(loader)
|
|
178
|
+
|
|
179
|
+
def test_type_list_invalid_content_type(self):
|
|
180
|
+
loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
181
|
+
loader.content["section2"]["param_list"]["content"] = "fail"
|
|
182
|
+
with self.assertRaisesRegex(
|
|
183
|
+
SettingsDefinitionError,
|
|
184
|
+
"^Unsupported list content type fail for \[section2\]>param_list$",
|
|
185
|
+
):
|
|
186
|
+
SettingsDefinition(loader)
|
|
187
|
+
loader.content["section2"]["param_list"]["content"] = "list"
|
|
188
|
+
with self.assertRaisesRegex(
|
|
189
|
+
SettingsDefinitionError,
|
|
190
|
+
"^Unsupported list content type list for \[section2\]>param_list$",
|
|
191
|
+
):
|
|
192
|
+
SettingsDefinition(loader)
|
|
193
|
+
|
|
194
|
+
def test_type_content_on_other_type(self):
|
|
195
|
+
loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
196
|
+
loader.content["section2"]["param_list"]["type"] = "str"
|
|
197
|
+
with self.assertRaisesRegex(
|
|
198
|
+
SettingsDefinitionError,
|
|
199
|
+
"^Content property is forbidden for parameter \[section2\]>param_list with "
|
|
200
|
+
"type str$",
|
|
201
|
+
):
|
|
202
|
+
SettingsDefinition(loader)
|
|
203
|
+
|
|
204
|
+
def test_type_list_invalid_default_type(self):
|
|
205
|
+
loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
206
|
+
loader.content["section2"]["param_list"]["content"] = "bool"
|
|
207
|
+
with self.assertRaisesRegex(
|
|
208
|
+
SettingsDefinitionError,
|
|
209
|
+
"^Default value value1 for parameter \[section2\]>param_list has not the "
|
|
210
|
+
"expected type bool$",
|
|
211
|
+
):
|
|
212
|
+
SettingsDefinition(loader)
|
|
213
|
+
|
|
214
|
+
def test_type_list_invalid_choice(self):
|
|
215
|
+
loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
216
|
+
loader.content["section2"]["param_list"]["choices"] = ["value1", "value3"]
|
|
217
|
+
with self.assertRaisesRegex(
|
|
218
|
+
SettingsDefinitionError,
|
|
219
|
+
"^Default value value2 for parameter \[section2\]>param_list is not one of "
|
|
220
|
+
"possible choices \['value1', 'value3'\]$",
|
|
221
|
+
):
|
|
222
|
+
SettingsDefinition(loader)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class TestRuntimeSettings(unittest.TestCase):
|
|
226
|
+
def test_valid_settings(self):
|
|
227
|
+
loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
228
|
+
definition = SettingsDefinition(loader)
|
|
229
|
+
settings = RuntimeSettings(definition)
|
|
230
|
+
self.assertEqual(settings.section1.param_str, "value1")
|
|
231
|
+
self.assertEqual(settings.section2.param_int, 10)
|
|
232
|
+
self.assertEqual(settings.section2.param_path, Path("/path/to/file"))
|
|
233
|
+
self.assertIsInstance(settings.section2.param_uri, tuple)
|
|
234
|
+
self.assertEqual(settings.section2.param_uri.scheme, "https")
|
|
235
|
+
self.assertIsInstance(settings.section1, RuntimeSettingsSection)
|
|
236
|
+
self.assertEqual(type(settings.section1).__name__, "RuntimeSettingsSection1")
|
|
237
|
+
|
|
238
|
+
def test_site_overrides(self):
|
|
239
|
+
def_loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
240
|
+
definition = SettingsDefinition(def_loader)
|
|
241
|
+
settings = RuntimeSettings(definition)
|
|
242
|
+
site_loader = RuntimeSettingsSiteLoaderIni(VALID_SITE)
|
|
243
|
+
settings.override(site_loader)
|
|
244
|
+
self.assertEqual(settings.section1.param_str, "site_value1")
|
|
245
|
+
self.assertEqual(settings.section2.param_path, Path("/site/path/to/file"))
|
|
246
|
+
self.assertEqual(settings.section2.param_list, ["value3", "value5"])
|
|
247
|
+
|
|
248
|
+
def test_site_override_invalid_section(self):
|
|
249
|
+
def_loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
250
|
+
definition = SettingsDefinition(def_loader)
|
|
251
|
+
settings = RuntimeSettings(definition)
|
|
252
|
+
site_loader = RuntimeSettingsSiteLoaderIni(VALID_SITE)
|
|
253
|
+
site_loader.content["big"] = {}
|
|
254
|
+
with self.assertRaisesRegex(
|
|
255
|
+
SettingsOverrideError,
|
|
256
|
+
"^Section big loaded in settings overrides is not defined in "
|
|
257
|
+
"settings definition$",
|
|
258
|
+
):
|
|
259
|
+
settings.override(site_loader)
|
|
260
|
+
|
|
261
|
+
def test_site_override_invalid_parameter(self):
|
|
262
|
+
def_loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
263
|
+
definition = SettingsDefinition(def_loader)
|
|
264
|
+
settings = RuntimeSettings(definition)
|
|
265
|
+
site_loader = RuntimeSettingsSiteLoaderIni(VALID_SITE)
|
|
266
|
+
site_loader.content["section2"]["unknown"] = "fail"
|
|
267
|
+
with self.assertRaisesRegex(
|
|
268
|
+
SettingsOverrideError,
|
|
269
|
+
"^Parameter unknown loaded in settings overrides is not defined in "
|
|
270
|
+
"section section2 of settings definition$",
|
|
271
|
+
):
|
|
272
|
+
settings.override(site_loader)
|
|
273
|
+
|
|
274
|
+
def test_site_override_invalid_type_int(self):
|
|
275
|
+
def_loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
276
|
+
definition = SettingsDefinition(def_loader)
|
|
277
|
+
settings = RuntimeSettings(definition)
|
|
278
|
+
site_loader = RuntimeSettingsSiteLoaderIni(VALID_SITE)
|
|
279
|
+
site_loader.content["section2"]["param_int"] = "fail"
|
|
280
|
+
with self.assertRaisesRegex(
|
|
281
|
+
SettingsOverrideError,
|
|
282
|
+
"^Invalid integer value 'fail' for \[section2\]>param_int in site "
|
|
283
|
+
"overrides$",
|
|
284
|
+
):
|
|
285
|
+
settings.override(site_loader)
|
|
286
|
+
|
|
287
|
+
def test_site_override_invalid_type_float(self):
|
|
288
|
+
def_loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
289
|
+
definition = SettingsDefinition(def_loader)
|
|
290
|
+
settings = RuntimeSettings(definition)
|
|
291
|
+
site_loader = RuntimeSettingsSiteLoaderIni(VALID_SITE)
|
|
292
|
+
site_loader.content["section2"]["param_float"] = "fail"
|
|
293
|
+
with self.assertRaisesRegex(
|
|
294
|
+
SettingsOverrideError,
|
|
295
|
+
"^Invalid float value 'fail' for \[section2\]>param_float in site "
|
|
296
|
+
"overrides$",
|
|
297
|
+
):
|
|
298
|
+
settings.override(site_loader)
|
|
299
|
+
|
|
300
|
+
def test_site_override_invalid_type_bool(self):
|
|
301
|
+
def_loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
302
|
+
definition = SettingsDefinition(def_loader)
|
|
303
|
+
settings = RuntimeSettings(definition)
|
|
304
|
+
site_loader = RuntimeSettingsSiteLoaderIni(VALID_SITE)
|
|
305
|
+
site_loader.content["section2"]["param_bool"] = "fail"
|
|
306
|
+
with self.assertRaisesRegex(
|
|
307
|
+
SettingsOverrideError,
|
|
308
|
+
"^Invalid boolean value 'fail' for \[section2\]>param_bool in site "
|
|
309
|
+
"overrides$",
|
|
310
|
+
):
|
|
311
|
+
settings.override(site_loader)
|
|
312
|
+
|
|
313
|
+
def test_site_override_invalid_choice(self):
|
|
314
|
+
def_loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
315
|
+
definition = SettingsDefinition(def_loader)
|
|
316
|
+
settings = RuntimeSettings(definition)
|
|
317
|
+
site_loader = RuntimeSettingsSiteLoaderIni(VALID_SITE)
|
|
318
|
+
site_loader.content["section2"]["param_int"] = "12"
|
|
319
|
+
with self.assertRaisesRegex(
|
|
320
|
+
SettingsOverrideError,
|
|
321
|
+
"^Value 12 for parameter \[section2\]>param_int in site overrides is not "
|
|
322
|
+
"one of possible choices \[10, 100, 500\]$",
|
|
323
|
+
):
|
|
324
|
+
settings.override(site_loader)
|
|
325
|
+
|
|
326
|
+
def test_site_override_undefined_required(self):
|
|
327
|
+
def_loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
328
|
+
definition = SettingsDefinition(def_loader)
|
|
329
|
+
settings = RuntimeSettings(definition)
|
|
330
|
+
site_loader = RuntimeSettingsSiteLoaderIni(VALID_SITE)
|
|
331
|
+
del site_loader.content["section2"]["param_required"]
|
|
332
|
+
with self.assertRaisesRegex(
|
|
333
|
+
SettingsOverrideError,
|
|
334
|
+
"^Parameter \[section2\]>param_required is missing but required in "
|
|
335
|
+
"settings overrides$",
|
|
336
|
+
):
|
|
337
|
+
settings.override(site_loader)
|
|
338
|
+
|
|
339
|
+
def test_site_override_list_invalid_content_type(self):
|
|
340
|
+
def_loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
341
|
+
def_loader.content["section2"]["param_list"]["content"] = "int"
|
|
342
|
+
def_loader.content["section2"]["param_list"]["default"] = [2, 5]
|
|
343
|
+
definition = SettingsDefinition(def_loader)
|
|
344
|
+
settings = RuntimeSettings(definition)
|
|
345
|
+
site_loader = RuntimeSettingsSiteLoaderIni(VALID_SITE)
|
|
346
|
+
with self.assertRaisesRegex(
|
|
347
|
+
SettingsOverrideError,
|
|
348
|
+
"^Invalid integer value 'value3' for \[section2\]>param_list in site "
|
|
349
|
+
"overrides$",
|
|
350
|
+
):
|
|
351
|
+
settings.override(site_loader)
|
|
352
|
+
|
|
353
|
+
def test_site_override_list_invalid_choice(self):
|
|
354
|
+
def_loader = SettingsDefinitionLoaderYaml(raw=VALID_DEFINITION)
|
|
355
|
+
def_loader.content["section2"]["param_list"]["choices"] = ["value1", "value2"]
|
|
356
|
+
definition = SettingsDefinition(def_loader)
|
|
357
|
+
settings = RuntimeSettings(definition)
|
|
358
|
+
site_loader = RuntimeSettingsSiteLoaderIni(VALID_SITE)
|
|
359
|
+
with self.assertRaisesRegex(
|
|
360
|
+
SettingsOverrideError,
|
|
361
|
+
"^Value value3 for parameter \[section2\]>param_list in site overrides is "
|
|
362
|
+
"not one of possible choices \['value1', 'value2'\]$",
|
|
363
|
+
):
|
|
364
|
+
settings.override(site_loader)
|