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/util.py ADDED
@@ -0,0 +1,800 @@
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 common utility functions and classes.
34
+ """
35
+
36
+ import functools
37
+ import glob
38
+ import os
39
+ import re
40
+ import sys
41
+ import time
42
+ from ast import literal_eval
43
+ from collections import OrderedDict
44
+
45
+ import yaml
46
+
47
+ from envstack import config
48
+ from envstack.node import (
49
+ AESGCMNode,
50
+ Base64Node,
51
+ EncryptedNode,
52
+ FernetNode,
53
+ CustomLoader,
54
+ )
55
+
56
+ # default memoization cache timeout in seconds
57
+ CACHE_TIMEOUT = 5
58
+
59
+ # value for unresolvable variables
60
+ null = ""
61
+
62
+ # regular expression pattern for matching windows drive letters
63
+ drive_letter_pattern = re.compile(r"(?P<sep>[:;])?(?P<drive>[A-Z]:[/\\])")
64
+
65
+ # regular expression pattern for bash-like variable expansion
66
+ variable_pattern = re.compile(
67
+ r"\$\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([-=?])((?:\$\{[^}]+\}|[^}])*))?\}"
68
+ )
69
+
70
+ # regular expression pattern for command substitution
71
+ cmdsub_pattern = re.compile(r"^\s*\$\((?P<cmd>.*)\)\s*$", re.DOTALL)
72
+
73
+
74
+ def cache(func):
75
+ """Function decorator to memoize return data."""
76
+
77
+ cache_dict = {}
78
+
79
+ @functools.wraps(func)
80
+ def wrapper(*args, **kwargs):
81
+ key = (args, tuple(kwargs.items()))
82
+ if key in cache_dict:
83
+ result, timestamp = cache_dict[key]
84
+ if time.time() - timestamp <= CACHE_TIMEOUT:
85
+ return result
86
+ result = func(*args, **kwargs)
87
+ cache_dict[key] = (result, time.time())
88
+ return result
89
+
90
+ return wrapper
91
+
92
+
93
+ def clear_sys_path(var: str = "PYTHONPATH"):
94
+ """
95
+ Remove paths from sys.path that are in the given environment variable.
96
+
97
+ :param var: The environment variable to remove paths from.
98
+ """
99
+ for path in get_paths_from_var(var):
100
+ if path and path in sys.path:
101
+ sys.path.remove(path)
102
+
103
+
104
+ def decode_value(value: str):
105
+ """Returns a decoded value that's been encoded by a wrapper.
106
+
107
+ Decoding encoded environments can be tricky. For example, it must account
108
+ for path templates that include curly braces, e.g. path templates string
109
+ like this must be preserved:
110
+
111
+ '/path/with/{variable}'
112
+
113
+ :param value: wrapper encoded env value.
114
+ :returns: decoded value.
115
+ """
116
+ # TODO: find a better way to encode/decode wrapper envs
117
+ return (
118
+ str(value)
119
+ .replace("'[", "[")
120
+ .replace("]'", "]")
121
+ .replace('"[', "[")
122
+ .replace(']"', "]")
123
+ .replace('"{"', "{'")
124
+ .replace('"}"', "'}")
125
+ .replace("'{'", "{'")
126
+ .replace("'}'", "'}")
127
+ )
128
+
129
+
130
+ def dedupe_list(lst: list):
131
+ """
132
+ Deduplicates a list while preserving the original order. Useful for
133
+ deduplicating paths.
134
+
135
+ :param lst: The list to deduplicate.
136
+ :returns: The deduplicated list.
137
+ """
138
+ return list(OrderedDict.fromkeys(lst))
139
+
140
+
141
+ def split_posix_paths(path_str: str):
142
+ """
143
+ Splits a path string using the posix path separator.
144
+
145
+ :param path_str: The input path string.
146
+ :returns: The split path list.
147
+ """
148
+ return [p.strip() for p in path_str.split(":") if p.strip()]
149
+
150
+
151
+ def split_windows_paths(path_str: str):
152
+ """
153
+ Splits a windows-style path string that may contain a mix of colon and
154
+ semicolon delimiters, while preserving drive letter patterns. Drive letters
155
+ must be uppercase.
156
+
157
+ Example:
158
+ Input: "C:\\Program Files\\Python:D:/path2:E:/path3:/usr/local/bin"
159
+ Output: ['C:\\Program Files\\Python', 'D:/path2', 'E:/path3', '/usr/local/bin']
160
+
161
+ :param path_str: The input path string.
162
+ :returns: The split path list.
163
+ """
164
+ result = []
165
+ tokens = [p.strip() for p in path_str.split(";") if p.strip()]
166
+
167
+ for token in tokens:
168
+ # windows-style path token; insert a marker before drive letters
169
+ if re.match(r"^[A-Z]:[/\\]", token) or "\\" in token:
170
+ modified = drive_letter_pattern.sub(lambda m: "|" + m.group("drive"), token)
171
+ # split on the marker, then on colons that are not in drive-letters
172
+ result += [
173
+ p
174
+ for part in modified.split("|")
175
+ for p in re.split(
176
+ r"(?<![A-Z]):", part
177
+ ) # capture colons not in drive letters
178
+ if p
179
+ ]
180
+ else:
181
+ result += split_posix_paths(token)
182
+
183
+ return result
184
+
185
+
186
+ def split_paths(path_str: str, platform: str = config.PLATFORM):
187
+ """
188
+ Splits a path string using the platform-specific path separator.
189
+
190
+ :param path_str: The input path string.
191
+ :param platform: The platform to use.
192
+ :returns: The split path list.
193
+ """
194
+ if platform == "windows":
195
+ return split_windows_paths(path_str)
196
+ else:
197
+ return split_posix_paths(path_str)
198
+
199
+
200
+ def dedupe_paths(path_str: str, platform: str = config.PLATFORM):
201
+ """
202
+ Deduplicates paths from a colon-separated string.
203
+
204
+ :param path_str: The input path string.
205
+ :platform: The platform to use.
206
+ :returns: The deduplicated path string.
207
+ """
208
+ deduped = dedupe_list(split_paths(path_str, platform))
209
+ joiner = ";" if platform == "windows" else ":"
210
+ return joiner.join(deduped)
211
+
212
+
213
+ def detect_path(s: str):
214
+ """
215
+ Returns True if the given string looks like a filesystem path,
216
+ and False if it appears to be a URL or not a path.
217
+
218
+ This heuristic checks:
219
+ - That the string does not start with a URL scheme
220
+ - Windows drive letters or UNC paths
221
+ - POSIX absolute paths (starting with '/')
222
+ - Relative paths if they contain path separators or a file extension marker.
223
+
224
+ Note: Some valid file names (like "README") may be ambiguous.
225
+
226
+ :param s: The input string.
227
+ :returns: True if the string looks like a filesystem path.
228
+ """
229
+ s = s.strip()
230
+ if not s:
231
+ return False
232
+
233
+ # exclude variables
234
+ if s.startswith("$") or s.startswith("{") or s.endswith("}"):
235
+ return False
236
+
237
+ # exclude URL-like strings (scheme://...)
238
+ if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", s):
239
+ return False
240
+
241
+ # windows: drive-letter (absolute or relative) or UNC path
242
+ if re.match(r"^[a-zA-Z]:", s) or s.startswith(r"\\\\"):
243
+ return True
244
+
245
+ # posix: absolute path starting with "/"
246
+ if s.startswith("/"):
247
+ return True
248
+
249
+ # check for delimiter-separated paths
250
+ if ";" in s or (":" in s and s.count(":") > 1):
251
+ return True
252
+
253
+ # check if the string contains a directory separator
254
+ if "/" in s or r"\\" in s:
255
+ return True
256
+
257
+ # check if the string has a dot (likely indicating a file extension)
258
+ # if re.search(r"(?<!^)\.", s):
259
+ # return True
260
+
261
+ return False
262
+
263
+
264
+ def dict_diff(dict1: dict, dict2: dict):
265
+ """
266
+ Compare two dictionaries and return their differences.
267
+
268
+ :param dict1: First dictionary.
269
+ :param dict2: Second dictionary.
270
+ :returns: diff dict: 'added', 'removed', 'changed', and 'unchanged'.
271
+ """
272
+ added = {k: dict2[k] for k in dict2 if k not in dict1}
273
+ removed = {k: dict1[k] for k in dict1 if k not in dict2}
274
+ changed = {
275
+ k: (dict1[k], dict2[k]) for k in dict1 if k in dict2 and dict1[k] != dict2[k]
276
+ }
277
+ unchanged = {k: dict1[k] for k in dict1 if k in dict2 and dict1[k] == dict2[k]}
278
+
279
+ return {
280
+ "added": added,
281
+ "removed": removed,
282
+ "changed": changed,
283
+ "unchanged": unchanged,
284
+ }
285
+
286
+
287
+ def encode(env: dict):
288
+ """Returns environment as a dict with str encoded key/values for passing to
289
+ wrapper subprocesses.
290
+
291
+ :param env: `Env` instance or os.environ.
292
+ :param resolved: fully resolve values (default=True).
293
+ :returns: dict with bytestring key/values.
294
+ """
295
+ c = lambda v: str(v) # noqa: E731
296
+ return dict((c(k), c(v)) for k, v in env.items())
297
+
298
+
299
+ def get_paths_from_var(
300
+ var: str = "PYTHONPATH", pathsep: str = os.pathsep, reverse: bool = True
301
+ ):
302
+ """Returns a list of paths from a given pathsep separated environment
303
+ variable.
304
+
305
+ :param var: The environment variable to get paths from.
306
+ :param pathsep: The path separator to use.
307
+ :param reverse: Reverse the order of the paths.
308
+ :returns: A list of paths.
309
+ """
310
+
311
+ paths = []
312
+ value = os.environ.get(var)
313
+
314
+ if value:
315
+ paths = value.split(pathsep)
316
+
317
+ if reverse:
318
+ paths.reverse()
319
+
320
+ return paths
321
+
322
+
323
+ def get_stack_name(name: str = config.DEFAULT_NAMESPACE):
324
+ """
325
+ Returns the stack name as a string. The stack name is always the last
326
+ element in the stack list or the basename of the envstack file.
327
+
328
+ :param name: The input name, can be a string, tuple, or list.
329
+ :return: The stack name as a string.
330
+ """
331
+ if isinstance(name, (tuple, list)):
332
+ name = str(name[-1]) if name else config.DEFAULT_NAMESPACE
333
+ if isinstance(name, str):
334
+ return os.path.splitext(os.path.basename(name))[0]
335
+ else:
336
+ raise ValueError("Invalid input type. Expected string, tuple, or list.")
337
+
338
+
339
+ def evaluate_modifiers(
340
+ expression: str,
341
+ environ: dict = os.environ,
342
+ parent: dict = {},
343
+ resolving: set = None,
344
+ ):
345
+ """
346
+ Evaluates Bash-like variable expansion modifiers.
347
+
348
+ - ${VAR} → empty string if unset
349
+ - ${VAR:=default} → if unset/null, set to default and use it
350
+ - ${VAR:-default} → if unset/null, use default (do not set)
351
+ - ${VAR:?message} → error if unset/null
352
+
353
+ :param expression: The string to evaluate.
354
+ :param environ: The environment variables to use for substitution.
355
+ :param parent: Parent environment variables to use for substitution.
356
+ :param resolving: A set of currently resolving variables to prevent cycles.
357
+ :returns: The evaluated string with variables substituted.
358
+ :raises ValueError: If a variable is not set and a message is provided.
359
+ """
360
+ if resolving is None:
361
+ resolving = set()
362
+
363
+ def sanitize_value(value):
364
+ if (
365
+ isinstance(value, str)
366
+ and value.endswith("}")
367
+ and not value.startswith("${")
368
+ ):
369
+ # trim a dangling '}' when there's no matching '{'
370
+ if value.count("{") == 0 and value.count("}") == 1:
371
+ return value.rstrip("}")
372
+ return value
373
+ elif isinstance(value, str) and detect_path(value):
374
+ return dedupe_paths(value)
375
+ return value
376
+
377
+ def is_template(s: str) -> bool:
378
+ # Check if the string is a template variable like ${VAR}
379
+ return isinstance(s, str) and bool(variable_pattern.search(s))
380
+
381
+ def non_empty(s: str) -> bool:
382
+ # non-empty string, not just whitespace
383
+ return isinstance(s, str) and s != ""
384
+
385
+ def is_literal(s: str) -> bool:
386
+ # non-empty, does not contain ${...}
387
+ return isinstance(s, str) and s != "" and not is_template(s)
388
+
389
+ def substitute_variable(match):
390
+ # extract variable name, operator, and argument from the match
391
+ var_name = match.group(1)
392
+ operator = match.group(2) # '=', '-', '?', or None
393
+ argument = match.group(3) # may be None
394
+ parent_value = parent.get(var_name, "")
395
+ override = os.getenv(var_name, parent_value)
396
+ current = str(
397
+ environ.get(var_name, parent_value or "")
398
+ ) # current value we have
399
+ varstr = "${%s}" % var_name
400
+
401
+ # cycle / self-reference guard
402
+ if var_name in resolving:
403
+ if operator in ("=", "-"):
404
+ # if os/parent provides a value, that wins over the default
405
+ if override:
406
+ return str(override)
407
+ default = evaluate_modifiers(argument or "", environ, parent, resolving)
408
+ if operator == "=":
409
+ environ[var_name] = default
410
+ return str(default)
411
+ elif operator == "?":
412
+ msg = argument or f"{var_name} is not set"
413
+ raise ValueError(msg)
414
+ else: # plain ${VAR}
415
+ return str(override or "")
416
+
417
+ resolving.add(var_name)
418
+ try:
419
+ # replace literal self-references inside current (best-effort)
420
+ if current and varstr in current:
421
+ current = current.replace(varstr, override or "")
422
+
423
+ # defaults branch (:=, :-)
424
+ if operator in ("=", "-"):
425
+ # 1) explicit env/parent wins if non-empty (bash: considered "set")
426
+ if non_empty(override):
427
+ return str(override)
428
+
429
+ # 2) compute the effective value of the current entry
430
+ if is_template(current):
431
+ eff = evaluate_modifiers(current, environ, parent, resolving)
432
+ else:
433
+ eff = current or ""
434
+
435
+ # 3) if effective value is non-empty, do NOT take default
436
+ if non_empty(eff):
437
+ return str(eff)
438
+
439
+ # 4) unset or null, use default (and assign for :=)
440
+ default = evaluate_modifiers(argument or "", environ, parent, resolving)
441
+ if operator == "=":
442
+ environ[var_name] = default
443
+ return str(default)
444
+
445
+ # error branch (:?)
446
+ elif operator == "?":
447
+ if not current:
448
+ msg = argument or f"{var_name} is not set"
449
+ raise ValueError(msg)
450
+ # resolve if it contains vars
451
+ return (
452
+ evaluate_modifiers(current, environ, parent, resolving)
453
+ if variable_pattern.search(current)
454
+ else current
455
+ )
456
+
457
+ elif operator is None:
458
+ # check for command substitution
459
+ if config.ALLOW_COMMANDS and cmdsub_pattern.match(str(current)):
460
+ return str(evaluate_command(current))
461
+ if is_literal(current):
462
+ return current # file literal wins
463
+ if is_template(current):
464
+ return str(evaluate_modifiers(current, environ, parent, resolving))
465
+ # current is unset/empty, use parent/os if present, else empty
466
+ return str(override or "")
467
+
468
+ # simple ${VAR}
469
+ else:
470
+ value = current or override or ""
471
+ if variable_pattern.search(value):
472
+ value = evaluate_modifiers(value, environ, parent, resolving)
473
+ return str(value)
474
+
475
+ finally:
476
+ resolving.discard(var_name)
477
+
478
+ try:
479
+ if isinstance(expression, (int, float, bool)):
480
+ expression = str(expression)
481
+
482
+ result = variable_pattern.sub(substitute_variable, expression)
483
+
484
+ if variable_pattern.search(result):
485
+ result = evaluate_modifiers(result, environ, parent, resolving)
486
+
487
+ except RecursionError:
488
+ # treat cycles as empty string instead of crashing (like bash)
489
+ result = ""
490
+
491
+ except TypeError:
492
+ # node/list/dict handlers
493
+ if isinstance(expression, AESGCMNode):
494
+ result = expression.resolve(env=environ)
495
+ elif isinstance(expression, Base64Node):
496
+ result = expression.resolve(env=environ)
497
+ elif isinstance(expression, EncryptedNode):
498
+ result = expression.resolve(env=environ)
499
+ elif isinstance(expression, FernetNode):
500
+ result = expression.resolve(env=environ)
501
+ elif isinstance(expression, list):
502
+ result = [evaluate_modifiers(v, environ, parent) for v in expression]
503
+ elif isinstance(expression, dict):
504
+ result = {
505
+ k: evaluate_modifiers(v, environ, parent) for k, v in expression.items()
506
+ }
507
+ else:
508
+ result = expression
509
+
510
+ resolved_value = sanitize_value(result)
511
+
512
+ # command substitution
513
+ if config.ALLOW_COMMANDS and isinstance(resolved_value, str):
514
+ resolved_value = evaluate_command(resolved_value)
515
+
516
+ return resolved_value
517
+
518
+
519
+ def evaluate_command(command: str):
520
+ """
521
+ Evaluates command substitution in the given string.
522
+
523
+ PYVERSION: $(python --version)
524
+
525
+ :param command: The command string to evaluate.
526
+ :returns: The command output or original string if no command found.
527
+ """
528
+
529
+ from envstack.wrapper import capture_output
530
+
531
+ match = cmdsub_pattern.match(command)
532
+ if match:
533
+ exit_code, out, err = capture_output(match.group(1))
534
+ if exit_code == 0:
535
+ return out.strip()
536
+ else:
537
+ return err.strip() or null
538
+
539
+ return command
540
+
541
+
542
+ def load_sys_path(
543
+ var: str = "PYTHONPATH", pathsep: str = os.pathsep, reverse: bool = True
544
+ ):
545
+ """
546
+ Add paths from the given environment variable to sys.path.
547
+
548
+ :param var: The environment variable to add paths from.
549
+ :param pathsep: The path separator to use.
550
+ :param reverse: Reverse the order of the paths.
551
+ """
552
+ for path in get_paths_from_var(var, pathsep, reverse):
553
+ if path and path not in sys.path:
554
+ sys.path.insert(0, path)
555
+
556
+
557
+ def safe_eval(value: str):
558
+ """
559
+ Returns template value preserving original class. Useful for preserving
560
+ nested values in wrappers. For example, a value of "1.0" returns 1.0, and a
561
+ value of "['a', 'b']" returns ['a', 'b'].
562
+
563
+ :param value: value to evaluate.
564
+ :returns: evaluated value.
565
+ """
566
+ try:
567
+ eval_func = literal_eval
568
+ except ImportError:
569
+ # warning: security issue
570
+ eval_func = eval
571
+
572
+ if type(value) is str:
573
+ try:
574
+ return eval_func(value)
575
+ except Exception:
576
+ try:
577
+ return eval_func(decode_value(value))
578
+ except Exception:
579
+ return value
580
+
581
+ return value
582
+
583
+
584
+ def get_stacks():
585
+ """
586
+ Returns a list of all stack names found in the environment paths.
587
+ """
588
+ paths = get_paths_from_var("ENVPATH")
589
+ stacks = set()
590
+
591
+ for path in paths:
592
+ env_files = glob.glob(os.path.join(path, "*.env"))
593
+ for env_file in env_files:
594
+ file_name = os.path.basename(env_file)
595
+ stack_name = os.path.splitext(file_name)[0]
596
+ stacks.add(stack_name)
597
+
598
+ return sorted(list(stacks))
599
+
600
+
601
+ def findenv(var_name: str):
602
+ """
603
+ Returns a list of paths where the given environment var is set.
604
+
605
+ :param var_name: The environment variable to search for.
606
+ :returns: A list of paths where the variable is set.
607
+ """
608
+ from envstack.env import trace_var
609
+
610
+ paths = set()
611
+
612
+ stacks = get_stacks()
613
+
614
+ for stack in stacks:
615
+ path = trace_var(stack, var=var_name)
616
+ if path and os.path.exists(path):
617
+ paths.add(path)
618
+
619
+ return sorted(list(paths))
620
+
621
+
622
+ def print_error(file_path: str, e: Exception):
623
+ """
624
+ Prints the problematic line and a few surrounding lines for context.
625
+
626
+ :param file_path: Path to the file.
627
+ :param e: The exception.
628
+ """
629
+ try:
630
+ with open(file_path, "r") as file:
631
+ lines = file.readlines()
632
+ if hasattr(e, "problem_mark") and e.problem_mark:
633
+ line_num = e.problem_mark.line - 1
634
+ # problematic line and a few surrounding lines for context
635
+ start = max(0, line_num - 1)
636
+ end = min(len(lines), line_num + 2)
637
+ for i in range(start, end):
638
+ prefix = ">> " if i == line_num else " "
639
+ print(f"{prefix}{i + 1}: {lines[i].rstrip()}")
640
+ except Exception as ex:
641
+ print("read error:", ex)
642
+
643
+
644
+ def validate_yaml(file_path: str):
645
+ """
646
+ Loads a YAML file and prints helpful error hints if invalid.
647
+
648
+ :param file_path: Path to the YAML file to validate.
649
+ """
650
+ try:
651
+ with open(file_path, "r") as stream:
652
+ data = yaml.load(stream.read(), Loader=CustomLoader)
653
+ if not isinstance(data, dict):
654
+ raise yaml.YAMLError("invalid data structure")
655
+ return data
656
+ except OSError as e:
657
+ print(e)
658
+ except yaml.YAMLError as e:
659
+ if hasattr(e, "problem_mark") and e.problem_mark:
660
+ mark = e.problem_mark
661
+ print(f' File "{file_path}" line {mark.line + 1}, column {mark.column}:')
662
+ print_error(file_path, e)
663
+ if hasattr(e, "problem") and e.problem:
664
+ print(f"SyntaxError: {e.problem}")
665
+ else:
666
+ print(f' File "{file_path}":')
667
+ print(f"SyntaxError: {e}")
668
+
669
+ return {}
670
+
671
+
672
+ def unquote_strings(file_path: str):
673
+ """
674
+ Unquotes all the single quote strings in a given file.
675
+
676
+ :param file_path: Path to the file.
677
+ """
678
+ with open(file_path, "r") as file:
679
+ content = file.read()
680
+
681
+ updated_content = re.sub(r"'([^']*)'", r"\1", content)
682
+
683
+ with open(file_path, "w") as file:
684
+ file.write(updated_content)
685
+
686
+
687
+ def dump_yaml(file_path: str, data: dict, unquote: bool = True):
688
+ """
689
+ Dumps a dictionary to a YAML file with custom formatting:
690
+
691
+ - unquotes single quoted strings
692
+ - partitions platform data
693
+ - adds a shebang line to make the file executable
694
+ - adds an include line if it exists in the data
695
+
696
+ :param file_path: Path to the output YAML file.
697
+ :param data: The dictionary to dump.
698
+ :param unquote: Unquote single quoted strings.
699
+ """
700
+ from envstack.node import CustomDumper, yaml
701
+
702
+ partitioned_data = partition_platform_data(data)
703
+
704
+ # write the platform partidioned data add shebang
705
+ with open(file_path, "w") as file:
706
+ file.write("#!/usr/bin/env envstack\n")
707
+ if data.get("include"):
708
+ file.write(f"include: {data['include']}\n")
709
+ else:
710
+ file.write("include: []\n")
711
+ if "include" in partitioned_data:
712
+ del partitioned_data["include"]
713
+ yaml.dump(
714
+ partitioned_data,
715
+ file,
716
+ Dumper=CustomDumper,
717
+ sort_keys=True,
718
+ default_flow_style=False,
719
+ )
720
+
721
+ if os.path.exists(file_path):
722
+ # unquote the merge keys because yaml doesn't like them quoted
723
+ if unquote:
724
+ unquote_strings(file_path)
725
+
726
+ # make the env stack file executable
727
+ try:
728
+ os.chmod(file_path, 0o755)
729
+ except Exception:
730
+ pass
731
+
732
+
733
+ def partition_platform_data(data: dict):
734
+ """
735
+ Given a data dictionary with keys 'all', 'darwin', 'linux', 'windows',
736
+ this function finds which key-value pairs are common across all platforms,
737
+ and which are unique to each platform. Platform-specific values go in their
738
+ respective dicts.
739
+
740
+ :param data: dictionary to partition.
741
+ :returns: platform partitioned dictionary.
742
+ """
743
+ # ensure "all" key is present
744
+ if "all" not in data:
745
+ data["all"] = dict((k, v) for k, v in data.items() if k != "include")
746
+
747
+ # platforms of interest (darwin, linux, windows)
748
+ platforms = ["darwin", "linux", "windows"]
749
+
750
+ # get the union of keys from all platforms
751
+ all_platform_keys = set()
752
+ for p in platforms:
753
+ all_platform_keys |= data.get(p, {}).keys()
754
+
755
+ # determine which keys are common to all platforms
756
+ common_keys = []
757
+ for key in all_platform_keys:
758
+ if all(key in data[p] for p in platforms):
759
+ # get first value for comparison later
760
+ first_value = data[platforms[0]][key]
761
+ # call it common if all platforms have the same value
762
+ if all(data[p][key] == first_value for p in platforms):
763
+ common_keys.append(key)
764
+
765
+ # build a new all dict for common items
766
+ if common_keys or all_platform_keys or data.get("all"):
767
+ new_all = {}
768
+ else:
769
+ new_all = {"<<": "*all"} # avoids syntax errors when no vars are present
770
+ for k in common_keys:
771
+ if k in data["all"]:
772
+ new_all[k] = data["all"][k]
773
+ else:
774
+ new_all[k] = data[platforms[0]][k]
775
+
776
+ # keep in all anything that is platform-agnostic
777
+ for k, v in data["all"].items():
778
+ if k not in all_platform_keys:
779
+ new_all[k] = v
780
+
781
+ # build dicts for each platform with only platform‑specific keys
782
+ new_platform_dicts = {}
783
+ for p in platforms:
784
+ new_platform_dicts[p] = {"<<": "*all"}
785
+ for k, v in data.get(p, {}).items():
786
+ if k not in common_keys:
787
+ new_platform_dicts[p][k] = v
788
+
789
+ # combine with platform-specific dicts
790
+ new_data = {"all": new_all}
791
+ for p in platforms:
792
+ new_data[p] = new_platform_dicts[p]
793
+
794
+ # ensure include is present
795
+ if data.get("include"):
796
+ new_data["include"] = data["include"]
797
+ else:
798
+ new_data["include"] = []
799
+
800
+ return new_data