Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 32 additions & 21 deletions src/dotenv/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -30,16 +30,17 @@ 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()
@click.option(
"-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.",
)
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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}")
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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)
}

Expand Down
40 changes: 37 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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