diff --git a/dotenv_stripout/cli.py b/dotenv_stripout/cli.py index 0db91bd..2fc06b4 100644 --- a/dotenv_stripout/cli.py +++ b/dotenv_stripout/cli.py @@ -3,8 +3,9 @@ from . import __version__ from .install import _install, _uninstall, is_installed from .stripout import list_dotenv_file_paths, strip_file, strip_stdin +from .git import Scope -cli = typer.Typer(help="Strip secrets from all .env files in the current repo") +cli = typer.Typer(help="Strip secrets from .env files in the current repo") @cli.callback(invoke_without_command=True) @@ -44,6 +45,9 @@ def main( for path in paths: typer.echo(path) strip_file(path) + typer.echo( + "\nNote: Files listed in .dotenv-stripout-ignore were not stripped." + ) else: raise typer.Abort() @@ -63,6 +67,7 @@ def status( ), ), ): + """Check whether the filter has been installed""" scope = "global" if _global else "local" if is_installed(scope): typer.echo(f"Filter is installed {scope}ly") @@ -86,6 +91,7 @@ def install( ), ), ): + """Install dotenv-stripout as a git filter""" scope = "global" if _global else "local" if is_installed(scope): typer.echo(f"Filter is already {scope}ly installed!") @@ -106,6 +112,7 @@ def uninstall( ), ), ): + """Uninstall dotenv-stripout as a git filter""" scope = "global" if _global else "local" if is_installed(scope): _uninstall(scope) diff --git a/dotenv_stripout/git.py b/dotenv_stripout/git.py index f4fa174..10aeb1f 100644 --- a/dotenv_stripout/git.py +++ b/dotenv_stripout/git.py @@ -1,30 +1,51 @@ import os from pathlib import Path from subprocess import CalledProcessError, check_output +from typing import Literal +Scope = Literal["local", "global"] -def git(command): + +def git(command: list[str]) -> str: """ - run a git command + Run a git command and return the output as a string + + :param list[str] command: Git command to run, eg. ["rev-parse", "--show-toplevel"] + :raises OSError: If the command fails, an OSError is raised + :return str: The output of the command """ command = ["git"] + command try: return check_output(command, text=True).strip() except CalledProcessError: - raise OSError( - "Something went wrong while running:\n" f"{' '.join(command)}" - ) + raise OSError("Something went wrong while running:\n" f"{' '.join(command)}") + +def get_git_top_level_path() -> Path: + """ + Get the top level path of the current git repository -def get_git_top_level_path(): + :return Path: The top level path of the current git repository + """ return Path(git(["rev-parse", "--show-toplevel"])) -def get_git_dir(): +def get_git_dir() -> Path: + """ + Get the path to the .git directory of the current git repository + + :return Path: The path to the .git directory of the current git repository + """ return Path(git(["rev-parse", "--git-dir"])) -def get_attrfile(scope): +def get_attrfile(scope: Scope) -> Path: + """ + Get the path to the git attributes file + + :param Scope scope: The scope of the git filter + :return Path: The path to the git attributes file + """ if scope == "global": config_dir = Path("~/.config").expanduser() xdg_config_dir = Path(os.environ.get("XDG_CONFIG_DIR", config_dir)) diff --git a/dotenv_stripout/install.py b/dotenv_stripout/install.py index ffd1f42..c82a431 100644 --- a/dotenv_stripout/install.py +++ b/dotenv_stripout/install.py @@ -1,6 +1,6 @@ import sys -from .git import get_attrfile, git +from .git import get_attrfile, git, Scope from .stripout import patterns attr_lines = [ @@ -10,7 +10,12 @@ ] -def _install(scope="local"): +def _install(scope: Scope = "local"): + """ + Install dotenv_stripout as a git filter in the given scope + + :param Scope scope: The scope to install the filter in, defaults to "local" + """ python = sys.executable.replace("\\", "/") git(["config", f"--{scope}", "filter.dotenvstripout.smudge", "cat"]) git( @@ -40,18 +45,27 @@ def _install(scope="local"): f.write(line) -def _uninstall(scope="local"): +def _uninstall(scope: Scope = "local"): + """ + Uninstall dotenv_stripout as a git filter from the given scope + + :param Scope scope: The scope to uninstall the filter from, defaults to "local" + """ git(["config", f"--{scope}", "--remove-section", "filter.dotenvstripout"]) attrfile = get_attrfile(scope) with attrfile.open("r") as f: - attrs_to_keep = [ - line for line in f.readlines() if line not in attr_lines - ] + attrs_to_keep = [line for line in f.readlines() if line not in attr_lines] with attrfile.open("w") as f: f.writelines(attrs_to_keep) -def is_installed(scope="local"): +def is_installed(scope: Scope = "local") -> bool: + """ + Check whether the filter has been installed in the given scope + + :param Scope scope: The scope to check for the filter in, defaults to "local" + :return bool: True if the filter is installed, False otherwise + """ attrfile = get_attrfile(scope) try: with attrfile.open("r") as f: diff --git a/dotenv_stripout/stripout.py b/dotenv_stripout/stripout.py index db44079..8831d1e 100644 --- a/dotenv_stripout/stripout.py +++ b/dotenv_stripout/stripout.py @@ -1,18 +1,66 @@ import sys - +from typing import Union +from pathlib import Path from .git import get_git_top_level_path patterns = ["*.env", "*.env.*"] +ignore_file = ".dotenv-stripout-ignore" + + +def list_ignored_files() -> set: + """ + Get the set of local .env files to leave unstripped + + Files to be left unstripped are listed in .dotenv-stripout-ignore files, + which can be present in any directory. + + :return set: A set of paths to the .env files which dotenv-stripout should ignore + """ + ignored_files = set() + repo_path = get_git_top_level_path() + dotenv_stripout_ignore_file_paths = repo_path.rglob(ignore_file) + ignore_patterns = [] + for file_path in dotenv_stripout_ignore_file_paths: + with file_path.open("r") as f: + for line in f: + if line.strip(): + ignore_patterns.append(line.strip()) + for pattern in ignore_patterns: + ignored_files.update( + [ + path + for path in repo_path.rglob(pattern) + if path.name not in ignored_files + ] + ) -def list_dotenv_file_paths(): + return ignored_files + + +def list_dotenv_file_paths() -> list: + """ + List all dotenv files in the repo, excluding those in .dotenv-stripout-ignore + + :return list: A list of paths to dotenv files to strip + """ repo_path = get_git_top_level_path() return [ - path for pattern in patterns for path in list(repo_path.rglob(pattern)) + path + for pattern in patterns + for path in repo_path.rglob(pattern) + if path not in list_ignored_files() ] -def strip_line(line, newline=""): +def strip_line(line: str, newline: str = "") -> str: + """ + Strip the value from a line of a dotenv file + + :param str line: The line to strip + :param str newline: The newline character to use, defaults to "" + :return str: The stripped line + """ line = line.strip() if len(line) > 0: if line.startswith("#"): @@ -22,11 +70,23 @@ def strip_line(line, newline=""): return line -def strip_lines(lines, newline=""): +def strip_lines(lines: list[str], newline: str = "") -> list[str]: + """ + Strip the values from a list of lines of a dotenv file + + :param list[str] lines: The lines to strip + :param str newline: The newline character to use, defaults to "" + :return list[str]: The stripped lines + """ return [strip_line(line, newline=newline) for line in lines] -def strip_file(path): +def strip_file(path: Union[Path, str]): + """ + Strip the values from a dotenv file + + :param Union[Path, str] path: The path to the dotenv file + """ with path.open("r") as f: lines = f.readlines() stripped_lines = strip_lines(lines, newline="\n") @@ -35,5 +95,6 @@ def strip_file(path): def strip_stdin(): + """Strip the values a dotenv file provided via stdin""" for line in sys.stdin: sys.stdout.write(strip_line(line, newline="\n")) diff --git a/test/data/.dotenv-stripout-ignore b/test/data/.dotenv-stripout-ignore new file mode 100644 index 0000000..4843ce5 --- /dev/null +++ b/test/data/.dotenv-stripout-ignore @@ -0,0 +1 @@ +safe.env diff --git a/test/data/safe.env b/test/data/safe.env new file mode 100644 index 0000000..1c7385b --- /dev/null +++ b/test/data/safe.env @@ -0,0 +1,5 @@ +# THIS IS A COMMENT +MY_SECRET_USERNAME=username +MY_SECRET_PASSWORD=password +BLAH=blah +BLAH2=also_blah diff --git a/test/data/subdir/safe.env b/test/data/subdir/safe.env new file mode 100644 index 0000000..1c7385b --- /dev/null +++ b/test/data/subdir/safe.env @@ -0,0 +1,5 @@ +# THIS IS A COMMENT +MY_SECRET_USERNAME=username +MY_SECRET_PASSWORD=password +BLAH=blah +BLAH2=also_blah diff --git a/test/data/subdir/subsubdir/safe.env b/test/data/subdir/subsubdir/safe.env new file mode 100644 index 0000000..1c7385b --- /dev/null +++ b/test/data/subdir/subsubdir/safe.env @@ -0,0 +1,5 @@ +# THIS IS A COMMENT +MY_SECRET_USERNAME=username +MY_SECRET_PASSWORD=password +BLAH=blah +BLAH2=also_blah diff --git a/test/test_path_matching.py b/test/test_path_matching.py index 2bccad5..6e864a9 100644 --- a/test/test_path_matching.py +++ b/test/test_path_matching.py @@ -2,14 +2,13 @@ from dotenv_stripout.stripout import list_dotenv_file_paths + data_dir = Path(__file__).parent / "data" names_to_match = [".env", ".env.something", "something.env.something"] -names_to_not_match = [".environment"] +names_to_not_match = [".environment", "safe.env"] directories = [data_dir, data_dir / "subdir", data_dir / "subdir" / "subsubdir"] paths_to_match = [dir / name for dir in directories for name in names_to_match] -paths_to_not_match = [ - dir / name for dir in directories for name in names_to_not_match -] +paths_to_not_match = [dir / name for dir in directories for name in names_to_not_match] found_paths = set(list_dotenv_file_paths()) diff --git a/test/test_stripping.py b/test/test_stripping.py index 7508ec2..9e8c02f 100644 --- a/test/test_stripping.py +++ b/test/test_stripping.py @@ -1,5 +1,5 @@ from pathlib import Path -from dotenv_stripout.stripout import strip_line, strip_file +from dotenv_stripout.stripout import strip_line, strip_file def test_normal_line(): @@ -22,27 +22,32 @@ def test_line_without_equals(): output_line = strip_line(input_line) assert output_line == expected_output_line + def test_ignores_commented_line(): input_line = "# THIS IS A COMMENT" output_line = strip_line(input_line) assert output_line == input_line + def test_ignores_empty_line(): input_line = "" output_line = strip_line(input_line) assert output_line == "" + def test_ignores_line_with_only_whitespace(): input_line = " " output_line = strip_line(input_line) assert output_line == "" + def test_ignores_line_with_only_whitespace_and_comment(): input_line = " # THIS IS A COMMENT" expected_output_line = "# THIS IS A COMMENT" output_line = strip_line(input_line) assert output_line == expected_output_line + def test_handles_commented_line_in_a_file(): test_file_path = Path(__file__).parent / "data" / ".env" copy_path = Path(__file__).parent / "data" / ".env.copy"