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/env.py ADDED
@@ -0,0 +1,997 @@
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 functions and classes for processing scoped .env files.
34
+ """
35
+
36
+ import os
37
+ import re
38
+ import string
39
+ from collections import defaultdict
40
+ from pathlib import Path
41
+
42
+ import yaml # noqa
43
+
44
+ from envstack import config, logger, path, util
45
+ from envstack.exceptions import * # noqa
46
+ from envstack.node import (
47
+ BaseNode,
48
+ EncryptedNode,
49
+ custom_node_types,
50
+ get_keys_from_env,
51
+ )
52
+
53
+ # value delimiter pattern (splits values by os.pathsep)
54
+ delimiter_pattern = re.compile("(?![^{]*})[;:]+")
55
+
56
+ # stores environment when calling envstack.save()
57
+ saved_environ = None
58
+
59
+ # stores seen stack names when getting sources
60
+ seen_stacks = set()
61
+
62
+
63
+ class Scope(path.Path):
64
+ """Scope class."""
65
+
66
+ def __init__(self, path: str = None):
67
+ """
68
+ :param path: scope path (default is CWD)
69
+ """
70
+ self.path = path or os.getcwd()
71
+
72
+
73
+ class Source(object):
74
+ """envstack .env source file."""
75
+
76
+ def __init__(self, path: str = None):
77
+ """
78
+ :param path: path to .env file.
79
+ """
80
+ self.path = path
81
+ self.data = defaultdict(dict)
82
+
83
+ def __eq__(self, other):
84
+ if not isinstance(other, Source):
85
+ return False
86
+ return self.path == other.path
87
+
88
+ def __ne__(self, other):
89
+ return not self.__eq__(other)
90
+
91
+ def __hash__(self):
92
+ return hash(self.__repr__())
93
+
94
+ def __repr__(self):
95
+ return f"<Source '{self.path}'>"
96
+
97
+ def __str__(self):
98
+ return str(self.path)
99
+
100
+ def exists(self):
101
+ """Returns True if the .env file exists"""
102
+ return os.path.exists(self.path)
103
+
104
+ def includes(self):
105
+ """Returns list of included environments, defined in
106
+ .env files above the "all:" statment as:
107
+
108
+ include: [name1, name2, ... nameN]
109
+ """
110
+ if not self.data:
111
+ self.load()
112
+ return self.data.get("include", [])
113
+
114
+ def length(self):
115
+ """Returns the char length of the path"""
116
+ return len(self.path)
117
+
118
+ def load(self, platform: str = config.PLATFORM):
119
+ """Reads .env from .path, and returns an Env class object"""
120
+ if self.path and not self.data:
121
+ self.data = load_file(self.path)
122
+ return self.data.get(platform, self.data.get("all", {}))
123
+
124
+ def namespace(self):
125
+ """Returns the namespace of the source file."""
126
+ return os.path.basename(self.path).split(".")[0]
127
+
128
+ def write(self, filepath: str = None):
129
+ """Writes the source data to the .env file."""
130
+ try:
131
+ util.dump_yaml(filepath or self.path, self.data)
132
+ except Exception as err:
133
+ logger.log.exception("Failed to write %s: %s", filepath or self.path, err)
134
+
135
+
136
+ class EnvVar(string.Template, str):
137
+ """A string class for supporting $-substitutions, e.g.: ::
138
+
139
+ >>> v = EnvVar('$FOO:${BAR}')
140
+ >>> v.substitute(FOO='foo', BAR='bar')
141
+ 'foo:bar'
142
+ """
143
+
144
+ def __init__(self, template: str = util.null):
145
+ super(EnvVar, self).__init__(template)
146
+
147
+ def __eq__(self, other):
148
+ if isinstance(other, EnvVar):
149
+ return self.template == other.template
150
+ return self.template == other
151
+
152
+ def __iter__(self):
153
+ return iter(self.parts())
154
+
155
+ def __getitem__(self, key: str):
156
+ return EnvVar(self.value().__getitem__(key))
157
+
158
+ def __setitem__(self, key: str, value: str):
159
+ v = self.value()
160
+ v.__setitem__(key, value)
161
+ self.template = v
162
+
163
+ def append(self, value: str):
164
+ """If value is a list, append object to the end of the list.
165
+
166
+ :param value: value to append
167
+ """
168
+ v = self.value()
169
+ v.append(value)
170
+ self.template = v
171
+
172
+ def extend(self, iterable):
173
+ """If value is a list, extend list by appending elements from the
174
+ iterable.
175
+
176
+ :param iterable: an iterable object
177
+ :returns: extended value
178
+ """
179
+ v = self.value()
180
+ v.extend(iterable)
181
+ self.template = v
182
+
183
+ def expand(self, env: dict = os.environ, recursive: bool = True):
184
+ """Returns expanded value of this var as new EnvVar instance.
185
+
186
+ :env: Env instance object or key/value dict.
187
+ :returns: expanded EnvVar instance.
188
+ """
189
+ try:
190
+ val = EnvVar(self.safe_substitute(env))
191
+ except RuntimeError as err:
192
+ if "maximum recursion" in str(err):
193
+ raise CyclicalReference(self.template) # noqa
194
+ else:
195
+ raise InvalidSyntax(err) # noqa
196
+ except Exception:
197
+ val = EnvVar(self.template)
198
+
199
+ if recursive:
200
+ if type(val.value()) == list:
201
+ ret = []
202
+ for v in val.value():
203
+ ret.append(EnvVar(v).expand(env, recursive))
204
+ return ret
205
+ elif type(val.value()) == dict:
206
+ ret = {}
207
+ for k, v in val.value().items():
208
+ ret[k] = EnvVar(v).expand(env, recursive)
209
+ return ret
210
+ elif val.parts():
211
+ return val.expand(env, recursive=False)
212
+ else:
213
+ return val
214
+ else:
215
+ return val
216
+
217
+ def keys(self):
218
+ """Returns EnvVar.keys() if the value of this EnvVar is a dict."""
219
+ if not isinstance(self.template, dict):
220
+ raise TypeError
221
+ return self.template.keys()
222
+
223
+ def parts(self):
224
+ """Returns a list of delimited parts, e.g. if a value is delimited by a
225
+ colon (or semicolon on windows), for example:
226
+
227
+ >>> v = EnvVar('$FOO:${BAR}/bin')
228
+ >>> v.parts()
229
+ ['$FOO', '${BAR}/bin']
230
+ """
231
+ if self.template:
232
+ if type(self.template) in (
233
+ str,
234
+ bytes,
235
+ ):
236
+ return delimiter_pattern.split(self.template)
237
+ return self.template
238
+ return []
239
+
240
+ def value(self):
241
+ """Returns value."""
242
+ return self.template
243
+
244
+ def vars(self):
245
+ """Returns a list of embedded, named variables, e.g.: ::
246
+
247
+ >>> v = EnvVar('$FOO:${BAR}/bin')
248
+ >>> v.vars()
249
+ ['FOO', 'BAR']
250
+ """
251
+ matches = type(self).pattern.findall(str(self.template))
252
+ return [key for match in matches for key in match if key]
253
+
254
+
255
+ class Env(dict):
256
+ """Dictionary subclass for managing environments.
257
+
258
+ >>> env = Env({"BAR": "${FOO}", "FOO": "foo"})
259
+ >>> resolve_environ(env)
260
+ {'BAR': 'foo', 'FOO': 'foo'}
261
+ """
262
+
263
+ def __init__(self, *args, **kwargs):
264
+ super(Env, self).__init__(*args, **kwargs)
265
+ self.scope = os.getcwd()
266
+ self.sources = []
267
+
268
+ def load_source(self, source: Source, platform=config.PLATFORM):
269
+ """Loads environment from a given Source object. Appends to sources
270
+ list.
271
+
272
+ :param source: Source object.
273
+ :param platform: name of platform (linux, darwin, windows).
274
+ """
275
+ self.sources.append(source)
276
+ self.update(source.load(platform=platform))
277
+
278
+ def copy(self):
279
+ """Returns a copy of this Env."""
280
+ return Env(super(Env, self).copy())
281
+
282
+ def merge(self, env: dict):
283
+ """Merges another env into this one, i.e. env[k] will replace self[k].
284
+
285
+ :param env: env to merge into this one.
286
+ """
287
+ for k, v in env.items():
288
+ self[k] = v
289
+
290
+ def set_namespace(self, name: str):
291
+ """Stores the namespace for this environment.
292
+
293
+ :param name: namespace.
294
+ """
295
+ self.namespace = name
296
+
297
+ def set_scope(self, path: str):
298
+ """Stores the scope for this environment.
299
+
300
+ :param path: path of scope.
301
+ """
302
+ self.scope = path
303
+
304
+ def bake(self, filename: str = None, depth: int = 0, encrypt: bool = False):
305
+ """Bakes an environment with multiple sources into a single environment
306
+ and writes to a new env file.
307
+
308
+ >>> env = load_environ(stack_name)
309
+ >>> env.bake("baked.env")
310
+
311
+ :param filename: path to save the baked environment.
312
+ :param depth: depth of source files to include (optional).
313
+ :param encrypt: encrypt the values (optional).
314
+ :returns: baked environment.
315
+ """
316
+ sources = self.sources
317
+ os.environ.update(get_keys_from_env(self)) # for encryption
318
+ baked = Source(filename)
319
+
320
+ def get_node_class(value):
321
+ """Returns the node class to use for a given value."""
322
+ if encrypt:
323
+ if type(value) in custom_node_types:
324
+ return value.__class__
325
+ else:
326
+ return EncryptedNode
327
+ return value.__class__
328
+
329
+ # track included files and seen keys
330
+ includes = []
331
+ seen_keys = set()
332
+
333
+ for source in sources[:depth]:
334
+ for key, value in source.data.items():
335
+ if isinstance(value, dict):
336
+ for k, v in value.items():
337
+ if k in self:
338
+ v = self[k]
339
+ node_class = get_node_class(v)
340
+ seen_keys.add(k)
341
+ else:
342
+ seen_keys.add(key)
343
+
344
+ current_depth = 0
345
+ for source in sources[-depth:]:
346
+ for key, value in source.data.items():
347
+ if key == "include" and current_depth <= depth:
348
+ includes = value
349
+ continue
350
+ if isinstance(value, dict):
351
+ for k, v in value.items():
352
+ if k in self and key == "all": # override only in "all"
353
+ v = self[k]
354
+ node_class = get_node_class(v)
355
+ baked.data.setdefault(key, {})[k] = node_class(v)
356
+ seen_keys.add(k)
357
+ else:
358
+ baked.data[key] = get_node_class(value)(value)
359
+ seen_keys.add(key)
360
+ current_depth += 1
361
+
362
+ # add/override with values from the current environment
363
+ for key, value in self.items():
364
+ if key == "STACK" or key in seen_keys:
365
+ continue
366
+ baked.data["all"][key] = get_node_class(value)(value)
367
+
368
+ # clear includes if environment stack is fully baked
369
+ if depth <= 0 or depth >= len(sources):
370
+ baked.data["include"] = []
371
+ else:
372
+ baked.data["include"] = includes
373
+
374
+ # create the baked environment from the baked source
375
+ baked_env = Env()
376
+ baked_env.load_source(baked)
377
+ if filename:
378
+ try:
379
+ baked.write()
380
+ except Exception as err:
381
+ raise WriteError(f"Failed to write {filename}", err)
382
+
383
+ return baked_env
384
+
385
+ def write(self, filename: str, depth: int = 0, encrypt: bool = False):
386
+ """Writes the environment to an env file.
387
+
388
+ >>> env = Env({"FOO": "${BAR}", "BAR": "bar"})
389
+ >>> env.write("foo.env")
390
+
391
+ To encrypt values, use EncryptedNode:
392
+
393
+ >>> env = Env({"FOO": "${BAR}", "BAR": EncryptedNode("bar")})
394
+ >>> env.write("encrypted.env")
395
+
396
+ :param filename: path to save the baked environment.
397
+ :param depth: depth of source files to include (optional).
398
+ :param encrypt: encrypt the values (optional).
399
+ :returns: Source object.
400
+ """
401
+ # the environment was loaded from one or more sources
402
+ if self.sources:
403
+ baked = self.bake(filename, depth=depth, encrypt=encrypt)
404
+ return baked.sources[0]
405
+
406
+ # the environment was created from scratch
407
+ else:
408
+ source = Source(filename)
409
+ for k, v in self.items():
410
+ source.data[k] = v
411
+ source.write()
412
+ return source
413
+
414
+
415
+ @util.cache
416
+ def get_sources(
417
+ *names,
418
+ scope: str = None,
419
+ ignore_missing: bool = config.IGNORE_MISSING,
420
+ envpath: str = os.environ.get("ENVPATH", ""),
421
+ ):
422
+ """
423
+ Returns a list of Source objects for a given list of .env basenames.
424
+
425
+ :param names: list of .env file names to search for.
426
+ :param scope: filesystem path defining the scope to walk up to.
427
+ :param ignore_missing: ignore missing .env files.
428
+ :param envpath: colon-separated list of directories to search for .env files
429
+ :raises TemplateNotFound: if a file is not found in ENVPATH or scope.
430
+ :returns: list of Source objects for the given stack names.
431
+ """
432
+ loaded_files = []
433
+ sources = []
434
+ loading_stack = set()
435
+
436
+ scope = Path(scope or os.getcwd()).resolve()
437
+
438
+ def _walk_to_scope(current_path):
439
+ """Generate directories from the current path up to the scope."""
440
+ paths = []
441
+ while current_path != scope.parent:
442
+ paths.append(current_path)
443
+ if current_path == scope:
444
+ break
445
+ current_path = current_path.parent
446
+ return [str(p) for p in paths]
447
+
448
+ # construct search paths from ${ENVPATH} and scope
449
+ envpath_dirs = util.split_paths(envpath)
450
+ scope_dirs = _walk_to_scope(scope)
451
+ envpath_dirs.reverse()
452
+ search_paths = envpath_dirs + scope_dirs
453
+
454
+ def _find_files(file_basename):
455
+ """Find .env files in the search paths."""
456
+ if not file_basename.endswith(".env"):
457
+ file_basename += ".env"
458
+ found_files = []
459
+ for directory in search_paths:
460
+ potential_file = Path(directory) / file_basename
461
+ try:
462
+ if potential_file.exists() and potential_file not in found_files:
463
+ found_files.append(potential_file)
464
+ except PermissionError:
465
+ logger.log.warning(f"Permission denied: {potential_file}")
466
+ continue
467
+ except OSError:
468
+ logger.log.warning(f"Error accessing {potential_file}")
469
+ continue
470
+ if not found_files and not ignore_missing:
471
+ raise TemplateNotFound( # noqa
472
+ f"{file_basename} not found in ENVPATH or scope."
473
+ )
474
+ return found_files
475
+
476
+ def _load_file(file_basename):
477
+ """Recursively load .env files and their includes."""
478
+ seen_stacks.add(os.path.splitext(file_basename)[0])
479
+
480
+ # check if we're sourcing a file or a namespace
481
+ if file_basename.endswith(".env") and os.path.exists(file_basename):
482
+ file_paths = [file_basename]
483
+ else:
484
+ file_paths = _find_files(file_basename)
485
+
486
+ # process each file independently
487
+ for file_path in file_paths:
488
+ if file_path in loaded_files:
489
+ continue
490
+
491
+ if file_basename in loading_stack:
492
+ raise ValueError(f"Cyclic dependency detected in {file_basename}")
493
+
494
+ source = Source(file_path)
495
+ if source in sources:
496
+ continue
497
+
498
+ loading_stack.add(file_basename)
499
+
500
+ # parse included files recursively, don't include if already seen
501
+ for include_basename in source.includes():
502
+ if include_basename in seen_stacks:
503
+ continue
504
+ _load_file(include_basename)
505
+
506
+ # add current file to the loaded list after processing includes
507
+ loaded_files.append(file_path)
508
+ sources.append(source)
509
+ loading_stack.remove(file_basename)
510
+
511
+ # process each stack in the list
512
+ for name in names:
513
+ _load_file(name)
514
+
515
+ return sources
516
+
517
+
518
+ def expandvars(var: str, env: Env = None, recursive: bool = False):
519
+ """Expands variables in a given string for a given environment,
520
+ e.g.: if env = {'ROOT':'/projects'}
521
+
522
+ >>> envstack.expandvars('$ROOT/a/b/c', env)
523
+ /projects/a/b/c
524
+
525
+ :param var: a string with embedded variables.
526
+ :param env: Env (defaults to default environ).
527
+ :param recursive: revursively expand values.
528
+ :returns: expanded value from values in env.
529
+ """
530
+ if not env:
531
+ env = Env()
532
+ var = EnvVar(var).expand(env, recursive=recursive)
533
+ return util.evaluate_modifiers(var, os.environ)
534
+
535
+
536
+ def clear(
537
+ name: str = config.DEFAULT_NAMESPACE,
538
+ shell: str = config.SHELL,
539
+ scope: str = None,
540
+ ):
541
+ """Returns shell commands that can be sourced to unset or restore env stack
542
+ environment variables. Should only be run after a previous export:
543
+
544
+ $ source <(envstack --export)
545
+ $ source <(envstack --clear)
546
+
547
+ List of shell names: bash, sh, tcsh, cmd, pwsh
548
+ (see output of config.detect_shell()).
549
+
550
+ :param name: stack namespace.
551
+ :param shell: name of shell (default: current shell).
552
+ :param scope: environment scope (default: cwd).
553
+ :returns: shell commands as string.
554
+ """
555
+ # load the envrinment for the given stack and get list of sources
556
+ env = load_environ(name, scope=scope)
557
+
558
+ # track the environment variables to export
559
+ export_list = list()
560
+
561
+ # restricted environment variables
562
+ restricted = [
563
+ "ENVPATH",
564
+ "LD_LIBRARY_PATH",
565
+ "PATH",
566
+ "PYTHONPATH",
567
+ "PROMPT",
568
+ "PS1",
569
+ "PWD",
570
+ ]
571
+
572
+ # get the name of the shell
573
+ shell_name = os.path.basename(shell)
574
+
575
+ for key in env:
576
+ if key not in os.environ:
577
+ continue
578
+ old_key = f"_ES_OLD_{key}"
579
+ old_val = os.environ.get(old_key)
580
+ if shell_name in ["bash", "sh", "zsh"]:
581
+ if old_val:
582
+ export_list.append("export %s=%s" % (key, old_val))
583
+ export_list.append("unset %s" % (old_key))
584
+ elif key not in restricted:
585
+ export_list.append(f"unset {key}")
586
+ elif shell_name == "tcsh":
587
+ if old_val:
588
+ export_list.append(f"setenv {key} {old_val}")
589
+ export_list.append(f"unsetenv {old_key}")
590
+ elif key not in restricted:
591
+ export_list.append(f"unsetenv {key}")
592
+ elif shell_name == "cmd":
593
+ if old_val:
594
+ export_list.append(f"set {key}={old_val}")
595
+ export_list.append(f"set {old_key}=")
596
+ elif key not in restricted:
597
+ export_list.append(f"set {key}=")
598
+ elif shell_name == "pwsh":
599
+ if old_val:
600
+ export_list.append(f"$env:{key}='{old_val}'")
601
+ export_list.append(f"Remove-Item Env:{old_key}")
602
+ elif key not in restricted:
603
+ export_list.append(f"Remove-Item Env:{key}")
604
+ elif shell_name == "unknown":
605
+ raise Exception("unknown shell")
606
+
607
+ export_list.sort()
608
+ exp = "\n".join(export_list)
609
+
610
+ return exp
611
+
612
+
613
+ def export_env_to_shell(env: Env, shell: str = config.SHELL):
614
+ """Returns shell commands that can be sourced to set environment stack
615
+ environment variables.
616
+
617
+ Supported shells: bash, sh, tcsh, cmd, pwsh (see config.detect_shell()).
618
+
619
+ :param env: environment dict.
620
+ :param shell: name of shell (default: current shell).
621
+ :returns: shell commands as string.
622
+ """
623
+
624
+ # track the environment variables to export
625
+ export_list = list()
626
+
627
+ # get the name of the shell
628
+ shell_name = os.path.basename(shell)
629
+
630
+ # iterate over the environment variables
631
+ for key, val in env.copy().items():
632
+ old_key = f"_ES_OLD_{key}"
633
+ old_val = os.environ.get(key)
634
+ if key == "PATH" and not val:
635
+ logger.log.warning("PATH is empty")
636
+ continue
637
+ if shell_name in ["bash", "sh", "zsh"]:
638
+ export_list.append(f"export {key}={val}")
639
+ if old_val:
640
+ export_list.append(f"export {old_key}={old_val}")
641
+ elif shell_name == "tcsh":
642
+ export_list.append(f'setenv {key}:"{val}"')
643
+ if old_val:
644
+ export_list.append(f'setenv {old_key}:"{old_val}"')
645
+ elif shell_name == "cmd":
646
+ export_list.append(f'set {key}="{val}"')
647
+ if old_val:
648
+ export_list.append(f'set {old_key}="{old_val}"')
649
+ elif shell_name == "pwsh":
650
+ export_list.append(f'$env:{key}="{val}"')
651
+ if old_val:
652
+ export_list.append(f'$env:{old_key}="{old_val}"')
653
+ elif shell_name == "unknown":
654
+ raise Exception("unknown shell")
655
+
656
+ export_list.sort()
657
+ exp = "\n".join(export_list)
658
+
659
+ return exp
660
+
661
+
662
+ def export(
663
+ name: str = config.DEFAULT_NAMESPACE,
664
+ shell: str = config.SHELL,
665
+ scope: str = None,
666
+ encrypt: bool = False,
667
+ ):
668
+ """Returns shell commands that can be sourced to set environment stack
669
+ environment variables.
670
+
671
+ Supported shells: bash, sh, tcsh, cmd, pwsh (see config.detect_shell()).
672
+
673
+ :param name: stack namespace.
674
+ :param shell: name of shell (optional).
675
+ :param scope: environment scope (optional).
676
+ :param encrypt: encrypt the values (optional).
677
+ :returns: shell commands as string.
678
+ """
679
+ env = load_environ(name, scope=scope, encrypt=encrypt)
680
+ return export_env_to_shell(env, shell)
681
+
682
+
683
+ def save():
684
+ """Caches the current environment for later restoration."""
685
+ global saved_environ
686
+
687
+ if not saved_environ:
688
+ saved_environ = dict(os.environ.copy())
689
+ return saved_environ
690
+
691
+
692
+ def revert():
693
+ """Reverts the environment to the last cached version. Updates sys.path
694
+ using paths found in PYTHONPATH.
695
+
696
+ Initialize the default environment stack:
697
+
698
+ >>> envstack.init()
699
+
700
+ Revert to the previous environment:
701
+
702
+ >>> envstack.revert()
703
+ """
704
+ global saved_environ
705
+ global seen_stacks
706
+
707
+ # clear the seen stacks
708
+ seen_stacks = set()
709
+
710
+ # nothing to revert to
711
+ if saved_environ is None:
712
+ return
713
+
714
+ # clear current sys.path values
715
+ util.clear_sys_path()
716
+
717
+ # restore the original environment
718
+ os.environ.clear()
719
+ os.environ.update(saved_environ)
720
+
721
+ # restore sys.path from PYTHONPATH
722
+ util.load_sys_path()
723
+
724
+ saved_environ = None
725
+
726
+
727
+ def init(*name, ignore_missing: bool = config.IGNORE_MISSING):
728
+ """Initializes the environment from a given stack namespace. Environments
729
+ propogate downwards with subsequent calls to init().
730
+
731
+ Updates sys.path using paths found in PYTHONPATH.
732
+
733
+ Initialize the default environment stack:
734
+
735
+ >>> envstack.init()
736
+
737
+ Initialize the 'dev' environment stack (inherits from previous call):
738
+
739
+ >>> envstack.init('dev')
740
+
741
+ Initialize both 'dev' and 'test', in that order:
742
+
743
+ >>> envstack.init('dev', 'test')
744
+
745
+ Revert to the original environment:
746
+
747
+ >>> envstack.revert()
748
+
749
+ :param *name: list of stack namespaces.
750
+ :param ignore_missing: ignore missing .env files.
751
+ """
752
+ # save environment to restore later using envstack.revert()
753
+ save()
754
+
755
+ # clear old sys.path values from PYTHONPATH
756
+ util.clear_sys_path()
757
+
758
+ # load the stack and update the environment
759
+ env = resolve_environ(load_environ(name, ignore_missing=ignore_missing))
760
+
761
+ # os.environ expects strings, so encode the values
762
+ # TypeError: str expected, not int
763
+ os.environ.update(util.encode(env))
764
+
765
+ # update sys.path from resolved PYTHONPATH
766
+ util.load_sys_path()
767
+
768
+
769
+ def bake_environ(
770
+ name: str = config.DEFAULT_NAMESPACE,
771
+ scope: str = None,
772
+ depth: int = 0,
773
+ filename: str = None,
774
+ encrypt: bool = False,
775
+ ):
776
+ """Bakes one or more environment stacks into a single source .env file.
777
+
778
+ $ envstack [STACK] -o <filename>
779
+
780
+ :param name: stack namespace.
781
+ :param scope: environment scope (optional).
782
+ :param depth: depth of source files to include (optional).
783
+ :param filename: path to save the baked environment.
784
+ :param encrypt: encrypt the values.
785
+ :returns: baked environment.
786
+ """
787
+ return load_environ(name, scope=scope).bake(filename, depth, encrypt)
788
+
789
+
790
+ def encrypt_environ(
791
+ env: dict, node_class: BaseNode = EncryptedNode, encrypt: bool = True
792
+ ):
793
+ """Encrypts all values in a given environment, returning a new environment.
794
+ Looks for encryption keys in the environment.
795
+
796
+ Python:
797
+
798
+ >>> env = {"FOO": "bar"}
799
+ >>> env = envstack.encrypt_environ(env)
800
+
801
+ Command line:
802
+
803
+ $ envstack [STACK] --encrypt
804
+
805
+ :param env: environment to encrypt.
806
+ :param node_class: node class to use for encryption.
807
+ Defaults to EncryptedNode, which looks for encryption keys in the
808
+ environment to determine the encryption method.
809
+ :param encrypt: pre-encrypt the values.
810
+ :returns: encrypted environment.
811
+ """
812
+ # stores the encrypted environment
813
+ encrypted_env = Env()
814
+
815
+ # copy the environment to avoid modifying the original
816
+ env_copy = env.copy()
817
+
818
+ # resolve internal environment and look for keys in os.environ
819
+ resolved_env = resolve_environ(env_copy)
820
+ resolved_env.update(get_keys_from_env(os.environ))
821
+
822
+ for k, v in env_copy.items():
823
+ if type(v) not in custom_node_types:
824
+ # TODO: use to_yaml() method to serialize instead?
825
+ node = node_class(v)
826
+ if encrypt:
827
+ node.value = node.encryptor(env=resolved_env).encrypt(str(v))
828
+ node.original_value = node.value
829
+ encrypted_env[k] = node
830
+ else:
831
+ encrypted_env[k] = v
832
+
833
+ return encrypted_env
834
+
835
+
836
+ def resolve_environ(env: Env):
837
+ """Resolves all variables in a given environment, returning a new
838
+ environment with resolved values.
839
+
840
+ >>> env = resolve_environ(load_environ(name))
841
+
842
+ :param env: unresolved environment.
843
+ :returns: resolved environment.
844
+ """
845
+ # stores the resolved environment
846
+ resolved = Env()
847
+
848
+ if type(env) is not Env:
849
+ env = Env(env)
850
+
851
+ # copy env to avoid modifying the original
852
+ env_copy = env.copy()
853
+
854
+ # evaluate one source environment at a time
855
+ seen_source_paths = []
856
+ included = Env()
857
+ sources = env.sources[:-1]
858
+ sources.reverse()
859
+ for source in sources:
860
+ if source.path in seen_source_paths:
861
+ continue
862
+ seen_source_paths.append(source.path)
863
+ source_environ = resolve_environ(Env(source.load()))
864
+ for key, value in source_environ.items():
865
+ included[key] = util.evaluate_modifiers(value, environ=source_environ)
866
+
867
+ # make a copy that contains the encryption keys
868
+ env_keys = env.copy()
869
+ env_keys.update(get_keys_from_env(os.environ))
870
+
871
+ # decrypt custom nodes
872
+ for key, value in list(env_copy.items()):
873
+ if type(value) in custom_node_types:
874
+ env_copy[key] = value.resolve(env=env_keys)
875
+
876
+ # resolve environment variables after decrypting custom nodes
877
+ for key, value in list(env_copy.items()):
878
+ value = util.evaluate_modifiers(value, environ=env_copy, parent=included)
879
+ resolved[key] = util.safe_eval(value)
880
+
881
+ return resolved
882
+
883
+
884
+ # TODO: make 'name' arg a list (*names)
885
+ def load_environ(
886
+ name: str = config.DEFAULT_NAMESPACE,
887
+ platform: str = config.PLATFORM,
888
+ scope: str = None,
889
+ ignore_missing: bool = config.IGNORE_MISSING,
890
+ encrypt: bool = False,
891
+ ):
892
+ """Loads raw environment for a given stack namespace.
893
+ Adds "STACK" key to environment, and sets the value to `name`.
894
+
895
+ To load an environment for a given namespace, where the scope is the current
896
+ working directory (cwd):
897
+
898
+ >>> env = load_environ(name)
899
+
900
+ To reload the same namespace for a different scope (different cwd):
901
+
902
+ >>> env = load_environ(name, scope="/path/to/scope")
903
+
904
+ To load resolved environment, use `resolve_environ()`:
905
+
906
+ >>> env = resolve_environ(load_environ(name))
907
+
908
+ :param name: list of stack names to load (basename of env files).
909
+ :param platform: name of platform (linux, darwin, windows).
910
+ :param scope: environment scope (default: cwd).
911
+ :param ignore_missing: ignore missing .env files.
912
+ :encrypt: encrypt the values using available encryption methods.
913
+ :returns: dict of environment variables.
914
+ """
915
+ if type(name) is str:
916
+ name = [name]
917
+ if not name:
918
+ name = [config.DEFAULT_NAMESPACE]
919
+
920
+ # create the environment to be returned
921
+ env = Env()
922
+ env.set_namespace(name)
923
+ if scope:
924
+ env.set_scope(scope)
925
+
926
+ # initial ${ENVPATH} value
927
+ envpath = os.getenv("ENVPATH", os.getcwd())
928
+
929
+ # dedupe sources based on paths
930
+ seen_paths = []
931
+
932
+ # get and load stack sources in order
933
+ for stack_name in name:
934
+ sources = get_sources(
935
+ stack_name,
936
+ envpath=envpath,
937
+ scope=scope,
938
+ ignore_missing=ignore_missing,
939
+ )
940
+ for source in sources:
941
+ # don't load the same source more than once
942
+ if source.path in seen_paths:
943
+ continue
944
+ env.load_source(source, platform=platform)
945
+ seen_paths.append(source.path)
946
+
947
+ # add the stack name to the environment
948
+ if not env.get("STACK"):
949
+ env["STACK"] = util.get_stack_name(name)
950
+
951
+ # resolve ${ENVPATH} (don't let it be None)
952
+ # TODO: use expandvars() instead of resolve_environ()
953
+ envpath = resolve_environ(env).get("ENVPATH", envpath) or envpath
954
+
955
+ # encrypt the values in the environment last
956
+ if encrypt:
957
+ return encrypt_environ(env)
958
+
959
+ return env
960
+
961
+
962
+ @util.cache
963
+ def load_file(path: str):
964
+ """Reads a given .env file and returns data as dict. Data is memoized in
965
+ memory for faster access.
966
+
967
+ :param path: path to .env file.
968
+ :returns: loaded yaml data as dict.
969
+ """
970
+
971
+ if not os.path.exists(path):
972
+ return {}
973
+
974
+ return util.validate_yaml(path)
975
+
976
+
977
+ @util.cache
978
+ def trace_var(*name, var: str = None, scope: str = None):
979
+ """Traces where a var is getting set for a given name.
980
+
981
+ :param name: name of tool or executable.
982
+ :param var: environment variable to trace.
983
+ :param scope: environment scope (default: cwd).
984
+ :returns: source path.
985
+ """
986
+ # get the sources for the given stack(s)
987
+ sources = get_sources(*name, scope=scope, ignore_missing=True)
988
+ sources.reverse()
989
+
990
+ # check for the variable in the env files
991
+ for source in sources:
992
+ data = load_file(source.path)
993
+ env = data.get(config.PLATFORM, data.get("all", {}))
994
+ if var in env:
995
+ return source.path
996
+ elif os.getenv(var):
997
+ return "local environment"