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 ADDED
@@ -0,0 +1,40 @@
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
+ Stacked environment variable management system.
34
+ """
35
+
36
+ __prog__ = "envstack"
37
+ __version__ = "1.0.3"
38
+
39
+ from envstack.env import clear, init, revert, save # noqa: F401
40
+ from envstack.env import load_environ, resolve_environ # noqa: F401
envstack/cli.py ADDED
@@ -0,0 +1,478 @@
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
+ Command line interface for envstack: stacked environment variable management.
34
+ """
35
+
36
+ import argparse
37
+ import os
38
+ import re
39
+ import sys
40
+ import traceback
41
+ from typing import List
42
+
43
+ from envstack import __version__, config
44
+ from envstack.env import (
45
+ bake_environ,
46
+ Env,
47
+ encrypt_environ,
48
+ export_env_to_shell,
49
+ export,
50
+ load_environ,
51
+ resolve_environ,
52
+ trace_var,
53
+ )
54
+ from envstack.envshell import EnvshellWrapper
55
+ from envstack.logger import setup_stream_handler
56
+ from envstack.wrapper import run_command
57
+
58
+ setup_stream_handler()
59
+
60
+
61
+ class StoreOnce(argparse.Action):
62
+ """Custom argparse action to ensure an option is only set once."""
63
+
64
+ def __call__(self, parser, namespace, values, option_string=None):
65
+ # if we've already seen this option once, bail
66
+ if getattr(namespace, f"__seen_{self.dest}", False):
67
+ aliases = "/".join(self.option_strings)
68
+ parser.error(f"{aliases} specified more than once.")
69
+ setattr(namespace, f"__seen_{self.dest}", True)
70
+ setattr(namespace, self.dest, values)
71
+
72
+
73
+ def _parse_env_lines(lines):
74
+ """Parse lines of environment variables from an iterable.
75
+
76
+ :param lines: An iterable of lines, such as a file or stdin.
77
+ :return: A dictionary of environment variables.
78
+ """
79
+ ENV_LINE_RE = re.compile(
80
+ r"""
81
+ ^\s*
82
+ (?:export\s+)?
83
+ (?P<key>(?:<<|[A-Za-z_][A-Za-z0-9_]*))
84
+ \s*
85
+ (?P<op>[:=])
86
+ \s*
87
+ (?P<val>.*)
88
+ \s*$
89
+ """,
90
+ re.VERBOSE,
91
+ )
92
+
93
+ def parse_line(line: str):
94
+ m = ENV_LINE_RE.match(line)
95
+ if not m:
96
+ return None
97
+ key = m.group("key")
98
+ val = m.group("val")
99
+ # strip matching quotes only if present
100
+ if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
101
+ val = val[1:-1]
102
+ return key, val
103
+
104
+ data = {}
105
+ for raw in lines:
106
+ k, v = parse_line(raw.strip())
107
+ data[k] = v
108
+ return data
109
+
110
+
111
+ def _parse_keyvals(items: dict):
112
+ """Parse a list of key=value pairs.
113
+
114
+ :param items: A list of strings in the form "key=value" or "key:value".
115
+ :return: A dictionary mapping keys to values.
116
+ """
117
+ out = {}
118
+ for kv in items:
119
+ if ":" in kv:
120
+ k, v = kv.split(":", 1)
121
+ elif "=" in kv:
122
+ k, v = kv.split("=", 1)
123
+ else:
124
+ k, v = kv, ""
125
+ out[k] = v
126
+ return out
127
+
128
+
129
+ def parse_args():
130
+ """Command line argument parser.
131
+
132
+ Returns:
133
+ tuple: (args, command)
134
+ """
135
+
136
+ if "--" in sys.argv:
137
+ dash_index = sys.argv.index("--")
138
+ args_after_dash = sys.argv[dash_index + 1 :] # noqa: E203
139
+ args_before_dash = sys.argv[1:dash_index]
140
+ else:
141
+ args_after_dash = []
142
+ args_before_dash = sys.argv[1:]
143
+
144
+ parser = argparse.ArgumentParser(
145
+ description=__doc__, formatter_class=argparse.RawTextHelpFormatter
146
+ )
147
+ parser.add_argument(
148
+ "-v",
149
+ "--version",
150
+ action="version",
151
+ version=f"envstack {__version__}",
152
+ )
153
+ parser.add_argument(
154
+ "namespace",
155
+ metavar="STACK",
156
+ nargs="*",
157
+ default=[config.DEFAULT_NAMESPACE],
158
+ help="the environment stacks to use",
159
+ )
160
+ parser.add_argument(
161
+ "-b",
162
+ "--bare",
163
+ action="store_true",
164
+ help="create a bare environment",
165
+ )
166
+ encrypt_group = parser.add_argument_group("encryption options")
167
+ encrypt_group.add_argument(
168
+ "-e",
169
+ "--encrypt",
170
+ action="store_true",
171
+ help="encrypt environment values",
172
+ )
173
+ encrypt_group.add_argument(
174
+ "--keygen",
175
+ action="store_true",
176
+ help="generate encryption keys",
177
+ )
178
+ parser.add_argument_group(encrypt_group)
179
+ bake_group = parser.add_argument_group("bake options")
180
+ bake_group.add_argument(
181
+ "-o",
182
+ "--out",
183
+ metavar="FILENAME",
184
+ help="save the environment to an env file",
185
+ )
186
+ bake_group.add_argument(
187
+ "-d",
188
+ "--depth",
189
+ type=int,
190
+ default=0,
191
+ help="depth of environment stack to bake (default: 0 = flatten)",
192
+ )
193
+ parser.add_argument_group(bake_group)
194
+ export_group = parser.add_argument_group("export options")
195
+ export_group.add_argument(
196
+ "--clear",
197
+ action="store_true",
198
+ help="generate unset commands for %s" % config.SHELL,
199
+ )
200
+ export_group.add_argument(
201
+ "--export",
202
+ action="store_true",
203
+ help="generate export commands for %s" % config.SHELL,
204
+ )
205
+ parser.add_argument_group(export_group)
206
+ parser.add_argument(
207
+ "-p",
208
+ "--platform",
209
+ default=config.PLATFORM,
210
+ metavar="PLATFORM",
211
+ help="platform to resolve variables for (linux, darwin, windows)",
212
+ )
213
+ parser.add_argument(
214
+ "-s",
215
+ "--set",
216
+ nargs="*",
217
+ action=StoreOnce,
218
+ metavar="KEY=VALUE",
219
+ help="overlay KEY=VALUE pairs to envstack environments",
220
+ )
221
+ parser.add_argument(
222
+ "--scope",
223
+ metavar="SCOPE",
224
+ help="search scope for environment stack files",
225
+ )
226
+ parser.add_argument(
227
+ "-u",
228
+ "--unresolved",
229
+ action="store_true",
230
+ help="dump unresolved environment variables to stdout",
231
+ )
232
+ parser.add_argument(
233
+ "-r",
234
+ "--resolve",
235
+ nargs="*",
236
+ action=StoreOnce,
237
+ metavar="VAR",
238
+ help="resolve environment variables",
239
+ )
240
+ parser.add_argument(
241
+ "-t",
242
+ "--trace",
243
+ nargs="*",
244
+ action=StoreOnce,
245
+ metavar="VAR",
246
+ help="trace where environment variables are being set",
247
+ )
248
+ parser.add_argument(
249
+ "--sources",
250
+ action="store_true",
251
+ help="list the env stack file sources",
252
+ )
253
+ export_group.add_argument(
254
+ "-q",
255
+ "--quiet",
256
+ action="store_true",
257
+ help="print the value of an environment variable only (no key)",
258
+ )
259
+
260
+ args = parser.parse_args(args_before_dash)
261
+
262
+ return args, args_after_dash
263
+
264
+
265
+ def envshell(namespace: List[str] = None, quiet: bool = False):
266
+ """Run a shell in the given environment stack."""
267
+
268
+ if not quiet:
269
+ shell_name = os.path.basename(config.SHELL).lower()
270
+
271
+ if shell_name in ("cmd", "cmd.exe"):
272
+ exit_hint = 'type "exit" to quit'
273
+ elif shell_name in ("powershell", "pwsh"):
274
+ exit_hint = 'type "exit" to quit'
275
+ else:
276
+ # bash, zsh, sh, etc.
277
+ exit_hint = 'CTRL+D or "exit" to quit'
278
+
279
+ print(f"\U0001F680 Launching envstack shell... ({exit_hint})")
280
+
281
+ name = (namespace or [config.DEFAULT_NAMESPACE])[:]
282
+ shell = EnvshellWrapper(name)
283
+ return shell.launch()
284
+
285
+
286
+ def whichenv():
287
+ """Entry point for the whichenv command line tool. Finds {VAR}s."""
288
+ from .util import findenv
289
+
290
+ if len(sys.argv) != 2:
291
+ print("Usage: whichenv [VAR]")
292
+ return 2
293
+
294
+ var_name = sys.argv[1]
295
+ paths = findenv(var_name)
296
+ for path in paths:
297
+ print("{0}: {1}".format(var_name, path))
298
+
299
+
300
+ def main():
301
+ """Main thread."""
302
+ args, command = parse_args()
303
+
304
+ try:
305
+ if command:
306
+ return run_command(command, args.namespace)
307
+
308
+ elif args.keygen:
309
+ from envstack.encrypt import generate_keys
310
+
311
+ data = generate_keys()
312
+
313
+ if args.export:
314
+ print(export_env_to_shell(data))
315
+ elif args.out:
316
+ Env(data).write(args.out)
317
+ else:
318
+ for key, value in data.items():
319
+ print(f"{key}={value}")
320
+
321
+ elif args.clear:
322
+ from envstack.env import clear
323
+
324
+ print(clear(args.namespace, shell=config.SHELL))
325
+
326
+ elif args.export and args.resolve is None and args.set is None:
327
+ print(export(args.namespace, shell=config.SHELL, encrypt=args.encrypt))
328
+
329
+ elif args.set is not None and args.resolve is None:
330
+ force_stdin = args.set == [] or args.set == ["-"]
331
+ using_pipe = args.set == [] and not sys.stdin.isatty()
332
+
333
+ # load the environment if not in bare mode
334
+ if args.bare:
335
+ env = Env()
336
+ else:
337
+ env = load_environ(args.namespace, platform=args.platform)
338
+
339
+ # interactive mode
340
+ if force_stdin and sys.stdin.isatty():
341
+ print(
342
+ "Enter ENV:VAR or ENV=VAR pairs. Press Ctrl+D or Ctrl+C to finish:",
343
+ file=sys.stderr,
344
+ )
345
+ lines = []
346
+ try:
347
+ while True:
348
+ lines.append(input() + "\n")
349
+ except (EOFError, KeyboardInterrupt):
350
+ pass
351
+ data = _parse_env_lines(lines)
352
+ if not data:
353
+ return 0
354
+ # pipe stdin (or '-' with non-tty stdin)
355
+ elif force_stdin or using_pipe:
356
+ data = _parse_env_lines(sys.stdin)
357
+ if not data:
358
+ raise ValueError("no KEY=VALUE pairs found on stdin")
359
+ # explicit args path
360
+ else:
361
+ data = _parse_keyvals(args.set)
362
+
363
+ # encrypt the new data only
364
+ if args.encrypt:
365
+ data = encrypt_environ(data)
366
+
367
+ # update the environment with the new data
368
+ env.update(data)
369
+
370
+ if args.export:
371
+ print(export_env_to_shell(env))
372
+ elif args.out:
373
+ env.write(args.out, depth=args.depth)
374
+ else:
375
+ for key, val in env.items():
376
+ if args.quiet:
377
+ if len(env) > 1:
378
+ print("error: --quiet requires exactly one KEY")
379
+ return 2
380
+ else:
381
+ print(val)
382
+ else:
383
+ print(f"{key}={val}")
384
+
385
+ elif args.out and args.resolve is None:
386
+ bake_environ(
387
+ args.namespace,
388
+ filename=args.out,
389
+ depth=args.depth,
390
+ encrypt=args.encrypt,
391
+ )
392
+
393
+ elif args.resolve is not None:
394
+ if args.depth:
395
+ print("error: --depth is not valid with --resolve")
396
+ return 2
397
+ resolved = resolve_environ(
398
+ load_environ(args.namespace, platform=args.platform)
399
+ )
400
+ if args.set:
401
+ resolved.update(_parse_keyvals(args.set))
402
+ if args.encrypt:
403
+ resolved = encrypt_environ(resolved)
404
+ if args.out:
405
+ if len(args.resolve) == 0:
406
+ resolved.write(args.out, depth=0)
407
+ else:
408
+ keys = args.resolve or resolved.keys()
409
+ if args.set:
410
+ keys = set(keys).union(_parse_keyvals(args.set).keys())
411
+ env = Env({key: resolved[key] for key in keys})
412
+ env.write(args.out, depth=0)
413
+ elif args.export:
414
+ if len(args.resolve) == 0:
415
+ print(export_env_to_shell(resolved, shell=config.SHELL))
416
+ else:
417
+ keys = args.resolve or resolved.keys()
418
+ if args.set:
419
+ keys = set(keys).union(_parse_keyvals(args.set).keys())
420
+ env = Env({key: resolved[key] for key in keys})
421
+ print(export_env_to_shell(env, shell=config.SHELL))
422
+ else:
423
+ keys = args.resolve or resolved.keys()
424
+ if args.set:
425
+ keys = set(keys).union(_parse_keyvals(args.set).keys())
426
+ for key in sorted(str(k) for k in keys):
427
+ val = resolved.get(key)
428
+ if key in resolved:
429
+ if args.quiet:
430
+ if len(keys) > 1:
431
+ print("error: --quiet requires exactly one KEY")
432
+ return 2
433
+ else:
434
+ print(val)
435
+ else:
436
+ print(f"{key}={val}")
437
+
438
+ elif args.trace is not None:
439
+ if len(args.trace) == 0:
440
+ args.trace = load_environ(args.namespace).keys()
441
+ for trace in args.trace:
442
+ path = trace_var(*args.namespace, var=trace)
443
+ if path:
444
+ if args.quiet:
445
+ if len(args.trace) > 1:
446
+ print("error: --quiet requires exactly one KEY")
447
+ return 2
448
+ else:
449
+ print(path)
450
+ else:
451
+ print("{0}={1}".format(trace, path))
452
+
453
+ elif args.sources:
454
+ env = load_environ(args.namespace, platform=args.platform)
455
+ for source in env.sources:
456
+ print(source.path)
457
+
458
+ elif args.unresolved:
459
+ env = load_environ(
460
+ args.namespace, platform=args.platform, encrypt=args.encrypt
461
+ )
462
+ for k, v in sorted(env.items(), key=lambda x: str(x[0])):
463
+ print(f"{k}={v}")
464
+
465
+ else:
466
+ return envshell(args.namespace, quiet=args.quiet)
467
+
468
+ except KeyboardInterrupt:
469
+ print("Stopping...")
470
+ return 2
471
+
472
+ except Exception:
473
+ traceback.print_exc()
474
+ return 1
475
+
476
+
477
+ if __name__ == "__main__":
478
+ sys.exit(main())
envstack/config.py ADDED
@@ -0,0 +1,108 @@
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 default configs and settings.
34
+ """
35
+
36
+ import os
37
+ import platform
38
+ import sys
39
+
40
+
41
+ def detect_shell():
42
+ """Detect the current shell."""
43
+ if PLATFORM == "windows":
44
+ comspec = os.environ.get("ComSpec")
45
+ if comspec:
46
+ if "cmd.exe" in comspec:
47
+ return "cmd"
48
+ elif "powershell.exe" in comspec:
49
+ return "pwsh"
50
+ else:
51
+ return "unknown"
52
+ else:
53
+ shell = os.environ.get("SHELL", "/bin/bash")
54
+ if shell:
55
+ return shell
56
+ else:
57
+ return "/usr/bin/bash"
58
+
59
+
60
+ # debug mode
61
+ DEBUG = os.getenv("DEBUG")
62
+
63
+ # default namespace
64
+ DEFAULT_NAMESPACE = os.getenv("DEFAULT_ENV_STACK", "default")
65
+
66
+ # allow embedded commands
67
+ ALLOW_COMMANDS = os.getenv("ALLOW_COMMANDS", "0") in ("1", "true", "True", "TRUE")
68
+
69
+ # embedded command timeout in seconds
70
+ try:
71
+ COMMAND_TIMEOUT = int(os.getenv("COMMAND_TIMEOUT", 5))
72
+ except ValueError:
73
+ COMMAND_TIMEOUT = 5
74
+
75
+ # default environment variables
76
+ ENV = os.getenv("ENV", "prod")
77
+ HOME = os.getenv("HOME")
78
+
79
+ # Ignore missing stack files when resolving environments
80
+ IGNORE_MISSING = os.getenv("IGNORE_MISSING", "1") in ("1", "true", "True", "TRUE")
81
+
82
+ # logging level
83
+ LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
84
+
85
+ # platform and shell info
86
+ ON_POSIX = "posix" in sys.builtin_module_names
87
+ PLATFORM = platform.system().lower()
88
+ PYTHON_VERSION = sys.version_info[0]
89
+ SHELL = detect_shell()
90
+ USERNAME = os.getenv("USERNAME", os.getenv("USER"))
91
+
92
+ # set some default environment values
93
+ DEFAULT_ENV = {
94
+ "ENV": ENV,
95
+ "HOME": HOME,
96
+ "PLATFORM": PLATFORM,
97
+ "ROOT": os.getenv(
98
+ "ROOT",
99
+ {
100
+ "darwin": "{HOME}/Library/Application Support/pipe",
101
+ "linux": "{HOME}/.local/pipe",
102
+ "windows": "C:\\ProgramData\\pipe",
103
+ }
104
+ .get(PLATFORM)
105
+ .format(**locals()),
106
+ ),
107
+ "USER": USERNAME,
108
+ }