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
11 changes: 10 additions & 1 deletion src/dotenv/__init__.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -43,6 +51,7 @@ def get_cli_string(
"get_cli_string",
"load_dotenv",
"dotenv_values",
"generate_template",
"get_key",
"set_key",
"unset_key",
Expand Down
33 changes: 32 additions & 1 deletion src/dotenv/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__


Expand Down Expand Up @@ -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.

Expand Down
91 changes: 91 additions & 0 deletions src/dotenv/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import os
import pathlib
import re
import stat
import sys
import tempfile
Expand Down Expand Up @@ -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 `= <optional whitespace> <value>` 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)
41 changes: 41 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
71 changes: 71 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == ""