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/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)