Skip to content

Commit c7f0dae

Browse files
committed
Refactor catch_dotenv hook and tests for improved readability and consistency
1 parent 989ac68 commit c7f0dae

File tree

3 files changed

+260
-99
lines changed

3 files changed

+260
-99
lines changed

pre_commit_hooks/catch_dotenv.py

Lines changed: 83 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@
66
import re
77
import sys
88
import tempfile
9+
from collections.abc import Iterable
910
from collections.abc import Sequence
10-
from typing import Iterable
1111

1212
# Defaults / constants
13-
DEFAULT_ENV_FILE = ".env"
14-
DEFAULT_GITIGNORE_FILE = ".gitignore"
15-
DEFAULT_EXAMPLE_ENV_FILE = ".env.example"
16-
GITIGNORE_BANNER = "# Added by pre-commit hook to prevent committing secrets"
13+
DEFAULT_ENV_FILE = '.env'
14+
DEFAULT_GITIGNORE_FILE = '.gitignore'
15+
DEFAULT_EXAMPLE_ENV_FILE = '.env.example'
16+
GITIGNORE_BANNER = '# Added by pre-commit hook to prevent committing secrets'
1717

18-
_KEY_REGEX = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
18+
_KEY_REGEX = re.compile(r'^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=')
1919

2020

2121
def _atomic_write(path: str, data: str) -> None:
@@ -28,55 +28,68 @@ def _atomic_write(path: str, data: str) -> None:
2828
parallel (tests exercise concurrent normalization). Keeping this helper
2929
local avoids adding any dependency.
3030
"""
31-
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or ".")
31+
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or '.')
3232
try:
33-
with os.fdopen(fd, "w", encoding="utf-8", newline="") as tmp_f:
33+
with os.fdopen(fd, 'w', encoding='utf-8', newline='') as tmp_f:
3434
tmp_f.write(data)
3535
os.replace(tmp_path, path)
3636
finally: # Clean up if replace failed
3737
if os.path.exists(tmp_path): # (rare failure case)
3838
try:
3939
os.remove(tmp_path)
40-
except OSError:
40+
except OSError:
4141
pass
4242

4343

4444
def _read_gitignore(gitignore_file: str) -> tuple[str, list[str]]:
4545
"""Read and parse .gitignore file content."""
4646
try:
4747
if os.path.exists(gitignore_file):
48-
with open(gitignore_file, "r", encoding="utf-8") as f:
48+
with open(gitignore_file, encoding='utf-8') as f:
4949
original_text = f.read()
5050
lines = original_text.splitlines()
5151
else:
52-
original_text = ""
52+
original_text = ''
5353
lines = []
5454
except OSError as exc:
55-
print(f"ERROR: unable to read {gitignore_file}: {exc}", file=sys.stderr)
55+
print(
56+
f"ERROR: unable to read {gitignore_file}: {exc}",
57+
file=sys.stderr,
58+
)
5659
raise
57-
return original_text if lines else "", lines
60+
return original_text if lines else '', lines
5861

5962

60-
def _normalize_gitignore_lines(lines: list[str], env_file: str, banner: str) -> list[str]:
61-
"""Normalize .gitignore lines by removing duplicates and adding canonical tail."""
63+
def _normalize_gitignore_lines(
64+
lines: list[str],
65+
env_file: str,
66+
banner: str,
67+
) -> list[str]:
68+
"""Normalize .gitignore lines by removing duplicates and canonical tail."""
6269
# Trim trailing blank lines
6370
while lines and not lines[-1].strip():
6471
lines.pop()
6572

6673
# Remove existing occurrences
67-
filtered: list[str] = [ln for ln in lines if ln.strip() not in {env_file, banner}]
74+
filtered: list[str] = [
75+
ln for ln in lines if ln.strip() not in {env_file, banner}
76+
]
6877

6978
if filtered and filtered[-1].strip():
70-
filtered.append("") # ensure single blank before banner
79+
filtered.append('') # ensure single blank before banner
7180
elif not filtered: # empty file -> still separate section visually
72-
filtered.append("")
81+
filtered.append('')
7382

7483
filtered.append(banner)
7584
filtered.append(env_file)
7685
return filtered
7786

7887

79-
def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) -> bool:
88+
def ensure_env_in_gitignore(
89+
env_file: str,
90+
gitignore_file: str,
91+
banner: str,
92+
) -> bool:
8093
"""Ensure canonical banner + env tail in .gitignore.
8194
8295
Returns True only when the file content was changed. Returns False both
@@ -89,27 +102,30 @@ def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) ->
89102
return False
90103

91104
filtered = _normalize_gitignore_lines(lines, env_file, banner)
92-
new_content = "\n".join(filtered) + "\n"
105+
new_content = '\n'.join(filtered) + '\n'
93106

94107
# Normalize original content to a single trailing newline for comparison
95108
normalized_original = original_content_str
96-
if normalized_original and not normalized_original.endswith("\n"):
97-
normalized_original += "\n"
109+
if normalized_original and not normalized_original.endswith('\n'):
110+
normalized_original += '\n'
98111
if new_content == normalized_original:
99112
return False
100113

101114
try:
102115
_atomic_write(gitignore_file, new_content)
103116
return True
104-
except OSError as exc:
105-
print(f"ERROR: unable to write {gitignore_file}: {exc}", file=sys.stderr)
117+
except OSError as exc:
118+
print(
119+
f"ERROR: unable to write {gitignore_file}: {exc}",
120+
file=sys.stderr,
121+
)
106122
return False
107123

108124

109125
def create_example_env(src_env: str, example_file: str) -> bool:
110126
"""Generate .env.example with unique KEY= lines (no values)."""
111127
try:
112-
with open(src_env, "r", encoding="utf-8") as f_env:
128+
with open(src_env, encoding='utf-8') as f_env:
113129
lines = f_env.readlines()
114130
except OSError as exc:
115131
print(f"ERROR: unable to read {src_env}: {exc}", file=sys.stderr)
@@ -136,33 +152,51 @@ def create_example_env(src_env: str, example_file: str) -> bool:
136152
]
137153
body = [f"{k}=" for k in keys]
138154
try:
139-
_atomic_write(example_file, "\n".join(header + body) + "\n")
155+
_atomic_write(example_file, '\n'.join(header + body) + '\n')
140156
return True
141157
except OSError as exc: # pragma: no cover
142-
print(f"ERROR: unable to write '{example_file}': {exc}", file=sys.stderr)
158+
print(
159+
f"ERROR: unable to write '{example_file}': {exc}",
160+
file=sys.stderr,
161+
)
143162
return False
144163

145164

146165
def _has_env(filenames: Iterable[str], env_file: str) -> bool:
147-
"""Return True if any staged path refers to a target env file by basename."""
166+
"""Return True if any staged path refers to target env file by basename."""
148167
return any(os.path.basename(name) == env_file for name in filenames)
149168

150169

151-
def _print_failure(env_file: str, gitignore_file: str, example_created: bool, gitignore_modified: bool) -> None:
170+
def _print_failure(
171+
env_file: str,
172+
gitignore_file: str,
173+
example_created: bool,
174+
gitignore_modified: bool,
175+
) -> None:
152176
# Match typical hook output style: one short line per action.
153177
print(f"Blocked committing {env_file}.")
154178
if gitignore_modified:
155179
print(f"Updated {gitignore_file}.")
156180
if example_created:
157-
print("Generated .env.example.")
181+
print('Generated .env.example.')
158182
print(f"Remove {env_file} from the commit and retry.")
159183

160184

161185
def main(argv: Sequence[str] | None = None) -> int:
162186
"""Hook entry-point."""
163-
parser = argparse.ArgumentParser(description="Blocks committing .env files.")
164-
parser.add_argument('filenames', nargs='*', help='Staged filenames (supplied by pre-commit).')
165-
parser.add_argument('--create-example', action='store_true', help='Generate example env file (.env.example).')
187+
parser = argparse.ArgumentParser(
188+
description='Blocks committing .env files.',
189+
)
190+
parser.add_argument(
191+
'filenames',
192+
nargs='*',
193+
help='Staged filenames (supplied by pre-commit).',
194+
)
195+
parser.add_argument(
196+
'--create-example',
197+
action='store_true',
198+
help='Generate example env file (.env.example).',
199+
)
166200
args = parser.parse_args(argv)
167201
env_file = DEFAULT_ENV_FILE
168202
# Use current working directory as repository root (pre-commit executes
@@ -175,14 +209,26 @@ def main(argv: Sequence[str] | None = None) -> int:
175209
if not _has_env(args.filenames, env_file):
176210
return 0
177211

178-
gitignore_modified = ensure_env_in_gitignore(env_file, gitignore_file, GITIGNORE_BANNER)
212+
gitignore_modified = ensure_env_in_gitignore(
213+
env_file,
214+
gitignore_file,
215+
GITIGNORE_BANNER,
216+
)
179217
example_created = False
180218
if args.create_example:
181219
# Source env is always looked up relative to repo root
182220
if os.path.exists(env_abspath):
183-
example_created = create_example_env(env_abspath, example_file)
184-
185-
_print_failure(env_file, gitignore_file, example_created, gitignore_modified)
221+
example_created = create_example_env(
222+
env_abspath,
223+
example_file,
224+
)
225+
226+
_print_failure(
227+
env_file,
228+
gitignore_file,
229+
example_created,
230+
gitignore_modified,
231+
)
186232
return 1 # Block commit
187233

188234

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ exclude =
2929

3030
[options.entry_points]
3131
console_scripts =
32+
catch-dotenv = pre_commit_hooks.catch_dotenv:main
3233
check-added-large-files = pre_commit_hooks.check_added_large_files:main
3334
check-ast = pre_commit_hooks.check_ast:main
3435
check-builtin-literals = pre_commit_hooks.check_builtin_literals:main

0 commit comments

Comments
 (0)