envstack 1.0.3__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.
envstack/envshell.py ADDED
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are met:
7
+ #
8
+ # - Redistributions of source code must retain the above copyright notice,
9
+ # this list of conditions and the following disclaimer.
10
+ #
11
+ # - Redistributions in binary form must reproduce the above copyright notice,
12
+ # this list of conditions and the following disclaimer in the documentation
13
+ # and/or other materials provided with the distribution.
14
+ #
15
+ # - Neither the name of the software nor the names of its contributors
16
+ # may be used to endorse or promote products derived from this software
17
+ # without specific prior written permission.
18
+ #
19
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
22
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
23
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
24
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
25
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
27
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
28
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29
+ # POSSIBILITY OF SUCH DAMAGE.
30
+ #
31
+
32
+ """
33
+ Contains envshell wrapper class.
34
+
35
+ Goal: drop the user into an interactive shell *without* sourcing their usual
36
+ shell rc files, so prompt vars (PS1/PROMPT) set by envstack can survive.
37
+
38
+ Notes:
39
+ - You can override the detected shell with ENVSTACK_SHELL.
40
+ Examples:
41
+ ENVSTACK_SHELL=/bin/zsh envstack --shell
42
+ ENVSTACK_SHELL=pwsh envstack --shell
43
+ """
44
+
45
+ import os
46
+ from pathlib import Path
47
+ from typing import List
48
+
49
+ from . import config
50
+ from .wrapper import Wrapper
51
+
52
+
53
+ def _basename(p: str) -> str:
54
+ """Return the lowercase basename of a path, robustly."""
55
+ try:
56
+ return Path(p).name.lower()
57
+ except Exception:
58
+ return os.path.basename(p).lower()
59
+
60
+
61
+ def _detect_shell_argv() -> List[str]:
62
+ """
63
+ Return argv list for a *clean* interactive shell.
64
+ """
65
+ shell = os.environ.get("ENVSTACK_SHELL", config.SHELL)
66
+
67
+ if os.name == "nt":
68
+ # Prefer COMSPEC if it looks like cmd.exe; otherwise allow pwsh/powershell.
69
+ comspec = os.environ.get("COMSPEC", "cmd.exe")
70
+
71
+ # If user explicitly asked for pwsh/powershell, honor it.
72
+ base = _basename(shell)
73
+ if base in ("pwsh", "pwsh.exe"):
74
+ return [shell, "-NoExit", "-NoProfile"]
75
+ if base in ("powershell", "powershell.exe"):
76
+ return [shell, "-NoExit", "-NoProfile"]
77
+
78
+ # Otherwise use cmd.exe, with /K to keep it open.
79
+ # (Even if config.SHELL returned "cmd", use COMSPEC so we get the real path.)
80
+ return [comspec, "/K"]
81
+
82
+ # POSIX shells
83
+ base = _basename(shell)
84
+
85
+ # bash: skip /etc/profile, ~/.bash_profile, ~/.bashrc, but stay interactive
86
+ if base == "bash":
87
+ return [shell, "--noprofile", "--norc", "-i"]
88
+
89
+ # zsh: -f skips zshrcs; -i for interactive
90
+ if base == "zsh":
91
+ return [shell, "-f", "-i"]
92
+
93
+ # tcsh/csh: -f skips rc; interactive by default when attached to a tty
94
+ if base in ("tcsh", "csh"):
95
+ return [shell, "-f"]
96
+
97
+ # fish: --no-config skips config.fish; interactive by default
98
+ if base == "fish":
99
+ return [shell, "--no-config"]
100
+
101
+ # Fallback: try interactive flag if common; otherwise just exec the shell
102
+ # (Most shells become interactive when connected to a tty anyway.)
103
+ return [shell, "-i"]
104
+
105
+
106
+ class EnvshellWrapper(Wrapper):
107
+ """A wrapper that spawns an interactive shell with the environment set."""
108
+
109
+ def __init__(self, *args, **kwargs):
110
+ super(EnvshellWrapper, self).__init__(*args, **kwargs)
111
+ self.shell = False # exec the shell directly
112
+
113
+ def executable(self):
114
+ """
115
+ Kept for interface compatibility. The actual argv is produced in
116
+ get_subprocess_command().
117
+ """
118
+ return ""
119
+
120
+ def get_subprocess_command(self, env):
121
+ """
122
+ Override to return argv list for subprocess.Popen(..., shell=False).
123
+ """
124
+ return _detect_shell_argv()
125
+
126
+ def get_shell_prompt(self) -> str:
127
+ """
128
+ Return the environment variable that controls the shell prompt and its
129
+ desired value.
130
+ """
131
+ if os.name == "nt":
132
+ return ("PROMPT", "$E[32m(${ENV:=${STACK}})$E[0m $P$G ")
133
+ else:
134
+ return ("PS1", r"\[\e[32m\](${ENV:=${STACK}})\[\e[0m\] \w\$ ")
135
+
136
+ def get_subprocess_env(self):
137
+ """
138
+ Override to inject PS1/PROMPT if not already set.
139
+ """
140
+ prompt_env, prompt_value = self.get_shell_prompt()
141
+ if prompt_env not in self.env:
142
+ self.env[prompt_env] = prompt_value
143
+ return super().get_subprocess_env()
envstack/exceptions.py ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are met:
7
+ #
8
+ # - Redistributions of source code must retain the above copyright notice,
9
+ # this list of conditions and the following disclaimer.
10
+ #
11
+ # - Redistributions in binary form must reproduce the above copyright notice,
12
+ # this list of conditions and the following disclaimer in the documentation
13
+ # and/or other materials provided with the distribution.
14
+ #
15
+ # - Neither the name of the software nor the names of its contributors
16
+ # may be used to endorse or promote products derived from this software
17
+ # without specific prior written permission.
18
+ #
19
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
22
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
23
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
24
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
25
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
27
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
28
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29
+ # POSSIBILITY OF SUCH DAMAGE.
30
+ #
31
+
32
+ __doc__ = """
33
+ Contains custom exceptions.
34
+ """
35
+
36
+
37
+ class CyclicalReference(Exception):
38
+ """Exception for circular references."""
39
+
40
+ pass
41
+
42
+
43
+ class DuplicateFieldValues(Exception):
44
+ """Exception for template paths with duplicate values for the same field."""
45
+
46
+ pass
47
+
48
+
49
+ class InvalidPath(Exception):
50
+ """Exception for paths that do not match a template."""
51
+
52
+ pass
53
+
54
+
55
+ class InvalidSource(Exception):
56
+ """Exception for invalid .env file."""
57
+
58
+ pass
59
+
60
+
61
+ class InvalidSyntax(Exception):
62
+ """Exception for invalid .env file syntax."""
63
+
64
+ pass
65
+
66
+
67
+ class MissingFieldError(Exception):
68
+ """Custom exception class for missing required field values."""
69
+
70
+ pass
71
+
72
+
73
+ class TemplateNotFound(Exception):
74
+ """Custom exception class for missing Templates."""
75
+
76
+ pass
77
+
78
+
79
+ class WriteError(Exception):
80
+ """Custom exception class for errors during export operations."""
81
+
82
+ pass
envstack/logger.py ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are met:
7
+ #
8
+ # - Redistributions of source code must retain the above copyright notice,
9
+ # this list of conditions and the following disclaimer.
10
+ #
11
+ # - Redistributions in binary form must reproduce the above copyright notice,
12
+ # this list of conditions and the following disclaimer in the documentation
13
+ # and/or other materials provided with the distribution.
14
+ #
15
+ # - Neither the name of the software nor the names of its contributors
16
+ # may be used to endorse or promote products derived from this software
17
+ # without specific prior written permission.
18
+ #
19
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
22
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
23
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
24
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
25
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
27
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
28
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29
+ # POSSIBILITY OF SUCH DAMAGE.
30
+ #
31
+
32
+ import logging
33
+
34
+ from envstack.config import LOG_LEVEL
35
+
36
+
37
+ class Logger(logging.Logger):
38
+ """Custom logger class."""
39
+
40
+ def setLevel(self, level):
41
+ if isinstance(level, str):
42
+ level = getattr(logging, level.upper(), logging.NOTSET)
43
+ super().setLevel(level)
44
+
45
+
46
+ log = Logger("envstack")
47
+ log.setLevel(LOG_LEVEL)
48
+ log.addHandler(logging.NullHandler())
49
+
50
+
51
+ def setup_stream_handler():
52
+ """Adds a new stdout stream handler."""
53
+ for h in log.handlers:
54
+ if h.name == log.name and "StreamHandler" in str(h):
55
+ del log.handlers[log.handlers.index(h)]
56
+ stream_hanlder = logging.StreamHandler()
57
+ stream_hanlder.set_name(log.name)
58
+ stream_hanlder.setFormatter(
59
+ logging.Formatter("%(asctime)s:%(name)s:%(levelname)s - %(message)s")
60
+ )
61
+ log.addHandler(stream_hanlder)
envstack/node.py ADDED
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are met:
7
+ #
8
+ # - Redistributions of source code must retain the above copyright notice,
9
+ # this list of conditions and the following disclaimer.
10
+ #
11
+ # - Redistributions in binary form must reproduce the above copyright notice,
12
+ # this list of conditions and the following disclaimer in the documentation
13
+ # and/or other materials provided with the distribution.
14
+ #
15
+ # - Neither the name of the software nor the names of its contributors
16
+ # may be used to endorse or promote products derived from this software
17
+ # without specific prior written permission.
18
+ #
19
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
22
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
23
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
24
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
25
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
27
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
28
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29
+ # POSSIBILITY OF SUCH DAMAGE.
30
+ #
31
+
32
+ __doc__ = """
33
+ Contains custom yaml constructor classes and functions.
34
+ """
35
+
36
+ import hashlib
37
+ import os
38
+ import re
39
+ import string
40
+
41
+ import yaml
42
+
43
+ from envstack import util
44
+ from envstack.encrypt import AESGCMEncryptor, Base64Encryptor, FernetEncryptor
45
+
46
+
47
+ class Template(string.Template, str):
48
+ def __init__(self, value):
49
+ super().__init__(value)
50
+ self.value = value
51
+
52
+ def __repr__(self):
53
+ return f"Template(value={self.value})"
54
+
55
+
56
+ class BaseNode(yaml.YAMLObject):
57
+ """Base class for custom yaml nodes."""
58
+
59
+ yaml_tag = None
60
+
61
+ def __init__(self, value):
62
+ self.value = value
63
+
64
+ def __repr__(self):
65
+ return f"{self.__class__.__name__}('{self.value}')"
66
+
67
+ def __str__(self):
68
+ return str(self.value)
69
+
70
+ def __eq__(self, other):
71
+ if isinstance(other, self.__class__):
72
+ return self.value == other.value
73
+ return False
74
+
75
+ @classmethod
76
+ def from_yaml(cls, loader: object, node: yaml.Node):
77
+ return cls(node.value)
78
+
79
+ @classmethod
80
+ def to_yaml(cls, dumper: object, node: yaml.Node):
81
+ return dumper.represent_scalar(cls.yaml_tag, node.value)
82
+
83
+ def resolve(self, env: dict = os.environ):
84
+ """Returns the decoded value."""
85
+ return self.value
86
+
87
+
88
+ class Base64Node(BaseNode):
89
+ """Base64 encoded string node."""
90
+
91
+ yaml_tag = "!base64"
92
+
93
+ def __init__(self, value):
94
+ super().__init__(value)
95
+ self.original_value = None
96
+
97
+ @classmethod
98
+ def from_yaml(cls, loader: object, node: yaml.Node):
99
+ """Returns a new Base64Node instance."""
100
+ node = cls(node.value)
101
+ node.original_value = node.value
102
+ return node
103
+
104
+ @classmethod
105
+ def to_yaml(cls, dumper: object, node: yaml.Node):
106
+ """Encrypts the value before writing to yaml."""
107
+ if node.value == node.original_value:
108
+ encrypted = node.value
109
+ elif isinstance(node.value, cls):
110
+ encrypted = str(node.value)
111
+ else:
112
+ encrypted = Base64Encryptor().encrypt(node.value)
113
+ return dumper.represent_scalar(cls.yaml_tag, encrypted)
114
+
115
+ def resolve(self, env: dict = os.environ):
116
+ """Returns base64 decoded value."""
117
+ return Base64Encryptor().decrypt(self.value)
118
+
119
+
120
+ class MD5Node(BaseNode):
121
+ """MD5 hash node."""
122
+
123
+ yaml_tag = "!md5"
124
+
125
+ @classmethod
126
+ def from_yaml(cls, loader: object, node: yaml.Node):
127
+ """Returns a new MD5Node instance."""
128
+ return cls(node.value)
129
+
130
+ @classmethod
131
+ def to_yaml(cls, dumper: object, node: yaml.Node):
132
+ """Encrypts the value before writing to yaml."""
133
+ md5_hash = hashlib.md5(node.value.encode()).hexdigest()
134
+ return dumper.represent_scalar(cls.yaml_tag, md5_hash)
135
+
136
+
137
+ class EncryptedNode(BaseNode):
138
+ """Default encryption node. Supports multiple encryption schemes.
139
+ Favors AES-GCM, then Fernet, then base64."""
140
+
141
+ yaml_tag = "!encrypt"
142
+
143
+ def __init__(self, value):
144
+ super().__init__(value)
145
+ self.original_value = None
146
+
147
+ @classmethod
148
+ def encryptor(cls, env: dict = os.environ):
149
+ """Returns the encryptor class based on the environment."""
150
+ if env.get(AESGCMEncryptor.KEY_VAR_NAME):
151
+ return AESGCMEncryptor(env=env)
152
+ elif env.get(FernetEncryptor.KEY_VAR_NAME):
153
+ return FernetEncryptor(env=env)
154
+ else:
155
+ return Base64Encryptor()
156
+
157
+ @classmethod
158
+ def from_yaml(cls, loader: object, node: yaml.Node):
159
+ """Returns a new EncryptedNode instance."""
160
+ node = cls(node.value)
161
+ node.original_value = node.value
162
+ return node
163
+
164
+ @classmethod
165
+ def to_yaml(cls, dumper: object, node: yaml.Node):
166
+ """Encrypts the value before writing (do not double encrypt)."""
167
+ if node.value == node.original_value:
168
+ encrypted = node.value
169
+ elif isinstance(node.value, cls):
170
+ encrypted = str(node.value)
171
+ else:
172
+ encrypted = cls.encryptor().encrypt(node.value)
173
+ return dumper.represent_scalar(cls.yaml_tag, encrypted)
174
+
175
+ def resolve(self, env: dict = os.environ):
176
+ """Returns the decrypted original value, preserving original type.
177
+
178
+ :param env: environment with encryption keys.
179
+ :return: decrypted value.
180
+ """
181
+ try:
182
+ value = self.encryptor(env=env).decrypt(self.value)
183
+ except Exception:
184
+ value = self.value
185
+ return util.safe_eval(value)
186
+
187
+
188
+ class AESGCMNode(BaseNode):
189
+ """Default encrypted node using AES-GCM."""
190
+
191
+ yaml_tag = "!aesgcm"
192
+
193
+ def __init__(self, value):
194
+ super().__init__(value)
195
+ self.original_value = None
196
+
197
+ @classmethod
198
+ def from_yaml(cls, loader: object, node: yaml.Node):
199
+ """Returns a new AESGCMNode instance."""
200
+ node = cls(node.value)
201
+ node.original_value = node.value
202
+ return node
203
+
204
+ @classmethod
205
+ def to_yaml(cls, dumper: object, node: yaml.Node):
206
+ """Encrypts the value before writing to yaml."""
207
+ if node.value == node.original_value:
208
+ encrypted = node.value
209
+ elif isinstance(node.value, cls):
210
+ encrypted = str(node.value)
211
+ else:
212
+ encrypted = AESGCMEncryptor().encrypt(node.value)
213
+ return dumper.represent_scalar(cls.yaml_tag, encrypted)
214
+
215
+ def resolve(self, env: dict = os.environ):
216
+ """Returns the decrypted value."""
217
+ return AESGCMEncryptor(env=env).decrypt(self.value)
218
+
219
+
220
+ class FernetNode(BaseNode):
221
+ """Default encrypted node using Fernet."""
222
+
223
+ yaml_tag = "!fernet"
224
+
225
+ def __init__(self, value):
226
+ super().__init__(value)
227
+ self.original_value = None
228
+
229
+ @classmethod
230
+ def from_yaml(cls, loader: object, node: yaml.Node):
231
+ """Returns a new FernetNode instance."""
232
+ node = cls(node.value)
233
+ node.original_value = node.value
234
+ return node
235
+
236
+ @classmethod
237
+ def to_yaml(cls, dumper: object, node: yaml.Node):
238
+ """Encrypts the value before writing to yaml."""
239
+ if node.value == node.original_value:
240
+ encrypted = node.value
241
+ elif isinstance(node.value, cls):
242
+ encrypted = str(node.value)
243
+ else:
244
+ encrypted = FernetEncryptor().encrypt(node.value)
245
+ return dumper.represent_scalar(cls.yaml_tag, encrypted)
246
+
247
+ def resolve(self, env: dict = os.environ):
248
+ """Returns the decrypted value."""
249
+ return FernetEncryptor(env=env).decrypt(self.value)
250
+
251
+
252
+ class CustomLoader(yaml.SafeLoader):
253
+ """Custom Loader class to preserve order of keys and ensure required
254
+ keys are first in the mapping."""
255
+
256
+ required_keys = {"include", "all", "darwin", "linux", "windows"}
257
+
258
+ def construct_mapping(self, node: yaml.Node, deep: bool = False):
259
+ """Construct a mapping from a YAML node, preserving the order of keys
260
+ and ensuring"""
261
+ # keep YAML merge keys (<<) working
262
+ self.flatten_mapping(node)
263
+
264
+ mapping = {}
265
+
266
+ for key_node, value_node in node.value:
267
+ # never implicit-resolve scalar KEYS (YES/NO/ON/OFF/null/date/etc)
268
+ if isinstance(key_node, yaml.ScalarNode):
269
+ key = key_node.value
270
+ else:
271
+ key = self.construct_object(key_node, deep=deep)
272
+
273
+ value = self.construct_object(value_node, deep=deep)
274
+ mapping[key] = value
275
+
276
+ # preserve order of keys and ensure required keys are first in the mapping
277
+ for key, value in list(mapping.items()):
278
+ if key in self.required_keys:
279
+ continue
280
+ mapping[key] = value
281
+
282
+ return mapping
283
+
284
+
285
+ class CustomDumper(yaml.SafeDumper):
286
+ """
287
+ Custom Dumper class to handle anchors, references and flow style for nested
288
+ mappings.
289
+ """
290
+
291
+ _VAR_LIKE = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}")
292
+
293
+ def __init__(self, *args, **kwargs):
294
+ super(CustomDumper, self).__init__(*args, **kwargs)
295
+ self.depth = 0
296
+ self.basekey = None
297
+ self.newanchors = {}
298
+
299
+ def anchor_node(self, node: yaml.Node):
300
+ """Anchor the node and set the basekey for the node.
301
+
302
+ :param node: yaml node to anchor.
303
+ """
304
+ # increase depth on entering anchor_node
305
+ self.depth += 1
306
+
307
+ # set basekey for the node
308
+ if self.depth == 2:
309
+ assert isinstance(node, yaml.ScalarNode), (
310
+ "yaml node not a string: %s" % node
311
+ )
312
+ self.basekey = str(node.value)
313
+ node.value = self.basekey
314
+
315
+ # set anchor for the node
316
+ if self.depth == 3:
317
+ assert self.basekey, "could not find base key for value: %s" % node
318
+ self.newanchors[node] = self.basekey
319
+
320
+ super(CustomDumper, self).anchor_node(node)
321
+ if self.newanchors:
322
+ self.anchors.update(self.newanchors)
323
+ self.newanchors.clear()
324
+
325
+ def sanitize_value(self, node: yaml.Node):
326
+ """Sanitize node value by quoting it if it starts with special characters
327
+ or contains a colon followed by a space, e.g. "@var", "|value", "key: value".
328
+
329
+ :param node: yaml node to sanitize.
330
+ """
331
+ if isinstance(node, yaml.ScalarNode):
332
+ if node.value and node.value[0] in "@|?,%`&":
333
+ node.style = '"%s"' % node.value
334
+ elif node.value and node.value[-1] == ":":
335
+ node.style = '"%s"' % node.value
336
+ elif ": " in str(node.value):
337
+ node.style = '"%s"' % node.value
338
+
339
+ def quote_vars(self, node: yaml.Node):
340
+ """Quote embedded variables the node value, e.g. LIST: [1, 2, 3, ${VAR}].
341
+
342
+ :param node: yaml node to quote.
343
+ """
344
+ if isinstance(node, yaml.ScalarNode):
345
+ if self._VAR_LIKE.match(node.value):
346
+ node.style = '"%s"' % node.value
347
+
348
+ def represent_data(self, data: dict):
349
+ """Represent data and set flow_style for nested mappings.
350
+
351
+ :param data: Data to represent.
352
+ :return: yaml node.
353
+ """
354
+ # increase depth on entering represent_data
355
+ self.depth += 1
356
+ node = super().represent_data(data)
357
+ self.depth -= 1
358
+
359
+ # use flow style for nested mappings
360
+ if isinstance(node, yaml.MappingNode) and self.depth >= 2:
361
+ node.flow_style = True
362
+ for _, value in node.value:
363
+ self.quote_vars(value)
364
+ elif isinstance(node, yaml.SequenceNode) and self.depth >= 2:
365
+ node.flow_style = True
366
+ for element in node.value:
367
+ self.quote_vars(element)
368
+ elif isinstance(node, yaml.ScalarNode):
369
+ self.sanitize_value(node)
370
+
371
+ return node
372
+
373
+
374
+ def get_keys_from_env(env: dict = os.environ):
375
+ """Return encryption keys from the environment.
376
+
377
+ :param env: Environment dictionary.
378
+ """
379
+ keys = {}
380
+ for key in [AESGCMEncryptor.KEY_VAR_NAME, FernetEncryptor.KEY_VAR_NAME]:
381
+ if key in env:
382
+ keys[key] = env[key]
383
+ return keys
384
+
385
+
386
+ def add_custom_node_type(node_type: BaseNode):
387
+ """Add custom node type to yaml. Node type must be a subclass of BaseNode,
388
+ with local implementation of from_yaml and to_yaml methods, and definition
389
+ of yaml_tag.
390
+
391
+ :param node_type: Custom node class.
392
+ """
393
+
394
+ try:
395
+ yaml.SafeLoader.add_constructor(node_type.yaml_tag, node_type.from_yaml)
396
+ yaml.SafeDumper.add_representer(node_type, node_type.to_yaml)
397
+ except Exception as e:
398
+ print(f"error adding custom node type {node_type}: {e}")
399
+
400
+
401
+ # add custom constructors and representers
402
+ custom_node_types = [
403
+ Base64Node,
404
+ EncryptedNode,
405
+ AESGCMNode,
406
+ FernetNode,
407
+ MD5Node,
408
+ ]
409
+ for node in custom_node_types:
410
+ add_custom_node_type(node)