From 3b1e86b0250707594c07847980c6dd2c7b54c8e6 Mon Sep 17 00:00:00 2001 From: Christopher McBride <3595025+ankona@users.noreply.github.com> Date: Tue, 6 May 2025 18:38:57 -0400 Subject: [PATCH 1/5] Add function to write header block to .env file --- src/dotenv/__init__.py | 5 +++-- src/dotenv/main.py | 33 +++++++++++++++++++++++++++ tests/test_main.py | 51 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py index 7f4c631b..ce68b28b 100644 --- a/src/dotenv/__init__.py +++ b/src/dotenv/__init__.py @@ -1,7 +1,7 @@ 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, get_key, load_dotenv, set_header, + set_key, unset_key) def load_ipython_extension(ipython: Any) -> None: @@ -42,6 +42,7 @@ def get_cli_string( __all__ = ['get_cli_string', 'load_dotenv', 'dotenv_values', + 'set_header', 'get_key', 'set_key', 'unset_key', diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 1848d602..88c8107f 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -396,3 +396,36 @@ def dotenv_values( override=True, encoding=encoding, ).dict() + + +def set_header( + dotenv_path: StrPath, + header: str, + encoding: Optional[str] = "utf-8", +) -> Tuple[bool, Optional[str]]: + """ + Adds or Updates a header in the .env file + + Parameters: + dotenv_path: Absolute or relative path to .env file. + header: The desired header block + encoding: Encoding to be used to read the file. + Returns: + Bool: True if at least one environment variable is set else False + Str: The header that was written + """ + with rewrite(dotenv_path, encoding=encoding) as (source, dest): + if not header or not header.strip(): + logger.info("Ignoring empty header.") + return False, header + + header = header.strip() + lines = header.split("\n") + for i, line in enumerate(lines): + if not line.startswith("# "): + lines[i] = f"# {line}" + header = "\n".join(lines) + text = "".join(atom for atom in source.readlines() if not atom.startswith("#")) + dest.write(f"{header}\n{text}\n") + + return True, header diff --git a/tests/test_main.py b/tests/test_main.py index 2d63eec1..a7ddcf58 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -396,3 +396,54 @@ def test_dotenv_values_file_stream(dotenv_path): result = dotenv.dotenv_values(stream=f) assert result == {"a": "b"} + + +@pytest.mark.parametrize( + "header", + [ + "", + " ", + None, + ], +) +def test_set_header_empty(dotenv_path, header): + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "info") as mock_info: + result, *_ = dotenv.set_header(dotenv_path, header) + assert not result + + mock_info.assert_called() + + +@pytest.mark.parametrize( + "new_header, expected, expected_header, old_header, content", + [ + ("# header only", True, "# header only", "", ""), + ("# single-line header", True, "# single-line header", "", "a=b\nc=d"), + ("# multi-line\n# header", True, "# multi-line\n# header", "", "a=b\nc=d"), + ( + "Single-line, no comment header", + True, + "# Single-line, no comment header", + "", + "a=b", + ), + ("# New header", True, "# New header", "# Old header", "a=b\nc=d"), + ], +) +def test_set_header( + dotenv_path, new_header, expected, expected_header, old_header, content +): + logger = logging.getLogger("dotenv.main") + dotenv_path.write_text(f"{old_header}\n{content}") + + with mock.patch.object(logger, "warning") as mock_warning: + result, written = dotenv.set_header(dotenv_path, new_header) + assert result == expected + assert written == expected_header + + text = dotenv_path.read_text() + assert content in text + assert expected_header in text + mock_warning.assert_not_called() From 0c6763c19793741234c1b9a574cb5dd7ecf7a37e Mon Sep 17 00:00:00 2001 From: Christopher McBride <3595025+ankona@users.noreply.github.com> Date: Tue, 6 May 2025 19:31:59 -0400 Subject: [PATCH 2/5] get rid of multi-line inputs; use textwrap.wrap automatically --- src/dotenv/main.py | 14 ++++++++------ tests/test_main.py | 12 ++++++------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 88c8107f..8c6c78ff 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -5,6 +5,7 @@ import shutil import sys import tempfile +import textwrap from collections import OrderedDict from contextlib import contextmanager from typing import IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, Union @@ -419,13 +420,14 @@ def set_header( logger.info("Ignoring empty header.") return False, header - header = header.strip() - lines = header.split("\n") + header = header.replace("\n", " ") + lines = textwrap.wrap(header, width=60) for i, line in enumerate(lines): - if not line.startswith("# "): - lines[i] = f"# {line}" - header = "\n".join(lines) + lines[i] = f"# {line}\n" + header = "".join(lines) + dest.write(header) + text = "".join(atom for atom in source.readlines() if not atom.startswith("#")) - dest.write(f"{header}\n{text}\n") + dest.write(f"{text}\n") return True, header diff --git a/tests/test_main.py b/tests/test_main.py index a7ddcf58..5b3d68c4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -419,17 +419,17 @@ def test_set_header_empty(dotenv_path, header): @pytest.mark.parametrize( "new_header, expected, expected_header, old_header, content", [ - ("# header only", True, "# header only", "", ""), - ("# single-line header", True, "# single-line header", "", "a=b\nc=d"), - ("# multi-line\n# header", True, "# multi-line\n# header", "", "a=b\nc=d"), + ("single-line input", True, "# single-line input\n", "", "a=b\nc=d"), + ("multi-line\ninput", True, "# multi-line input\n", "", "a=b\nc=d"), + ("new header", True, "# new header\n", "# old header", "a=b\nc=d"), ( - "Single-line, no comment header", + " ".join("x" * 57 for _ in range(2)), True, - "# Single-line, no comment header", + "".join(f"# {'x' * 57}\n" for _ in range(2)), "", "a=b", ), - ("# New header", True, "# New header", "# Old header", "a=b\nc=d"), + # ("# New header", True, "# New header", "# Old header", "a=b\nc=d"), ], ) def test_set_header( From b525b250153ff6f809f61bc9662417ebfa3b856e Mon Sep 17 00:00:00 2001 From: Christopher McBride <3595025+ankona@users.noreply.github.com> Date: Tue, 6 May 2025 19:34:17 -0400 Subject: [PATCH 3/5] get rid of loop --- src/dotenv/main.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 8c6c78ff..57983a98 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -420,10 +420,8 @@ def set_header( logger.info("Ignoring empty header.") return False, header - header = header.replace("\n", " ") - lines = textwrap.wrap(header, width=60) - for i, line in enumerate(lines): - lines[i] = f"# {line}\n" + lines = textwrap.wrap(header.replace("\n", " "), width=60) + lines = [f"# {line}\n" for line in lines] header = "".join(lines) dest.write(header) From e072ee6fc26d3acdd575dcdc3820326c7a3c9593 Mon Sep 17 00:00:00 2001 From: Christopher McBride <3595025+ankona@users.noreply.github.com> Date: Tue, 6 May 2025 19:35:30 -0400 Subject: [PATCH 4/5] get rid of extra local --- src/dotenv/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 57983a98..0fbe48d2 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -421,8 +421,7 @@ def set_header( return False, header lines = textwrap.wrap(header.replace("\n", " "), width=60) - lines = [f"# {line}\n" for line in lines] - header = "".join(lines) + header = "".join(f"# {line}\n" for line in lines) dest.write(header) text = "".join(atom for atom in source.readlines() if not atom.startswith("#")) From e8634a6e252e0639d98e38fa985986c8e36a1224 Mon Sep 17 00:00:00 2001 From: Christopher McBride <3595025+ankona@users.noreply.github.com> Date: Tue, 6 May 2025 19:37:38 -0400 Subject: [PATCH 5/5] remove commented test case --- tests/test_main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index 5b3d68c4..1e951a06 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -429,7 +429,6 @@ def test_set_header_empty(dotenv_path, header): "", "a=b", ), - # ("# New header", True, "# New header", "# Old header", "a=b\nc=d"), ], ) def test_set_header(