diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 79613e28..6cc1429b 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -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 @@ -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: diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 3c4608d5..802ef0df 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -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 @@ -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 @@ -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: diff --git a/tests/test_cli.py b/tests/test_cli.py index d4e3ad4d..148ae34a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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", ( diff --git a/tests/test_main.py b/tests/test_main.py index 1c33c808..2361c107 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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" )