Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow users to declare safe env files in .dotenv-stripout-ignore, whose values won't be stripped #7

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
9 changes: 8 additions & 1 deletion dotenv_stripout/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()

Expand All @@ -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")
Expand All @@ -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!")
Expand All @@ -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)
Expand Down
37 changes: 29 additions & 8 deletions dotenv_stripout/git.py
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
28 changes: 21 additions & 7 deletions dotenv_stripout/install.py
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
73 changes: 67 additions & 6 deletions dotenv_stripout/stripout.py
Original file line number Diff line number Diff line change
@@ -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("#"):
Expand All @@ -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")
Expand All @@ -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"))
1 change: 1 addition & 0 deletions test/data/.dotenv-stripout-ignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
safe.env
5 changes: 5 additions & 0 deletions test/data/safe.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# THIS IS A COMMENT
MY_SECRET_USERNAME=username
MY_SECRET_PASSWORD=password
BLAH=blah
BLAH2=also_blah
5 changes: 5 additions & 0 deletions test/data/subdir/safe.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# THIS IS A COMMENT
MY_SECRET_USERNAME=username
MY_SECRET_PASSWORD=password
BLAH=blah
BLAH2=also_blah
5 changes: 5 additions & 0 deletions test/data/subdir/subsubdir/safe.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# THIS IS A COMMENT
MY_SECRET_USERNAME=username
MY_SECRET_PASSWORD=password
BLAH=blah
BLAH2=also_blah
7 changes: 3 additions & 4 deletions test/test_path_matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
7 changes: 6 additions & 1 deletion test/test_stripping.py
Original file line number Diff line number Diff line change
@@ -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():
Expand All @@ -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"
Expand Down
Loading