diff --git a/README.md b/README.md index ddc8ba87..ba617b42 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,18 @@ $ dotenv list --format=json $ dotenv run -- python foo.py ``` +The CLI interface also supports reading from multiple `.env` files. +Each file overrides the previous: if there are duplicate keys, the last file determines the final value. + +```shell +$ echo -e "a=1\nb=2\n" > .env1 +$ echo -e "b=20\nc=30\n" > .env2 +$ python -m dotenv -f .env1 -f .env2 list +a=1 +b=20 +c=30 +``` + Run `dotenv --help` for more information about the options and subcommands. ## File format diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 65ead461..ec4b9eb7 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -26,14 +26,15 @@ def enumerate_env(): try: cwd = os.getcwd() except FileNotFoundError: - return None + return [] path = os.path.join(cwd, '.env') - return path + return [path] @click.group() @click.option('-f', '--file', default=enumerate_env(), type=click.Path(file_okay=True), + multiple=True, help="Location of the .env file, defaults to .env file in current working directory.") @click.option('-q', '--quote', default='always', type=click.Choice(['always', 'never', 'auto']), @@ -43,9 +44,9 @@ def enumerate_env(): help="Whether to write the dot file as an executable bash script.") @click.version_option(version=__version__) @click.pass_context -def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None: +def cli(ctx: click.Context, file: List[Any], quote: Any, export: Any) -> None: """This script is used to set, get or unset values from a .env file.""" - ctx.obj = {'QUOTE': quote, 'EXPORT': export, 'FILE': file} + ctx.obj = {'QUOTE': quote, 'EXPORT': export, 'FILES': file} @contextmanager @@ -72,10 +73,13 @@ def stream_file(path: os.PathLike) -> Iterator[IO[str]]: "which displays name=value without quotes.") def list(ctx: click.Context, format: bool) -> None: """Display all the stored key/value.""" - file = ctx.obj['FILE'] + files = ctx.obj['FILES'] - with stream_file(file) as stream: - values = dotenv_values(stream=stream) + values = {} + for file in files: + with stream_file(file) as stream: + file_values = dotenv_values(stream=stream) + values.update(file_values) if format == 'json': click.echo(json.dumps(values, indent=2, sort_keys=True)) @@ -95,9 +99,16 @@ def list(ctx: click.Context, format: bool) -> None: @click.argument('value', required=True) def set(ctx: click.Context, key: Any, value: Any) -> None: """Store the given key/value.""" - file = ctx.obj['FILE'] + files = ctx.obj['FILES'] quote = ctx.obj['QUOTE'] export = ctx.obj['EXPORT'] + + if len(files) > 1: + click.echo(f"Set is not supported for multiple files: {[str(f) for f in files]}.", err=True) + exit(1) + + file = files[0] + success, key, value = set_key(file, key, value, quote, export) if success: click.echo(f'{key}={value}') @@ -110,10 +121,13 @@ def set(ctx: click.Context, key: Any, value: Any) -> None: @click.argument('key', required=True) def get(ctx: click.Context, key: Any) -> None: """Retrieve the value for the given key.""" - file = ctx.obj['FILE'] + files = ctx.obj['FILES'] + values = {} - with stream_file(file) as stream: - values = dotenv_values(stream=stream) + for file in files: + with stream_file(file) as stream: + file_values = dotenv_values(stream=stream) + values.update(file_values) stored_value = values.get(key) if stored_value: @@ -127,8 +141,15 @@ def get(ctx: click.Context, key: Any) -> None: @click.argument('key', required=True) def unset(ctx: click.Context, key: Any) -> None: """Removes the given key.""" - file = ctx.obj['FILE'] + files = ctx.obj['FILES'] quote = ctx.obj['QUOTE'] + + if len(files) > 1: + click.echo(f"Unset is not supported for multiple files: {[str(f) for f in files]}.", err=True) + exit(1) + + file = files[0] + success, key = unset_key(file, key, quote) if success: click.echo(f"Successfully removed {key}") @@ -146,17 +167,22 @@ def unset(ctx: click.Context, key: Any) -> None: @click.argument('commandline', nargs=-1, type=click.UNPROCESSED) def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: """Run command with environment variables present.""" - file = ctx.obj['FILE'] - if not os.path.isfile(file): - raise click.BadParameter( - f'Invalid value for \'-f\' "{file}" does not exist.', - ctx=ctx - ) - dotenv_as_dict = { - k: v - for (k, v) in dotenv_values(file).items() - if v is not None and (override or k not in os.environ) - } + + files = ctx.obj['FILES'] + + dotenv_as_dict = {} + for file in files: + if not os.path.isfile(file): + raise click.BadParameter( + f'Invalid value for \'-f\' "{file}" does not exist.', + ctx=ctx + ) + file_dotenv_as_dict = { + k: v + for (k, v) in dotenv_values(file).items() + if v is not None and (override or k not in os.environ) + } + dotenv_as_dict.update(file_dotenv_as_dict) if not commandline: click.echo('No command given.') diff --git a/tests/conftest.py b/tests/conftest.py index 69193de0..2437019d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,3 +14,10 @@ def dotenv_path(tmp_path): path = tmp_path / '.env' path.write_bytes(b'') yield path + + +@pytest.fixture +def extra_dotenv_path(tmp_path): + path = tmp_path / '.env_extra' + path.write_bytes(b'') + yield path diff --git a/tests/test_cli.py b/tests/test_cli.py index fc309b48..f42eceb8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,7 @@ import os import sh from pathlib import Path -from typing import Optional +from typing import Dict, List, Optional import pytest @@ -25,7 +25,7 @@ ("export", "x='a b c'", '''export x='a b c'\n'''), ) ) -def test_list(cli, dotenv_path, format: Optional[str], content: str, expected: str): +def test_list_single_file(cli, dotenv_path, format: Optional[str], content: str, expected: str): dotenv_path.write_text(content + '\n') args = ['--file', dotenv_path, 'list'] @@ -37,6 +37,25 @@ def test_list(cli, dotenv_path, format: Optional[str], content: str, expected: s assert (result.exit_code, result.output) == (0, expected) +@pytest.mark.parametrize( + "contents,expected", + ( + (["x='1'", "y='2'"], '''x=1\ny=2\n'''), + (["x='1'", "x='2'"], '''x=2\n'''), + (["x='1'\ny='2'", "y='20'\nz='30'"], '''x=1\ny=20\nz=30\n'''), + ) +) +def test_list_multi_file(cli, dotenv_path, extra_dotenv_path, contents: List[str], expected: str): + dotenv_path.write_text(contents[0] + '\n') + extra_dotenv_path.write_text(contents[1] + '\n') + + args = ['--file', dotenv_path, '--file', extra_dotenv_path, 'list'] + + result = cli.invoke(dotenv_cli, args) + + assert (result.exit_code, result.output) == (0, expected) + + def test_list_non_existent_file(cli): result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'list']) @@ -57,7 +76,7 @@ def test_list_no_file(cli): assert (result.exit_code, result.output) == (1, "") -def test_get_existing_value(cli, dotenv_path): +def test_get_existing_value_single_file(cli, dotenv_path): dotenv_path.write_text("a=b") result = cli.invoke(dotenv_cli, ['--file', dotenv_path, 'get', 'a']) @@ -65,9 +84,32 @@ def test_get_existing_value(cli, dotenv_path): assert (result.exit_code, result.output) == (0, "b\n") +@pytest.mark.parametrize( + "contents,expected_values", + ( + (["a=1", "b=2"], {"a": "1", "b": "2"}), + (["b=2", "a=1"], {"a": "1", "b": "2"}), + (["a=1", "a=2"], {"a": "2"}), + ) +) +def test_get_existing_value_multi_file( + cli, + dotenv_path, + extra_dotenv_path, + contents: List[str], + expected_values: Dict[str, str] +): + dotenv_path.write_text(contents[0]) + extra_dotenv_path.write_text(contents[1]) + + for key, value in expected_values.items(): + result = cli.invoke(dotenv_cli, ['--file', dotenv_path, '--file', extra_dotenv_path, 'get', key]) + + assert (result.exit_code, result.output) == (0, f"{value}\n") + + def test_get_non_existent_value(cli, dotenv_path): result = cli.invoke(dotenv_cli, ['--file', dotenv_path, 'get', 'a']) - assert (result.exit_code, result.output) == (1, "") @@ -101,6 +143,12 @@ def test_unset_non_existent_value(cli, dotenv_path): assert dotenv_path.read_text() == "" +def test_unset_multi_file_not_allowed(cli, dotenv_path, extra_dotenv_path): + result = cli.invoke(dotenv_cli, ['--file', dotenv_path, '--file', extra_dotenv_path, 'unset', 'a']) + assert result.exit_code == 1 + assert result.output == f"Unset is not supported for multiple files: ['{dotenv_path}', '{extra_dotenv_path}'].\n" + + @pytest.mark.parametrize( "quote_mode,variable,value,expected", ( @@ -151,6 +199,12 @@ def test_set_no_file(cli): assert "Missing argument" in result.output +def test_set_multi_file_not_allowed(cli, dotenv_path, extra_dotenv_path): + result = cli.invoke(dotenv_cli, ['--file', dotenv_path, '--file', extra_dotenv_path, 'set', 'a', 'b']) + assert result.exit_code == 1 + assert result.output == f"Set is not supported for multiple files: ['{dotenv_path}', '{extra_dotenv_path}'].\n" + + def test_get_default_path(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -208,6 +262,24 @@ def test_run_with_other_env(dotenv_path): assert result == "b\n" +@pytest.mark.parametrize( + "contents,expected_values", + ( + (["a=1", "b=2"], {"a": "1", "b": "2"}), + (["b=2", "a=1"], {"a": "1", "b": "2"}), + (["a=1", "a=2"], {"a": "2"}), + ) +) +def test_run_with_multi_envs(dotenv_path, extra_dotenv_path, contents: List[str], expected_values: Dict[str, str]): + dotenv_path.write_text(contents[0]) + extra_dotenv_path.write_text(contents[1]) + + for key, value in expected_values.items(): + result = sh.dotenv("--file", dotenv_path, '--file', extra_dotenv_path, "run", "printenv", key) + + assert result == f"{value}\n" + + def test_run_without_cmd(cli): result = cli.invoke(dotenv_cli, ['run'])