forked from pre-commit/pre-commit-hooks
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcatch_dotenv.py
More file actions
236 lines (202 loc) · 7.08 KB
/
catch_dotenv.py
File metadata and controls
236 lines (202 loc) · 7.08 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
#!/usr/bin/env python
from __future__ import annotations
import argparse
import os
import re
import sys
import tempfile
from collections.abc import Iterable
from collections.abc import Sequence
# Defaults / constants
DEFAULT_ENV_FILE = '.env'
DEFAULT_GITIGNORE_FILE = '.gitignore'
DEFAULT_EXAMPLE_ENV_FILE = '.env.example'
GITIGNORE_BANNER = '# Added by pre-commit hook to prevent committing secrets'
_KEY_REGEX = re.compile(r'^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=')
def _atomic_write(path: str, data: str) -> None:
"""Atomically (best-effort) write text.
Writes to a same-directory temporary file then replaces the target with
os.replace(). This is a slight divergence from most existing hooks which
write directly, but here we intentionally reduce the (small) risk of
partially-written files because the hook may be invoked rapidly / in
parallel (tests exercise concurrent normalization). Keeping this helper
local avoids adding any dependency.
"""
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or '.')
try:
with os.fdopen(fd, 'w', encoding='utf-8', newline='') as tmp_f:
tmp_f.write(data)
os.replace(tmp_path, path)
finally: # Clean up if replace failed
if os.path.exists(tmp_path): # (rare failure case)
try:
os.remove(tmp_path)
except OSError:
pass
def _read_gitignore(gitignore_file: str) -> tuple[str, list[str]]:
"""Read and parse .gitignore file content."""
try:
if os.path.exists(gitignore_file):
with open(gitignore_file, encoding='utf-8') as f:
original_text = f.read()
lines = original_text.splitlines()
else:
original_text = ''
lines = []
except OSError as exc:
print(
f"ERROR: unable to read {gitignore_file}: {exc}",
file=sys.stderr,
)
raise
return original_text, lines
def _normalize_gitignore_lines(
lines: list[str],
env_file: str,
banner: str,
) -> list[str]:
"""Normalize .gitignore lines by removing duplicates and canonical tail."""
# Trim trailing blank lines
while lines and not lines[-1].strip():
lines.pop()
# Remove existing occurrences
filtered: list[str] = [
ln for ln in lines if ln.strip() not in {env_file, banner}
]
if filtered and filtered[-1].strip():
filtered.append('') # ensure single blank before banner
elif not filtered: # empty file -> still separate section visually
filtered.append('')
filtered.append(banner)
filtered.append(env_file)
return filtered
def ensure_env_in_gitignore(
env_file: str,
gitignore_file: str,
banner: str,
) -> bool:
"""Ensure canonical banner + env tail in .gitignore.
Returns True only when the file content was changed. Returns False both
when unchanged and on IO errors (we intentionally conflate for the simple
hook contract; errors are still surfaced via stderr output).
"""
try:
original_content_str, lines = _read_gitignore(gitignore_file)
except OSError:
return False
filtered = _normalize_gitignore_lines(lines, env_file, banner)
new_content = '\n'.join(filtered) + '\n'
# Normalize original content to a single trailing newline for comparison
normalized_original = original_content_str
if normalized_original and not normalized_original.endswith('\n'):
normalized_original += '\n'
if new_content == normalized_original:
return False
try:
_atomic_write(gitignore_file, new_content)
return True
except OSError as exc:
print(
f"ERROR: unable to write {gitignore_file}: {exc}",
file=sys.stderr,
)
return False
def create_example_env(src_env: str, example_file: str) -> bool:
"""Generate .env.example with unique KEY= lines (no values)."""
try:
with open(src_env, encoding='utf-8') as f_env:
lines = f_env.readlines()
except OSError as exc:
print(f"ERROR: unable to read {src_env}: {exc}", file=sys.stderr)
return False
seen: set[str] = set()
keys: list[str] = []
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
m = _KEY_REGEX.match(stripped)
if not m:
continue
key = m.group(1)
if key not in seen:
seen.add(key)
keys.append(key)
header = [
'# Generated by catch-dotenv hook.',
'# Variable names only – fill in sample values as needed.',
'',
]
body = [f"{k}=" for k in keys]
try:
_atomic_write(example_file, '\n'.join(header + body) + '\n')
return True
except OSError as exc: # pragma: no cover
print(
f"ERROR: unable to write '{example_file}': {exc}",
file=sys.stderr,
)
return False
def _has_env(filenames: Iterable[str], env_file: str) -> bool:
"""Return True if any staged path refers to target env file by basename."""
return any(os.path.basename(name) == env_file for name in filenames)
def _print_failure(
env_file: str,
gitignore_file: str,
example_created: bool,
gitignore_modified: bool,
) -> None:
# Match typical hook output style: one short line per action.
print(f"Blocked committing {env_file}.")
if gitignore_modified:
print(f"Updated {gitignore_file}.")
if example_created:
print('Generated .env.example.')
print(f"Remove {env_file} from the commit and retry.")
def main(argv: Sequence[str] | None = None) -> int:
"""Hook entry-point."""
parser = argparse.ArgumentParser(
description='Blocks committing .env files.',
)
parser.add_argument(
'filenames',
nargs='*',
help='Staged filenames (supplied by pre-commit).',
)
parser.add_argument(
'--create-example',
action='store_true',
help='Generate example env file (.env.example).',
)
args = parser.parse_args(argv)
env_file = DEFAULT_ENV_FILE
# Use current working directory as repository root (pre-commit executes
# hooks from the repo root).
repo_root = os.getcwd()
gitignore_file = os.path.join(repo_root, DEFAULT_GITIGNORE_FILE)
example_file = os.path.join(repo_root, DEFAULT_EXAMPLE_ENV_FILE)
env_abspath = os.path.join(repo_root, env_file)
if not _has_env(args.filenames, env_file):
return 0
gitignore_modified = ensure_env_in_gitignore(
env_file,
gitignore_file,
GITIGNORE_BANNER,
)
example_created = False
if args.create_example:
# Source env is always looked up relative to repo root
if os.path.exists(env_abspath):
example_created = create_example_env(
env_abspath,
example_file,
)
_print_failure(
env_file,
gitignore_file,
example_created,
gitignore_modified,
)
return 1 # Block commit
if __name__ == '__main__':
raise SystemExit(main())