csspin 2.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.
- csspin/__init__.py +1340 -0
- csspin/__main__.py +20 -0
- csspin/builtin/__init__.py +322 -0
- csspin/cli.py +711 -0
- csspin/schema.py +204 -0
- csspin/schema.yaml +134 -0
- csspin/tree.py +764 -0
- csspin-2.0.0.dist-info/METADATA +134 -0
- csspin-2.0.0.dist-info/RECORD +13 -0
- csspin-2.0.0.dist-info/WHEEL +5 -0
- csspin-2.0.0.dist-info/entry_points.txt +2 -0
- csspin-2.0.0.dist-info/licenses/LICENSE +176 -0
- csspin-2.0.0.dist-info/top_level.txt +1 -0
csspin/tree.py
ADDED
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
# -*- mode: python; coding: utf-8 -*-
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2020 CONTACT Software GmbH
|
|
4
|
+
# https://www.contact-software.com/
|
|
5
|
+
#
|
|
6
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
# you may not use this file except in compliance with the License.
|
|
8
|
+
# You may obtain a copy of the License at
|
|
9
|
+
#
|
|
10
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
#
|
|
12
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
# See the License for the specific language governing permissions and
|
|
16
|
+
# limitations under the License.
|
|
17
|
+
|
|
18
|
+
# Disabling "union-attr" since inspect.currentframe() could return None, which
|
|
19
|
+
# is not the case for the implementation in this file.
|
|
20
|
+
# mypy: disable-error-code=union-attr
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import inspect
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
from collections import OrderedDict, namedtuple
|
|
29
|
+
from types import ModuleType
|
|
30
|
+
from typing import TYPE_CHECKING
|
|
31
|
+
|
|
32
|
+
import ruamel.yaml
|
|
33
|
+
import ruamel.yaml.comments
|
|
34
|
+
from path import Path
|
|
35
|
+
|
|
36
|
+
from csspin import ( # pylint: disable=cyclic-import
|
|
37
|
+
Verbosity,
|
|
38
|
+
debug,
|
|
39
|
+
die,
|
|
40
|
+
interpolate1,
|
|
41
|
+
warn,
|
|
42
|
+
)
|
|
43
|
+
from csspin.schema import DESCRIPTOR_REGISTRY
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from collections.abc import Hashable
|
|
47
|
+
from typing import Any, Callable, Generator, Iterable
|
|
48
|
+
|
|
49
|
+
from traceback import format_exc
|
|
50
|
+
|
|
51
|
+
KeyInfo = namedtuple("KeyInfo", ["file", "line"])
|
|
52
|
+
ParentInfo = namedtuple("ParentInfo", ["parent", "key"])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ConfigTree(OrderedDict):
|
|
56
|
+
"""A specialization of `OrderedDict` that we use to store the
|
|
57
|
+
configuration tree internally.
|
|
58
|
+
|
|
59
|
+
`ConfigTree` has three features over `OrderedDict`: first, it
|
|
60
|
+
behaves like a "bunch", i.e. items can be access as dot
|
|
61
|
+
expressions (``config.myprop``). Second, each subtree is linked to
|
|
62
|
+
its parent, to enable the computation of full names:
|
|
63
|
+
|
|
64
|
+
>>> tree_keyname(parent.subtree, "prop")
|
|
65
|
+
'parent->subtree->prop'
|
|
66
|
+
|
|
67
|
+
Third, we keep track of the locations settings came from. This is
|
|
68
|
+
done automatically, i.e for each update operation we inspect the
|
|
69
|
+
callstack and store source file name and line number. For data
|
|
70
|
+
read from another source (e.g. a YAML file), the location
|
|
71
|
+
information can be updated manually via `tree_set_keyinfo`.
|
|
72
|
+
|
|
73
|
+
Note that APIs used to access tracking information are *not* part
|
|
74
|
+
of this class, as each identifier we add may clash with property
|
|
75
|
+
names used.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self: ConfigTree, *args: Any, **kwargs: dict) -> None:
|
|
79
|
+
ofsframes = kwargs.pop("__ofs_frames__", 0)
|
|
80
|
+
super().__init__(*args, **kwargs)
|
|
81
|
+
self.__keyinfo = {}
|
|
82
|
+
self.__parentinfo = None # pylint: disable=unused-private-member
|
|
83
|
+
for key, value in self.items():
|
|
84
|
+
self.__keyinfo[key] = _call_location(2 + ofsframes) # type: ignore[operator]
|
|
85
|
+
if isinstance(value, ConfigTree):
|
|
86
|
+
# pylint: disable=protected-access,unused-private-member
|
|
87
|
+
value.__parentinfo = ParentInfo(self, key)
|
|
88
|
+
|
|
89
|
+
def __setitem__(self: ConfigTree, key: Hashable, value: Any) -> None:
|
|
90
|
+
super().__setitem__(key, value)
|
|
91
|
+
_set_callsite(self, key, 3, value)
|
|
92
|
+
|
|
93
|
+
def setdefault(self: ConfigTree, key: Hashable, default: Any = None) -> Any:
|
|
94
|
+
val = super().setdefault(key, default)
|
|
95
|
+
_set_callsite(self, key, 3, default)
|
|
96
|
+
return val
|
|
97
|
+
|
|
98
|
+
# __setattr__ and __getattr__ give the configuration tree "bunch"
|
|
99
|
+
# behaviour, i.e. one can access the dictionary items as if they
|
|
100
|
+
# were properties; this makes for a more convenient notation when
|
|
101
|
+
# using the settings in code and f-like interpolation expressions.
|
|
102
|
+
|
|
103
|
+
def __setattr__(self: ConfigTree, name: str, value: Any) -> None:
|
|
104
|
+
if any(name.startswith(f"{char}_ConfigTree__") for char in ("", "_")):
|
|
105
|
+
# "protected" and "private" variables must not go into the
|
|
106
|
+
# dictionary, obviously.
|
|
107
|
+
object.__setattr__(self, name, value)
|
|
108
|
+
else:
|
|
109
|
+
self[name] = value
|
|
110
|
+
_set_callsite(self, name, 3, value)
|
|
111
|
+
|
|
112
|
+
def __getattr__(self: ConfigTree, name: str) -> Any:
|
|
113
|
+
if name in self:
|
|
114
|
+
return self.get(name)
|
|
115
|
+
raise AttributeError(f"No property '{name}'")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def tree_get_descriptor(tree: ConfigTree, key: Hashable) -> Any:
|
|
119
|
+
"""
|
|
120
|
+
Retrieve the descriptor of a key within the configuration tree or ``None``
|
|
121
|
+
if there is no schema available for this key.
|
|
122
|
+
"""
|
|
123
|
+
if schema := getattr(tree, "_ConfigTree__schema", None):
|
|
124
|
+
return schema.properties.get(key, None)
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def tree_set_descriptor(
|
|
129
|
+
tree: ConfigTree, key: Hashable, desc_type: Any, **kwargs: Any
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Set or override descriptor of type `descriptor` to `key` property in `tree` ConfigTree.
|
|
132
|
+
All additional arguments are passed to the descriptor's __init__."""
|
|
133
|
+
schema = getattr(tree, "_ConfigTree__schema", None)
|
|
134
|
+
if not hasattr(schema, "properties"):
|
|
135
|
+
setattr(schema, "properties", ConfigTree())
|
|
136
|
+
|
|
137
|
+
desc = desc_type(**kwargs)
|
|
138
|
+
schema.properties[key] = desc
|
|
139
|
+
if isinstance(tree[key], OrderedDict):
|
|
140
|
+
setattr(tree[key], "_ConfigTree__schema", desc)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def tree_set_types(tree: ConfigTree, key: Hashable, types: Iterable[str]) -> None:
|
|
144
|
+
"""Set element's type"""
|
|
145
|
+
if desc := tree_get_descriptor(tree, key):
|
|
146
|
+
desc.type = list(types)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def tree_typecheck(tree: ConfigTree, key: Hashable, value: Any) -> Any:
|
|
150
|
+
if desc := tree_get_descriptor(tree, key):
|
|
151
|
+
return desc.coerce(value)
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def tree_types(tree: ConfigTree, key: Hashable) -> list:
|
|
156
|
+
"""Retrieve the types of a specific key within the configuration tree."""
|
|
157
|
+
if desc := tree_get_descriptor(tree, key):
|
|
158
|
+
return desc.type # type: ignore[no-any-return]
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def tree_sanitize(cfg: ConfigTree) -> None:
|
|
163
|
+
"""Walk through the tree recursively to interpolate all str and Path
|
|
164
|
+
values while enforcing types.
|
|
165
|
+
|
|
166
|
+
Enforcing types after interpolation enables defining values that can be
|
|
167
|
+
interpolated to non-string and non-path objects.
|
|
168
|
+
|
|
169
|
+
NOTE: This implementation doesn't work for lists containing objects. So far
|
|
170
|
+
there is no use-case for having config trees within lists.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def enforce_typecheck(
|
|
174
|
+
cfg_: ConfigTree,
|
|
175
|
+
keys: list,
|
|
176
|
+
value: str | Path,
|
|
177
|
+
ki: KeyInfo,
|
|
178
|
+
) -> None:
|
|
179
|
+
if len(keys) == 1:
|
|
180
|
+
cfg_[keys[0]] = tree_typecheck(cfg_, keys[0], value)
|
|
181
|
+
tree_set_keyinfo(cfg_, keys[0], ki)
|
|
182
|
+
else:
|
|
183
|
+
enforce_typecheck(cfg_[keys[0]], keys[1:], value, ki)
|
|
184
|
+
|
|
185
|
+
interpolateable = (str, Path)
|
|
186
|
+
for _, value, fullname, ki, _, _, _ in tree_walk(cfg):
|
|
187
|
+
if (
|
|
188
|
+
isinstance(value, interpolateable)
|
|
189
|
+
and (value := interpolate1(value))
|
|
190
|
+
or (
|
|
191
|
+
isinstance(value, list)
|
|
192
|
+
and (
|
|
193
|
+
value := [
|
|
194
|
+
interpolate1(val) if isinstance(val, interpolateable) else val
|
|
195
|
+
for val in value
|
|
196
|
+
]
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
):
|
|
200
|
+
enforce_typecheck(
|
|
201
|
+
cfg_=cfg,
|
|
202
|
+
keys=fullname.split("->"),
|
|
203
|
+
value=value,
|
|
204
|
+
ki=ki,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def tree_update_key(tree: ConfigTree, key: Hashable, value: Any) -> None:
|
|
209
|
+
OrderedDict.__setitem__(tree, key, value) # type: ignore[assignment]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _call_location(depth: int) -> KeyInfo:
|
|
213
|
+
fn, lno, _, _, _ = inspect.getframeinfo(
|
|
214
|
+
sys._getframe(depth) # pylint: disable=protected-access
|
|
215
|
+
)
|
|
216
|
+
return KeyInfo(fn, lno)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _set_callsite(tree: ConfigTree, key: Hashable, depth: int, value: Any) -> None:
|
|
220
|
+
if hasattr(tree, "_ConfigTree__keyinfo"):
|
|
221
|
+
# pylint: disable=protected-access
|
|
222
|
+
tree._ConfigTree__keyinfo[key] = _call_location(depth)
|
|
223
|
+
tree_set_parent(value, tree, key) # type: ignore[arg-type]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def tree_set_keyinfo(tree: ConfigTree, key: Hashable, ki: KeyInfo) -> None:
|
|
227
|
+
tree._ConfigTree__keyinfo[key] = ki # pylint: disable=protected-access
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def tree_keyinfo(tree: ConfigTree, key: Hashable) -> KeyInfo:
|
|
231
|
+
if key not in tree:
|
|
232
|
+
die(f"{key=} not in configuration tree.")
|
|
233
|
+
|
|
234
|
+
return tree._ConfigTree__keyinfo[key] # type: ignore[no-any-return] # pylint: disable=protected-access
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def tree_set_parent(tree: ConfigTree, parent: ConfigTree, name: str) -> None:
|
|
238
|
+
if hasattr(tree, "_ConfigTree__parentinfo"):
|
|
239
|
+
tree._ConfigTree__parentinfo = ParentInfo( # pylint: disable=protected-access
|
|
240
|
+
parent, name
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def tree_keyname(tree: ConfigTree, key: str) -> str:
|
|
245
|
+
if key not in tree:
|
|
246
|
+
raise AttributeError(f"{key=} not in {tree=}")
|
|
247
|
+
|
|
248
|
+
path = [key]
|
|
249
|
+
parentinfo = tree._ConfigTree__parentinfo # pylint: disable=protected-access
|
|
250
|
+
while parentinfo:
|
|
251
|
+
path.insert(0, parentinfo.key)
|
|
252
|
+
parentinfo = (
|
|
253
|
+
parentinfo.parent._ConfigTree__parentinfo # pylint: disable=protected-access
|
|
254
|
+
)
|
|
255
|
+
return "->".join(path)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def tree_load(fn: str) -> ConfigTree | Any:
|
|
259
|
+
yaml = ruamel.yaml.YAML()
|
|
260
|
+
with open(fn, encoding="utf-8") as f:
|
|
261
|
+
try:
|
|
262
|
+
data = yaml.load(f)
|
|
263
|
+
except ruamel.yaml.parser.ParserError as ex:
|
|
264
|
+
die(f"\n{ex.problem_mark.name}:{ex.problem_mark.line + 1}: {ex}")
|
|
265
|
+
return parse_yaml(data, fn)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def tree_walk(config: ConfigTree, indent: str = "") -> Generator:
|
|
269
|
+
"""Walk configuration tree depth-first, yielding:
|
|
270
|
+
- Key
|
|
271
|
+
- Value
|
|
272
|
+
- Full name of the key
|
|
273
|
+
- Tracking information
|
|
274
|
+
- Types
|
|
275
|
+
- Indentation string that increases by ``" "`` for each level
|
|
276
|
+
- Descriptor
|
|
277
|
+
"""
|
|
278
|
+
for key, value in sorted(config.items()):
|
|
279
|
+
yield key, value, tree_keyname(config, key), tree_keyinfo(
|
|
280
|
+
config,
|
|
281
|
+
key,
|
|
282
|
+
), tree_types(config, key), indent, tree_get_descriptor(config, key)
|
|
283
|
+
if isinstance(value, ConfigTree):
|
|
284
|
+
for key, value, fullname, info, types, subindent, desc in tree_walk(
|
|
285
|
+
value, indent + " "
|
|
286
|
+
):
|
|
287
|
+
yield key, value, fullname, info, types, subindent, desc
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def tree_dump(tree: ConfigTree) -> str:
|
|
291
|
+
"""Print the configuration tree in a human-readable format."""
|
|
292
|
+
text = []
|
|
293
|
+
|
|
294
|
+
grey, reset = "\033[90m", "\033[0m"
|
|
295
|
+
|
|
296
|
+
def write(line: str, internal: bool = False) -> None:
|
|
297
|
+
output = f"{grey}{line}{reset}" if internal else line
|
|
298
|
+
text.append(output)
|
|
299
|
+
|
|
300
|
+
cwd = os.getcwd()
|
|
301
|
+
home = os.path.expanduser("~")
|
|
302
|
+
|
|
303
|
+
def shorten_filename_line(info: KeyInfo) -> str:
|
|
304
|
+
if (
|
|
305
|
+
tree.verbosity < Verbosity.DEBUG
|
|
306
|
+
and Path(info.file).absolute().dirname()
|
|
307
|
+
== Path(__file__).absolute().dirname()
|
|
308
|
+
):
|
|
309
|
+
return "csspin"
|
|
310
|
+
|
|
311
|
+
if info.file.startswith(cwd):
|
|
312
|
+
return f"{info.file[len(cwd) + 1 :]}:{info.line}" # noqa: E203
|
|
313
|
+
if info.file.startswith(home):
|
|
314
|
+
return f"~{info.file[len(home):]}:{info.line}"
|
|
315
|
+
return f"{info.file}:{info.line}"
|
|
316
|
+
|
|
317
|
+
filterout = (DESCRIPTOR_REGISTRY["object"], ModuleType)
|
|
318
|
+
tagcolumn = max(
|
|
319
|
+
(
|
|
320
|
+
len(shorten_filename_line(info) + ":")
|
|
321
|
+
for _, _, _, info, _, _, _ in tree_walk(tree)
|
|
322
|
+
),
|
|
323
|
+
default=0,
|
|
324
|
+
)
|
|
325
|
+
separator = "|"
|
|
326
|
+
|
|
327
|
+
def build_tree_dump(tree: ConfigTree, key_prefix: str = "", ind: str = "") -> None:
|
|
328
|
+
"""Build the tree dump of passed configuration tree.
|
|
329
|
+
|
|
330
|
+
:param tree: The tree to iterate through
|
|
331
|
+
:param key_prefix: The prefix of the keys (only applicable for recursive
|
|
332
|
+
calls to align with the top-level data type, e.g. "list")
|
|
333
|
+
:param ind: Additional indention for the output
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
for key, value, _, info, types, indent, _ in tree_walk(tree):
|
|
337
|
+
is_internal = "internal" in types
|
|
338
|
+
if is_internal and not tree.verbosity > Verbosity.NORMAL:
|
|
339
|
+
continue
|
|
340
|
+
indent += ind
|
|
341
|
+
key = key_prefix + key
|
|
342
|
+
|
|
343
|
+
tag = shorten_filename_line(info) + ":"
|
|
344
|
+
space = (tagcolumn - len(tag) + 1) * " "
|
|
345
|
+
|
|
346
|
+
if isinstance(value, filterout):
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
if isinstance(value, list):
|
|
350
|
+
if value:
|
|
351
|
+
write(f"{tag}{space}{separator}{indent}{key}:", is_internal)
|
|
352
|
+
blank_location = len(f"{tag}{space}") * " "
|
|
353
|
+
for item in value:
|
|
354
|
+
if isinstance(item, filterout):
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
if isinstance(item, str):
|
|
358
|
+
write(
|
|
359
|
+
f"{blank_location}{separator}{indent} - {repr(item)}",
|
|
360
|
+
is_internal,
|
|
361
|
+
)
|
|
362
|
+
elif isinstance(item, ConfigTree):
|
|
363
|
+
build_tree_dump(item, key_prefix="- ", ind=indent + " ")
|
|
364
|
+
else:
|
|
365
|
+
write(f"{tag}{space}{separator}{indent}{key}: []", is_internal)
|
|
366
|
+
elif isinstance(value, dict):
|
|
367
|
+
if value:
|
|
368
|
+
write(f"{tag}{space}{separator}{indent}{key}:", is_internal)
|
|
369
|
+
else:
|
|
370
|
+
write(f"{tag}{space}{separator}{indent}{key}: {{}}", is_internal)
|
|
371
|
+
else:
|
|
372
|
+
write(
|
|
373
|
+
f"{tag}{space}{separator}{indent}{key}: {repr(value)}", is_internal
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
build_tree_dump(tree)
|
|
377
|
+
return "\n".join(text)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def directive_append(target: ConfigTree, key: Hashable, value: Any) -> None:
|
|
381
|
+
if key not in target:
|
|
382
|
+
die(f"{key=} not in passed target tree.")
|
|
383
|
+
if not isinstance(target[key], list):
|
|
384
|
+
die("Can't append value to tree since it's target not type 'list'")
|
|
385
|
+
if isinstance(value, list):
|
|
386
|
+
target[key].extend(value)
|
|
387
|
+
else:
|
|
388
|
+
target[key].append(value)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def directive_prepend(target: ConfigTree, key: Hashable, value: Any) -> None:
|
|
392
|
+
if key not in target:
|
|
393
|
+
die(f"{key=} not in passed target tree.")
|
|
394
|
+
if not isinstance(target[key], list):
|
|
395
|
+
die("Can't prepend value to tree since it's target is not type 'list'")
|
|
396
|
+
if isinstance(value, list):
|
|
397
|
+
target[key][0:0] = value
|
|
398
|
+
else:
|
|
399
|
+
target[key].insert(0, value)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def directive_interpolate(target: ConfigTree, key: Hashable, value: Any) -> None:
|
|
403
|
+
tree_update_key(target, key, interpolate1(value))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def rpad(seq: list, length: int, padding: int | None = None) -> list:
|
|
407
|
+
"""Right pad a sequence to become at least `length` long with `padding` items.
|
|
408
|
+
|
|
409
|
+
Post-condition ``len(rpad(seq, n)) >= n``.
|
|
410
|
+
|
|
411
|
+
Example:
|
|
412
|
+
|
|
413
|
+
>>> rpad([1], 3)
|
|
414
|
+
[None, None, 1]
|
|
415
|
+
|
|
416
|
+
"""
|
|
417
|
+
while True:
|
|
418
|
+
pad_length = length - len(seq)
|
|
419
|
+
if pad_length > 0:
|
|
420
|
+
seq.insert(0, padding)
|
|
421
|
+
else:
|
|
422
|
+
break
|
|
423
|
+
return seq
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def tree_merge(target: ConfigTree, source: ConfigTree) -> None:
|
|
427
|
+
"""Merge the 'source' configuration tree into 'target'.
|
|
428
|
+
|
|
429
|
+
Merging is done by adding values from 'source' to 'target' if they
|
|
430
|
+
do not yet exist. Subtrees are merged recursively. In a second
|
|
431
|
+
pass, special keys of the form "directive key" (i.e. separated by
|
|
432
|
+
space) in 'target' are processed. Supported directives include
|
|
433
|
+
"append" for adding values or lists to a list, and "interpolate"
|
|
434
|
+
for replacing configuration variables.
|
|
435
|
+
"""
|
|
436
|
+
if not isinstance(target, ConfigTree):
|
|
437
|
+
die("Can't merge tree's since 'target' is not type 'csspin.tree.ConfigTree'") # type: ignore[unreachable] # noqa: E501
|
|
438
|
+
if not isinstance(source, ConfigTree):
|
|
439
|
+
die("Can't merge tree's since 'source' is not type 'csspin.tree.ConfigTree'") # type: ignore[unreachable] # noqa: E501
|
|
440
|
+
|
|
441
|
+
if not hasattr(target, "_ConfigTree__schema") and hasattr(
|
|
442
|
+
source, "_ConfigTree__schema"
|
|
443
|
+
):
|
|
444
|
+
setattr(
|
|
445
|
+
target,
|
|
446
|
+
"_ConfigTree__schema",
|
|
447
|
+
source._ConfigTree__schema, # pylint: disable=protected-access
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
for key, value in source.items():
|
|
451
|
+
if target.get(key, None) is None:
|
|
452
|
+
try:
|
|
453
|
+
target[key] = value
|
|
454
|
+
tree_set_keyinfo(target, key, tree_keyinfo(source, key))
|
|
455
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
456
|
+
debug(format_exc())
|
|
457
|
+
die(f"Can't merge {value=} into '{target=}[{key=}]'")
|
|
458
|
+
elif isinstance(value, ConfigTree):
|
|
459
|
+
tree_merge(target[key], value)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def tree_apply_certain(tree: ConfigTree, keys: Iterable[str] | None = None) -> None:
|
|
463
|
+
"""Apply directives to ceratin keys
|
|
464
|
+
|
|
465
|
+
:param target: The target tree
|
|
466
|
+
:type target: ConfigTree
|
|
467
|
+
:param keys: The keys, to which directives must be applied.
|
|
468
|
+
Default value is None, what makes the function apply
|
|
469
|
+
directives to all keys without exceptions. Alternatively,
|
|
470
|
+
empty Iterable will result in not applying any directives.
|
|
471
|
+
:type keys: Iterable[str] | None"""
|
|
472
|
+
|
|
473
|
+
# Note that we need a list for the iteration, as we remove directive keys on
|
|
474
|
+
# the fly.
|
|
475
|
+
for clause, value in list(tree.items()):
|
|
476
|
+
directive, key = rpad(clause.split(maxsplit=1), 2)
|
|
477
|
+
if keys is not None and key not in keys:
|
|
478
|
+
continue
|
|
479
|
+
if fn := globals().get(f"directive_{directive}", None):
|
|
480
|
+
fn(tree, key, value)
|
|
481
|
+
del tree[clause]
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def tree_apply_directives(tree: ConfigTree) -> None:
|
|
485
|
+
"""Recursively walking through the tree and processing directives"""
|
|
486
|
+
|
|
487
|
+
tree_apply_certain(tree)
|
|
488
|
+
|
|
489
|
+
for _, value, _, _, types, _, _ in tree_walk(tree):
|
|
490
|
+
if isinstance(value, ConfigTree) and "internal" not in types:
|
|
491
|
+
tree_apply_directives(value)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def tree_update(target: ConfigTree, source: ConfigTree, keep: str | tuple = ()) -> None:
|
|
495
|
+
"""This will *overwrite*, not fill up, like tree_merge.
|
|
496
|
+
|
|
497
|
+
Key-Value pairs with origin global.yaml will not be updated.
|
|
498
|
+
|
|
499
|
+
:param target: The target tree which is to be modified
|
|
500
|
+
:type target: ConfigTree
|
|
501
|
+
:param source: The tree containing the files to insert into the target tree
|
|
502
|
+
:type source: ConfigTree
|
|
503
|
+
:param keep: Ignore keys with KeyInfo.file equals values in ``keep``,
|
|
504
|
+
defaults to ()
|
|
505
|
+
:type keep: str | tuple, optional
|
|
506
|
+
"""
|
|
507
|
+
|
|
508
|
+
if not isinstance(keep, (str, tuple)):
|
|
509
|
+
raise TypeError("keep must be type 'str' or 'tuple'.")
|
|
510
|
+
|
|
511
|
+
# import csspin.schema here to avoid cyclic import
|
|
512
|
+
from csspin import schema # pylint: disable=cyclic-import
|
|
513
|
+
|
|
514
|
+
if (
|
|
515
|
+
isinstance(keep, str) and (keep := (keep,))
|
|
516
|
+
) or "global.yaml" not in keep: # pylint: disable=condition-evals-to-constant
|
|
517
|
+
keep += ("global.yaml",)
|
|
518
|
+
|
|
519
|
+
for key, value in source.items():
|
|
520
|
+
ki = tree_keyinfo(source, key)
|
|
521
|
+
if "internal" in tree_types(target, key) and tree_keyinfo(
|
|
522
|
+
target, key
|
|
523
|
+
).file.removesuffix("_schema.yaml") != ki.file.removesuffix(".py"):
|
|
524
|
+
# Ensure that only plugin defaults can override internal properties.
|
|
525
|
+
die(f"Can't override internal property {key}")
|
|
526
|
+
try:
|
|
527
|
+
if isinstance(value, dict):
|
|
528
|
+
if key not in target:
|
|
529
|
+
target[key] = ConfigTree()
|
|
530
|
+
tree_update(target[key], value, keep=keep) # type: ignore[arg-type]
|
|
531
|
+
tree_set_keyinfo(target, key, ki)
|
|
532
|
+
else:
|
|
533
|
+
tree_update(target[key], value, keep=keep) # type: ignore[arg-type]
|
|
534
|
+
elif key not in target or (
|
|
535
|
+
(ki_file := tree_keyinfo(target, key).file)
|
|
536
|
+
and all(pattern not in ki_file for pattern in keep)
|
|
537
|
+
):
|
|
538
|
+
target[key] = value
|
|
539
|
+
tree_set_keyinfo(target, key, ki)
|
|
540
|
+
except (TypeError, schema.SchemaError) as exc:
|
|
541
|
+
debug(format_exc())
|
|
542
|
+
die(f"{ki.file}:{ki.line}: cannot assign '{value}' to '{key}': {exc}")
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def tree_update_properties(
|
|
546
|
+
cfg: ConfigTree,
|
|
547
|
+
override_properties: Iterable[str] = (),
|
|
548
|
+
prepend_properties: Iterable[str] = (),
|
|
549
|
+
append_properties: Iterable[str] = (),
|
|
550
|
+
) -> None:
|
|
551
|
+
"""Modify the configuration tree, by overriding, prepending and
|
|
552
|
+
appending settings to existing properties.
|
|
553
|
+
|
|
554
|
+
Properties must be a tuple of strings in the format "some.key=new_value".
|
|
555
|
+
"""
|
|
556
|
+
|
|
557
|
+
def modify_property(prop: str, func: Callable) -> None:
|
|
558
|
+
"""Modify a config tree value using given func"""
|
|
559
|
+
try:
|
|
560
|
+
fullname, value = prop.split("=", 1)
|
|
561
|
+
except ValueError:
|
|
562
|
+
debug(format_exc())
|
|
563
|
+
die(f"Value assignment to {prop} invalid (hint: {prop}=foo)")
|
|
564
|
+
|
|
565
|
+
path = list(fullname.split("."))
|
|
566
|
+
scope = cfg
|
|
567
|
+
while len(path) > 1:
|
|
568
|
+
try:
|
|
569
|
+
scope = getattr(scope, path.pop(0))
|
|
570
|
+
except AttributeError:
|
|
571
|
+
debug(format_exc())
|
|
572
|
+
warn(f"Can't set unknown property '{fullname}' - skipping!")
|
|
573
|
+
return
|
|
574
|
+
if path[0] not in scope:
|
|
575
|
+
warn(f"Can't set unknown property '{fullname}' - skipping!")
|
|
576
|
+
return
|
|
577
|
+
|
|
578
|
+
if "internal" in tree_types(scope, path[0]):
|
|
579
|
+
die(f"Can't override internal property {prop}")
|
|
580
|
+
|
|
581
|
+
func(scope, path[0], ruamel.yaml.YAML().load(interpolate1(value)))
|
|
582
|
+
# Set the value source to "command-line"
|
|
583
|
+
tree_set_keyinfo(scope, path[0], KeyInfo("command-line", "0"))
|
|
584
|
+
|
|
585
|
+
# Update the configuration tree based on environment variables
|
|
586
|
+
for prop in (
|
|
587
|
+
f"{key.replace('SPIN_TREE_', '').replace('__', '.').lower()}={value}"
|
|
588
|
+
for (key, value) in os.environ.items()
|
|
589
|
+
if key.startswith("SPIN_TREE_")
|
|
590
|
+
):
|
|
591
|
+
modify_property(prop, setattr)
|
|
592
|
+
|
|
593
|
+
# Update the configuration tree based on passed -p, --pp and --ap
|
|
594
|
+
for prop in override_properties:
|
|
595
|
+
modify_property(prop, setattr)
|
|
596
|
+
for prop in prepend_properties:
|
|
597
|
+
modify_property(prop, directive_prepend)
|
|
598
|
+
for prop in append_properties:
|
|
599
|
+
modify_property(prop, directive_append)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# Variable references are names prefixed by '$' (like $port, $version,
|
|
603
|
+
# $name etc.)
|
|
604
|
+
RE_VAR = re.compile(r"\$(\w+)")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
class YamlParser:
|
|
608
|
+
def __init__(self: YamlParser, fn: str, facts: dict, variables: dict) -> None:
|
|
609
|
+
self._facts = {
|
|
610
|
+
"win32": sys.platform == "win32",
|
|
611
|
+
"darwin": sys.platform == "darwin",
|
|
612
|
+
"linux": sys.platform.startswith("linux"),
|
|
613
|
+
"posix": os.name == "posix",
|
|
614
|
+
"nt": os.name == "nt",
|
|
615
|
+
}
|
|
616
|
+
self._var = {}
|
|
617
|
+
|
|
618
|
+
self._facts.update(facts)
|
|
619
|
+
self._var.update(variables)
|
|
620
|
+
self._fn = fn
|
|
621
|
+
|
|
622
|
+
def parse_yaml(
|
|
623
|
+
self: YamlParser,
|
|
624
|
+
data: str | int | list | dict | ruamel.yaml.comments.CommentedKeyMap | None,
|
|
625
|
+
) -> ConfigTree | int | str | list | ruamel.yaml.comments.CommentedKeyMap | None:
|
|
626
|
+
if isinstance(data, str):
|
|
627
|
+
return self.parse_str(data)
|
|
628
|
+
elif isinstance(data, list):
|
|
629
|
+
return self.parse_list(data)
|
|
630
|
+
elif isinstance(data, dict):
|
|
631
|
+
return self.parse_dict(data)
|
|
632
|
+
return data
|
|
633
|
+
|
|
634
|
+
def parse_str(self: YamlParser, data: Any) -> str:
|
|
635
|
+
def replacer(mo: re.Match) -> str:
|
|
636
|
+
return self._var.get(mo.group(1)) # type: ignore[return-value]
|
|
637
|
+
|
|
638
|
+
return RE_VAR.sub(replacer, data)
|
|
639
|
+
|
|
640
|
+
def parse_list(self: YamlParser, data: Iterable) -> list:
|
|
641
|
+
return [self.parse_yaml(x) for x in data]
|
|
642
|
+
|
|
643
|
+
def parse_dict(
|
|
644
|
+
self: YamlParser, data: ruamel.yaml.comments.CommentedKeyMap
|
|
645
|
+
) -> ConfigTree | None:
|
|
646
|
+
if not data:
|
|
647
|
+
data = {}
|
|
648
|
+
config = ConfigTree(data)
|
|
649
|
+
for key, value in data.items():
|
|
650
|
+
key = self.parse_yaml(key)
|
|
651
|
+
if " " in key:
|
|
652
|
+
# This is a directive -- lookup the appropriate
|
|
653
|
+
# handler to process it
|
|
654
|
+
directive, expression = key.split(" ", 1)
|
|
655
|
+
method = getattr(self, "directive_" + directive, self.parse_key)
|
|
656
|
+
method(key, expression, value, config)
|
|
657
|
+
else:
|
|
658
|
+
self.parse_key(key, key, value, config)
|
|
659
|
+
if hasattr(config, key):
|
|
660
|
+
if isinstance(value, dict):
|
|
661
|
+
tree_set_parent(config[key], config, key)
|
|
662
|
+
|
|
663
|
+
ki = KeyInfo(self._fn, data.lc.key(key)[0] + 1)
|
|
664
|
+
tree_set_keyinfo(config, key, ki)
|
|
665
|
+
|
|
666
|
+
# If parsing this dict resulted in a list (which can happen by
|
|
667
|
+
# using e.g. if), the result has been stored under the magic
|
|
668
|
+
# key '$' and we return that instead of the parsed dict.
|
|
669
|
+
if "$" in config:
|
|
670
|
+
config = config["$"]
|
|
671
|
+
# if the config is empty, it should be replaced by None
|
|
672
|
+
if len(config) == 0:
|
|
673
|
+
config = None # type: ignore[assignment]
|
|
674
|
+
return config
|
|
675
|
+
|
|
676
|
+
def directive_var(
|
|
677
|
+
self: YamlParser,
|
|
678
|
+
key: str,
|
|
679
|
+
expression: str,
|
|
680
|
+
value: ruamel.yaml.comments.CommentedKeyMap,
|
|
681
|
+
out: ConfigTree,
|
|
682
|
+
) -> None:
|
|
683
|
+
_value = self.parse_yaml(value)
|
|
684
|
+
if isinstance(_value, int):
|
|
685
|
+
# re.sub works with str only
|
|
686
|
+
_value = str(_value)
|
|
687
|
+
self._var[expression] = _value
|
|
688
|
+
del out[key]
|
|
689
|
+
|
|
690
|
+
def parse_key(
|
|
691
|
+
self: YamlParser,
|
|
692
|
+
key: str,
|
|
693
|
+
expression: str,
|
|
694
|
+
value: ruamel.yaml.comments.CommentedKeyMap,
|
|
695
|
+
out: ConfigTree,
|
|
696
|
+
) -> None:
|
|
697
|
+
_value = self.parse_yaml(value)
|
|
698
|
+
if isinstance(_value, str):
|
|
699
|
+
self._var[expression] = _value
|
|
700
|
+
out[key] = _value
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def parse_yaml(
|
|
704
|
+
yaml_file: Any,
|
|
705
|
+
fn: str,
|
|
706
|
+
facts: dict | None = None,
|
|
707
|
+
variables: dict | None = None,
|
|
708
|
+
) -> Any:
|
|
709
|
+
facts = facts if facts else {}
|
|
710
|
+
variables = variables if variables else {}
|
|
711
|
+
yamlparser = YamlParser(fn, facts, variables)
|
|
712
|
+
return yamlparser.parse_yaml(yaml_file)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def tree_extract_secrets(cfg: ConfigTree) -> set[str]:
|
|
716
|
+
"""Return a set of strings from ConfigTree whose descriptors are of type 'secret'"""
|
|
717
|
+
secrets = set()
|
|
718
|
+
exceptions = ("", None) # Calling replace on these strings can brake the output
|
|
719
|
+
|
|
720
|
+
for _, value, _, _, _, _, desc in tree_walk(cfg):
|
|
721
|
+
if isinstance(desc, DESCRIPTOR_REGISTRY["secret"]):
|
|
722
|
+
if value not in exceptions:
|
|
723
|
+
secrets.add(str(value))
|
|
724
|
+
|
|
725
|
+
return secrets
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def tree_inherit_internal(cfg: ConfigTree, parent_internal: bool = False) -> None:
|
|
729
|
+
"""Walk through the tree and make all subtrees
|
|
730
|
+
inherit parent's `internal` property"""
|
|
731
|
+
INTERNAL_TYPE = "internal"
|
|
732
|
+
|
|
733
|
+
for key, value in cfg.items():
|
|
734
|
+
if not tree_get_descriptor(cfg, key):
|
|
735
|
+
continue
|
|
736
|
+
|
|
737
|
+
types = tree_types(cfg, key)
|
|
738
|
+
internal = INTERNAL_TYPE in types
|
|
739
|
+
|
|
740
|
+
if parent_internal and not internal:
|
|
741
|
+
types.append(INTERNAL_TYPE)
|
|
742
|
+
tree_set_types(cfg, key, types)
|
|
743
|
+
|
|
744
|
+
if isinstance(value, ConfigTree):
|
|
745
|
+
tree_inherit_internal(value, internal or parent_internal)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def tree_ensure_descriptors(cfg: ConfigTree) -> None:
|
|
749
|
+
"""Create descriptors for those elements of config tree,
|
|
750
|
+
which are not defined in schema.yaml"""
|
|
751
|
+
if not getattr(cfg, "_ConfigTree__schema", None):
|
|
752
|
+
return
|
|
753
|
+
|
|
754
|
+
for key, value in cfg.items():
|
|
755
|
+
if not tree_get_descriptor(cfg, key):
|
|
756
|
+
desc_name = type(value).__name__.lower()
|
|
757
|
+
if desc_name not in DESCRIPTOR_REGISTRY:
|
|
758
|
+
desc_name = "object"
|
|
759
|
+
|
|
760
|
+
desc_type = DESCRIPTOR_REGISTRY[desc_name]
|
|
761
|
+
tree_set_descriptor(cfg, key, desc_type, description={"type": [desc_name]})
|
|
762
|
+
|
|
763
|
+
if isinstance(value, ConfigTree):
|
|
764
|
+
tree_ensure_descriptors(value)
|