diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py index dde24a01..8595386e 100644 --- a/src/dotenv/__init__.py +++ b/src/dotenv/__init__.py @@ -1,6 +1,14 @@ from typing import Any, Optional -from .main import dotenv_values, find_dotenv, get_key, load_dotenv, set_key, unset_key +from .main import ( + dotenv_values, + find_dotenv, + generate_template, + get_key, + load_dotenv, + set_key, + unset_key, +) def load_ipython_extension(ipython: Any) -> None: @@ -43,6 +51,7 @@ def get_cli_string( "get_cli_string", "load_dotenv", "dotenv_values", + "generate_template", "get_key", "set_key", "unset_key", diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 79613e28..bc697d92 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -17,7 +17,7 @@ ) sys.exit(1) -from .main import dotenv_values, set_key, unset_key +from .main import dotenv_values, generate_template, set_key, unset_key from .version import __version__ @@ -201,6 +201,37 @@ def run(ctx: click.Context, override: bool, commandline: tuple[str, ...]) -> Non run_command([*commandline, *ctx.args], dotenv_as_dict) +@cli.command() +@click.pass_context +@click.argument("output", default=None, required=False, type=click.Path()) +@click.option( + "--keep-directives", + is_flag=True, + default=False, + help="Keep ::dotenv-template-preserve and ::dotenv-template-exclude directives in output.", +) +def template(ctx: click.Context, output: Any, keep_directives: bool) -> None: + """Generate a template from the .env file. + + Values are replaced with their key names. Use ::dotenv-template-preserve + in a line to keep its value, or ::dotenv-template-exclude to omit it. + + If OUTPUT is given, the template is written to that file. Otherwise it is + printed to stdout. + """ + file = ctx.obj["FILE"] + + with stream_file(file) as stream: + result = generate_template(stream=stream, keep_directives=keep_directives) + + if output: + with open(output, "w") as f: + f.write(result) + click.echo(f"Template written to {output}") + else: + click.echo(result, nl=False) + + def run_command(command: List[str], env: Dict[str, str]) -> None: """Replace the current process with the specified command. diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 491634d9..dc3a312e 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -2,6 +2,7 @@ import logging import os import pathlib +import re import stat import sys import tempfile @@ -480,3 +481,93 @@ def _is_file_or_fifo(path: StrPath) -> bool: return False return stat.S_ISFIFO(st.st_mode) + + +_DIRECTIVE_PRESERVE = "::dotenv-template-preserve" +_DIRECTIVE_EXCLUDE = "::dotenv-template-exclude" + +# Matches `= ` where value is single-quoted, +# double-quoted, or unquoted (everything up to a comment or end of line). +_VALUE_RE = re.compile( + r"(=[ \t]*)" + r"(?:'(?:\\'|[^'])*'|\"(?:\\\"|[^\"])*\"|[^\s#\r\n]*)" +) + +# Matches a directive token and any surrounding horizontal whitespace. +_DIRECTIVE_RE = re.compile( + r"[ \t]*(?:::dotenv-template-preserve|::dotenv-template-exclude)[ \t]*" +) + + +def _strip_directives(line: str) -> str: + """Remove directive tokens from a line and clean up artifacts.""" + result = _DIRECTIVE_RE.sub("", line) + # Preserve the original line ending + stripped = result.rstrip("\r\n") + ending = result[len(stripped) :] + # Remove a bare comment marker left after stripping + cleaned = re.sub(r"[ \t]*#[ \t]*$", "", stripped) + return cleaned.rstrip() + ending + + +def generate_template( + dotenv_path: Optional[StrPath] = None, + stream: Optional[IO[str]] = None, + encoding: Optional[str] = "utf-8", + keep_directives: bool = False, +) -> str: + """ + Generate a template from a .env file. + + For each key-value binding, the value is replaced with the key name, unless + an inline directive overrides this behavior: + + - ``::dotenv-template-preserve`` keeps the line as-is (value included). + - ``::dotenv-template-exclude`` removes the line from the template. + + By default, directive tokens are stripped from the output. Set + *keep_directives* to ``True`` to retain them. + + Comments and blank lines are preserved. + + Parameters: + dotenv_path: Absolute or relative path to the .env file. + stream: ``StringIO`` with .env content, used if *dotenv_path* is ``None``. + encoding: Encoding used to read the file. + keep_directives: If ``True``, directive comments are kept in the output. + + Returns: + The generated template as a string. + """ + if dotenv_path is None and stream is None: + dotenv_path = find_dotenv() + + dotenv = DotEnv( + dotenv_path=dotenv_path, + stream=stream, + interpolate=False, + encoding=encoding, + ) + + lines: list[str] = [] + with dotenv._get_stream() as s: + for binding in with_warn_for_invalid_lines(parse_stream(s)): + original = binding.original.string + + # Comments, blank lines, and parse errors: preserve as-is + if binding.key is None: + lines.append(original) + continue + + if _DIRECTIVE_EXCLUDE in original: + continue + + if _DIRECTIVE_PRESERVE in original: + line = original if keep_directives else _strip_directives(original) + lines.append(line) + continue + + # Replace the value with the key name + lines.append(_VALUE_RE.sub(r"\g<1>" + binding.key, original, count=1)) + + return "".join(lines) diff --git a/tests/test_cli.py b/tests/test_cli.py index d4e3ad4d..def9a85d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -293,3 +293,44 @@ def test_run_with_dotenv_and_command_flags(dotenv_path, tmp_path): check_process(result, exit_code=0) assert result.stdout.strip().startswith("dotenv, version") + + +def test_template_stdout(cli, dotenv_path): + dotenv_path.write_text("A=hello\nB=world\n") + + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "template"]) + + assert (result.exit_code, result.output) == (0, "A=A\nB=B\n") + + +def test_template_to_file(cli, dotenv_path, tmp_path): + dotenv_path.write_text("A=hello\nB=world\n") + output_path = tmp_path / ".env.template" + + result = cli.invoke( + dotenv_cli, ["--file", dotenv_path, "template", str(output_path)] + ) + + assert result.exit_code == 0 + assert output_path.read_text() == "A=A\nB=B\n" + assert "Template written to" in result.output + + +def test_template_keep_directives(cli, dotenv_path): + dotenv_path.write_text('A="example" # ::dotenv-template-preserve\nB=val\n') + + result = cli.invoke( + dotenv_cli, ["--file", dotenv_path, "template", "--keep-directives"] + ) + + assert (result.exit_code, result.output) == ( + 0, + 'A="example" # ::dotenv-template-preserve\nB=B\n', + ) + + +def test_template_non_existent_file(cli): + result = cli.invoke(dotenv_cli, ["--file", "nx_file", "template"]) + + assert result.exit_code == 2 + assert "Error opening env file" in result.output diff --git a/tests/test_main.py b/tests/test_main.py index 50703af0..f4326de9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -695,3 +695,74 @@ def test_dotenv_values_file_stream(dotenv_path): result = dotenv.dotenv_values(stream=f) assert result == {"a": "b"} + + +@pytest.mark.parametrize( + "input_text,expected", + [ + # Basic value replacement + ("A=hello\nB=world\n", "A=A\nB=B\n"), + # Key with no value + ("A\n", "A\n"), + # Comments and blank lines preserved + ("# a comment\nA=b\n\nB=c\n", "# a comment\nA=A\n\nB=B\n"), + # Export prefix preserved + ("export A=b\n", "export A=A\n"), + ], +) +def test_generate_template_basic(input_text, expected): + stream = io.StringIO(input_text) + + result = dotenv.generate_template(stream=stream) + + assert result == expected + + +def test_generate_template_exclude(): + stream = io.StringIO("A=secret # ::dotenv-template-exclude\nB=ok\n") + + result = dotenv.generate_template(stream=stream) + + assert result == "B=B\n" + + +def test_generate_template_preserve_strips_directive(): + stream = io.StringIO('A="example" # ::dotenv-template-preserve\n') + + result = dotenv.generate_template(stream=stream) + + assert result == 'A="example"\n' + + +def test_generate_template_preserve_with_comment(): + stream = io.StringIO('A="example" # useful hint ::dotenv-template-preserve\n') + + result = dotenv.generate_template(stream=stream) + + assert result == 'A="example" # useful hint\n' + + +def test_generate_template_keep_directives(): + stream = io.StringIO( + 'A="example" # ::dotenv-template-preserve\nB=secret # ::dotenv-template-exclude\nC=val\n' + ) + + result = dotenv.generate_template(stream=stream, keep_directives=True) + + assert result == 'A="example" # ::dotenv-template-preserve\nC=C\n' + + +def test_generate_template_file(dotenv_path): + dotenv_path.write_text("A=hello\nB=world\n") + + result = dotenv.generate_template(dotenv_path=dotenv_path) + + assert result == "A=A\nB=B\n" + + +def test_generate_template_empty(): + stream = io.StringIO("") + + result = dotenv.generate_template(stream=stream) + + assert result == ""