diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 7a4c7adc..1b3f45b9 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -3,7 +3,7 @@ import shlex import sys from contextlib import contextmanager -from typing import IO, Any, Dict, Iterator, List, Optional +from typing import IO, Any, Dict, Iterator, List if sys.platform == "win32": from subprocess import Popen @@ -21,7 +21,7 @@ from .version import __version__ -def enumerate_env() -> Optional[str]: +def enumerate_env() -> tuple[str, ...]: """ Return a path for the ${pwd}/.env file. @@ -30,9 +30,9 @@ def enumerate_env() -> Optional[str]: try: cwd = os.getcwd() except FileNotFoundError: - return None + return () path = os.path.join(cwd, ".env") - return path + return (path,) @click.group() @@ -40,6 +40,7 @@ def enumerate_env() -> Optional[str]: "-f", "--file", default=enumerate_env(), + multiple=True, type=click.Path(file_okay=True), help="Location of the .env file, defaults to .env file in current working directory.", ) @@ -59,9 +60,9 @@ def enumerate_env() -> Optional[str]: ) @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 @@ -92,10 +93,12 @@ def stream_file(path: os.PathLike) -> Iterator[IO[str]]: ) def list_values(ctx: click.Context, output_format: str) -> 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: + values.update(dotenv_values(stream=stream)) if output_format == "json": click.echo(json.dumps(values, indent=2, sort_keys=True)) @@ -115,9 +118,11 @@ def list_values(ctx: click.Context, output_format: str) -> None: @click.argument("value", required=True) def set_value(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"] + + file = files[-1] success, key, value = set_key(file, key, value, quote, export) if success: click.echo(f"{key}={value}") @@ -130,10 +135,12 @@ def set_value(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"] - with stream_file(file) as stream: - values = dotenv_values(stream=stream) + values = {} + for file in files: + with stream_file(file) as stream: + values.update(dotenv_values(stream=stream)) stored_value = values.get(key) if stored_value: @@ -147,9 +154,10 @@ 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"] - success, key = unset_key(file, key, quote) + for file in files: + success, key = unset_key(file, key, quote) if success: click.echo(f"Successfully removed {key}") else: @@ -172,14 +180,17 @@ def unset(ctx: click.Context, key: Any) -> None: @click.argument("commandline", nargs=-1, type=click.UNPROCESSED) def run(ctx: click.Context, override: bool, commandline: tuple[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 - ) + files = ctx.obj["FILES"] + for file in files: + 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() + for file in files + for k, v in dotenv_values(file).items() if v is not None and (override or k not in os.environ) } diff --git a/tests/test_cli.py b/tests/test_cli.py index ebc4fdd9..6e2dc063 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -280,13 +280,13 @@ def test_run_with_command_flags(dotenv_path): """ Check that command flags passed after `dotenv run` are not interpreted. - Here, we want to run `printenv --version`, not `dotenv --version`. + Here, we want to run `echo --version`, not `dotenv --version`. """ - result = invoke_sub(["--file", dotenv_path, "run", "printenv", "--version"]) + result = invoke_sub(["--file", dotenv_path, "run", "printf", "%s\n", "--version"]) assert result.returncode == 0 - assert result.stdout.strip().startswith("printenv ") + assert result.stdout.strip() == "--version" def test_run_with_dotenv_and_command_flags(cli, dotenv_path): @@ -300,3 +300,37 @@ def test_run_with_dotenv_and_command_flags(cli, dotenv_path): assert result.returncode == 0 assert result.stdout.strip().startswith("dotenv, version") + + +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") +@pytest.mark.parametrize( + "files,file_contents,expected", + ( + ([".env"], ["a=1"], {"a": "1"}), + ([".env", ".env.secondary"], ["a=1", "b=2"], {"a": "1", "b": "2"}), + ( + [".env", ".env.secondary", ".env.extra"], + ["a=1", "a=3\nb=2", "a=5\nc=3"], + {"a": "5", "b": "2", "c": "3"}, + ), + ), +) +def test_run_with_multiple_env_files( + tmp_path, files: Sequence[str], file_contents: Sequence[str], expected: dict +): + """ + Test loading variables from two separate env files using file arguments. + + This demonstrates the pattern shown in the README where multiple env files + are loaded (e.g., .env.shared and .env.secret) and all variables from both + files are accessible. + """ + with sh.pushd(tmp_path): + file_args = [] + for file_name, content in zip(files, file_contents, strict=True): + (tmp_path / file_name).write_text(content) + file_args.extend(["--file", file_name]) + + for key, value in expected.items(): + result = invoke_sub([*file_args, "run", "printenv", key]) + assert result.stdout.strip() == value