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
27 changes: 24 additions & 3 deletions src/dotenv/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,24 @@ def enumerate_env() -> Optional[str]:
type=click.BOOL,
help="Whether to write the dot file as an executable bash script.",
)
@click.option(
"--quote-type",
default="single",
type=click.Choice(["single", "double"]),
help="Preferred quote character when values are quoted. Default is single.",
)
@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: Any, quote: Any, export: Any, quote_type: 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,
"QUOTE_TYPE": quote_type,
"FILE": file,
}


@contextmanager
Expand Down Expand Up @@ -124,7 +137,15 @@ def set_value(ctx: click.Context, key: Any, value: Any) -> None:
file = ctx.obj["FILE"]
quote = ctx.obj["QUOTE"]
export = ctx.obj["EXPORT"]
success, key, value = set_key(file, key, value, quote, export)
quote_type = ctx.obj["QUOTE_TYPE"]
success, key, value = set_key(
file,
key,
value,
quote,
export,
quote_type=quote_type,
)
if success:
click.echo(f"{key}={value}")
else:
Expand Down
11 changes: 9 additions & 2 deletions src/dotenv/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import tempfile
from collections import OrderedDict
from contextlib import contextmanager
from typing import IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, Union
from typing import IO, Dict, Iterable, Iterator, Literal, Mapping, Optional, Tuple, Union

from .parser import Binding, parse_stream
from .variables import parse_variables
Expand Down Expand Up @@ -198,6 +198,7 @@ def set_key(
export: bool = False,
encoding: Optional[str] = "utf-8",
follow_symlinks: bool = False,
quote_type: Literal["single", "double"] = "single",
) -> Tuple[Optional[bool], str, str]:
"""
Adds or Updates a key/value to the given .env
Expand All @@ -210,13 +211,19 @@ def set_key(
"""
if quote_mode not in ("always", "auto", "never"):
raise ValueError(f"Unknown quote_mode: {quote_mode}")
if quote_type not in ("single", "double"):
raise ValueError(f"Unknown quote_type: {quote_type}")

quote = quote_mode == "always" or (
quote_mode == "auto" and not value_to_set.isalnum()
)

if quote:
value_out = "'{}'".format(value_to_set.replace("'", "\\'"))
if quote_type == "single":
value_out = "'{}'".format(value_to_set.replace("'", "\\'"))
else:
escaped_value = value_to_set.replace("\\", "\\\\").replace('"', '\\"')
value_out = '"{}"'.format(escaped_value)
else:
value_out = value_to_set
if export:
Expand Down
30 changes: 30 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,36 @@ def test_set_quote_options(cli, dotenv_path, quote_mode, variable, value, expect
assert dotenv_path.read_text() == expected


def test_set_quote_type_double(cli, dotenv_path):
result = cli.invoke(
dotenv_cli,
[
"--file",
dotenv_path,
"--quote",
"always",
"--quote-type",
"double",
"set",
"a",
'b"c',
],
)

assert (result.exit_code, result.output) == (0, 'a=b"c\n')
assert dotenv_path.read_text() == 'a="b\\"c"\n'


def test_set_invalid_quote_type(cli, dotenv_path):
result = cli.invoke(
dotenv_cli,
["--file", dotenv_path, "--quote-type", "invalid", "set", "a", "x"],
)

assert result.exit_code == 2
assert "Invalid value for '--quote-type'" in result.output


@pytest.mark.parametrize(
"dotenv_path,export_mode,variable,value,expected",
(
Expand Down
19 changes: 19 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,25 @@ def test_set_key_encoding(dotenv_path):
assert dotenv_path.read_text(encoding=encoding) == "a='é'\n"


def test_set_key_quote_type_double(dotenv_path):
result = dotenv.set_key(dotenv_path, "a", "b'c", quote_type="double")

assert result == (True, "a", "b'c")
assert dotenv_path.read_text() == 'a="b\'c"\n'


def test_set_key_quote_type_double_escapes_double_quote(dotenv_path):
result = dotenv.set_key(dotenv_path, "a", 'b"c', quote_type="double")

assert result == (True, "a", 'b"c')
assert dotenv_path.read_text() == 'a="b\\"c"\n'


def test_set_key_quote_type_invalid(dotenv_path):
with pytest.raises(ValueError, match="Unknown quote_type"):
dotenv.set_key(dotenv_path, "a", "b", quote_type="invalid")


@pytest.mark.skipif(
sys.platform == "win32", reason="file mode bits behave differently on Windows"
)
Expand Down
Loading