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/__init__.py +40 -0
- envstack/cli.py +478 -0
- envstack/config.py +108 -0
- envstack/encrypt.py +361 -0
- envstack/env.py +997 -0
- envstack/envshell.py +143 -0
- envstack/exceptions.py +82 -0
- envstack/logger.py +61 -0
- envstack/node.py +410 -0
- envstack/path.py +448 -0
- envstack/util.py +800 -0
- envstack-1.0.3.dist-info/LICENSE +12 -0
- envstack-1.0.3.dist-info/METADATA +177 -0
- envstack-1.0.3.dist-info/RECORD +17 -0
- envstack-1.0.3.dist-info/WHEEL +5 -0
- envstack-1.0.3.dist-info/entry_points.txt +3 -0
- envstack-1.0.3.dist-info/top_level.txt +1 -0
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"
|