1 import json 2 import os 3 import shlex 4 import sys 5 from contextlib import contextmanager 6 from typing import Any, Dict, IO, Iterator, List, Optional 7 8 try: 9 import click 10 except ImportError: 11 sys.stderr.write('It seems python-dotenv is not installed with cli option. \n' 12 'Run pip install "python-dotenv[cli]" to fix this.') 13 sys.exit(1) 14 15 from .main import dotenv_values, set_key, unset_key 16 from .version import __version__ 17 18 19 def enumerate_env() -> Optional[str]: 20 """ 21 Return a path for the ${pwd}/.env file. 22 23 If pwd does not exist, return None. 24 """ 25 try: 26 cwd = os.getcwd() 27 except FileNotFoundError: 28 return None 29 path = os.path.join(cwd, '.env') 30 return path 31 32 33 @click.group() 34 @click.option('-f', '--file', default=enumerate_env(), 35 type=click.Path(file_okay=True), 36 help="Location of the .env file, defaults to .env file in current working directory.") 37 @click.option('-q', '--quote', default='always', 38 type=click.Choice(['always', 'never', 'auto']), 39 help="Whether to quote or not the variable values. Default mode is always. This does not affect parsing.") 40 @click.option('-e', '--export', default=False, 41 type=click.BOOL, 42 help="Whether to write the dot file as an executable bash script.") 43 @click.version_option(version=__version__) 44 @click.pass_context 45 def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None: 46 """This script is used to set, get or unset values from a .env file.""" 47 ctx.obj = {'QUOTE': quote, 'EXPORT': export, 'FILE': file} 48 49 50 @contextmanager 51 def stream_file(path: os.PathLike) -> Iterator[IO[str]]: 52 """ 53 Open a file and yield the corresponding (decoded) stream. 54 55 Exits with error code 2 if the file cannot be opened. 56 """ 57 58 try: 59 with open(path) as stream: 60 yield stream 61 except OSError as exc: 62 print(f"Error opening env file: {exc}", file=sys.stderr) 63 exit(2) 64 65 66 @cli.command() 67 @click.pass_context 68 @click.option('--format', default='simple', 69 type=click.Choice(['simple', 'json', 'shell', 'export']), 70 help="The format in which to display the list. Default format is simple, " 71 "which displays name=value without quotes.") 72 def list(ctx: click.Context, format: bool) -> None: 73 """Display all the stored key/value.""" 74 file = ctx.obj['FILE'] 75 76 with stream_file(file) as stream: 77 values = dotenv_values(stream=stream) 78 79 if format == 'json': 80 click.echo(json.dumps(values, indent=2, sort_keys=True)) 81 else: 82 prefix = 'export ' if format == 'export' else '' 83 for k in sorted(values): 84 v = values[k] 85 if v is not None: 86 if format in ('export', 'shell'): 87 v = shlex.quote(v) 88 click.echo(f'{prefix}{k}={v}') 89 90 91 @cli.command() 92 @click.pass_context 93 @click.argument('key', required=True) 94 @click.argument('value', required=True) 95 def set(ctx: click.Context, key: Any, value: Any) -> None: 96 """Store the given key/value.""" 97 file = ctx.obj['FILE'] 98 quote = ctx.obj['QUOTE'] 99 export = ctx.obj['EXPORT'] 100 success, key, value = set_key(file, key, value, quote, export) 101 if success: 102 click.echo(f'{key}={value}') 103 else: 104 exit(1) 105 106 107 @cli.command() 108 @click.pass_context 109 @click.argument('key', required=True) 110 def get(ctx: click.Context, key: Any) -> None: 111 """Retrieve the value for the given key.""" 112 file = ctx.obj['FILE'] 113 114 with stream_file(file) as stream: 115 values = dotenv_values(stream=stream) 116 117 stored_value = values.get(key) 118 if stored_value: 119 click.echo(stored_value) 120 else: 121 exit(1) 122 123 124 @cli.command() 125 @click.pass_context 126 @click.argument('key', required=True) 127 def unset(ctx: click.Context, key: Any) -> None: 128 """Removes the given key.""" 129 file = ctx.obj['FILE'] 130 quote = ctx.obj['QUOTE'] 131 success, key = unset_key(file, key, quote) 132 if success: 133 click.echo(f"Successfully removed {key}") 134 else: 135 exit(1) 136 137 138 @cli.command(context_settings={'ignore_unknown_options': True}) 139 @click.pass_context 140 @click.option( 141 "--override/--no-override", 142 default=True, 143 help="Override variables from the environment file with those from the .env file.", 144 ) 145 @click.argument('commandline', nargs=-1, type=click.UNPROCESSED) 146 def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: 147 """Run command with environment variables present.""" 148 file = ctx.obj['FILE'] 149 if not os.path.isfile(file): 150 raise click.BadParameter( 151 f'Invalid value for \'-f\' "{file}" does not exist.', 152 ctx=ctx 153 ) 154 dotenv_as_dict = { 155 k: v 156 for (k, v) in dotenv_values(file).items() 157 if v is not None and (override or k not in os.environ) 158 } 159 160 if not commandline: 161 click.echo('No command given.') 162 exit(1) 163 run_command(commandline, dotenv_as_dict) 164 165 166 def run_command(command: List[str], env: Dict[str, str]) -> None: 167 """Replace the current process with the specified command. 168 169 Replaces the current process with the specified command and the variables from `env` 170 added in the current environment variables. 171 172 Parameters 173 ---------- 174 command: List[str] 175 The command and it's parameters 176 env: Dict 177 The additional environment variables 178 179 Returns 180 ------- 181 None 182 This function does not return any value. It replaces the current process with the new one. 183 184 """ 185 # copy the current environment variables and add the vales from 186 # `env` 187 cmd_env = os.environ.copy() 188 cmd_env.update(env) 189 190 os.execvpe(command[0], args=command, env=cmd_env) 191