Skip to content

Commit 989ac68

Browse files
committed
Improve error handling and clarity in catch_dotenv hook and tests
1 parent 33746f5 commit 989ac68

File tree

3 files changed

+137
-22
lines changed

3 files changed

+137
-22
lines changed

pre_commit_hooks/catch_dotenv.py

Lines changed: 51 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,30 @@
1919

2020

2121
def _atomic_write(path: str, data: str) -> None:
22-
"""Atomic-ish text write: write to same-dir temp then os.replace."""
22+
"""Atomically (best-effort) write text.
23+
24+
Writes to a same-directory temporary file then replaces the target with
25+
os.replace(). This is a slight divergence from most existing hooks which
26+
write directly, but here we intentionally reduce the (small) risk of
27+
partially-written files because the hook may be invoked rapidly / in
28+
parallel (tests exercise concurrent normalization). Keeping this helper
29+
local avoids adding any dependency.
30+
"""
2331
fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or ".")
2432
try:
2533
with os.fdopen(fd, "w", encoding="utf-8", newline="") as tmp_f:
2634
tmp_f.write(data)
2735
os.replace(tmp_path, path)
2836
finally: # Clean up if replace failed
29-
if os.path.exists(tmp_path): # pragma: no cover (rare failure case)
37+
if os.path.exists(tmp_path): # (rare failure case)
3038
try:
3139
os.remove(tmp_path)
32-
except OSError: # pragma: no cover
40+
except OSError:
3341
pass
3442

3543

36-
def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) -> bool:
37-
"""Normalize .gitignore tail (banner + env) collapsing duplicates. Returns True if modified."""
44+
def _read_gitignore(gitignore_file: str) -> tuple[str, list[str]]:
45+
"""Read and parse .gitignore file content."""
3846
try:
3947
if os.path.exists(gitignore_file):
4048
with open(gitignore_file, "r", encoding="utf-8") as f:
@@ -45,14 +53,17 @@ def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) ->
4553
lines = []
4654
except OSError as exc:
4755
print(f"ERROR: unable to read {gitignore_file}: {exc}", file=sys.stderr)
48-
return False
49-
original_content_str = original_text if lines else "" # post-read snapshot
56+
raise
57+
return original_text if lines else "", lines
5058

59+
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."""
5162
# Trim trailing blank lines
5263
while lines and not lines[-1].strip():
5364
lines.pop()
5465

55-
# Remove existing occurrences (exact match after strip)
66+
# Remove existing occurrences
5667
filtered: list[str] = [ln for ln in lines if ln.strip() not in {env_file, banner}]
5768

5869
if filtered and filtered[-1].strip():
@@ -62,14 +73,35 @@ def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) ->
6273

6374
filtered.append(banner)
6475
filtered.append(env_file)
76+
return filtered
77+
78+
79+
def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) -> bool:
80+
"""Ensure canonical banner + env tail in .gitignore.
81+
82+
Returns True only when the file content was changed. Returns False both
83+
when unchanged and on IO errors (we intentionally conflate for the simple
84+
hook contract; errors are still surfaced via stderr output).
85+
"""
86+
try:
87+
original_content_str, lines = _read_gitignore(gitignore_file)
88+
except OSError:
89+
return False
6590

91+
filtered = _normalize_gitignore_lines(lines, env_file, banner)
6692
new_content = "\n".join(filtered) + "\n"
67-
if new_content == (original_content_str if original_content_str.endswith("\n") else original_content_str + ("" if not original_content_str else "\n")):
93+
94+
# Normalize original content to a single trailing newline for comparison
95+
normalized_original = original_content_str
96+
if normalized_original and not normalized_original.endswith("\n"):
97+
normalized_original += "\n"
98+
if new_content == normalized_original:
6899
return False
100+
69101
try:
70102
_atomic_write(gitignore_file, new_content)
71103
return True
72-
except OSError as exc: # pragma: no cover
104+
except OSError as exc:
73105
print(f"ERROR: unable to write {gitignore_file}: {exc}", file=sys.stderr)
74106
return False
75107

@@ -107,7 +139,7 @@ def create_example_env(src_env: str, example_file: str) -> bool:
107139
_atomic_write(example_file, "\n".join(header + body) + "\n")
108140
return True
109141
except OSError as exc: # pragma: no cover
110-
print(f"ERROR: unable to write '{example_file}': {exc}")
142+
print(f"ERROR: unable to write '{example_file}': {exc}", file=sys.stderr)
111143
return False
112144

113145

@@ -116,26 +148,25 @@ def _has_env(filenames: Iterable[str], env_file: str) -> bool:
116148
return any(os.path.basename(name) == env_file for name in filenames)
117149

118150

119-
120-
121151
def _print_failure(env_file: str, gitignore_file: str, example_created: bool, gitignore_modified: bool) -> None:
122-
parts: list[str] = [f"Blocked committing {env_file}."]
152+
# Match typical hook output style: one short line per action.
153+
print(f"Blocked committing {env_file}.")
123154
if gitignore_modified:
124-
parts.append(f"Updated {gitignore_file}.")
155+
print(f"Updated {gitignore_file}.")
125156
if example_created:
126-
parts.append("Generated .env.example.")
127-
parts.append(f"Remove {env_file} from the commit and retry.")
128-
print(" ".join(parts))
157+
print("Generated .env.example.")
158+
print(f"Remove {env_file} from the commit and retry.")
129159

130160

131161
def main(argv: Sequence[str] | None = None) -> int:
132162
"""Hook entry-point."""
133-
parser = argparse.ArgumentParser(description="Block committing environment files (.env).")
163+
parser = argparse.ArgumentParser(description="Blocks committing .env files.")
134164
parser.add_argument('filenames', nargs='*', help='Staged filenames (supplied by pre-commit).')
135165
parser.add_argument('--create-example', action='store_true', help='Generate example env file (.env.example).')
136166
args = parser.parse_args(argv)
137167
env_file = DEFAULT_ENV_FILE
138-
# Use current working directory as repository root (simplified; no ascent)
168+
# Use current working directory as repository root (pre-commit executes
169+
# hooks from the repo root).
139170
repo_root = os.getcwd()
140171
gitignore_file = os.path.join(repo_root, DEFAULT_GITIGNORE_FILE)
141172
example_file = os.path.join(repo_root, DEFAULT_EXAMPLE_ENV_FILE)

testing/resources/test.env

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# =============================================================================
2+
# DUMMY SECRETS FOR DOTENV TEST
3+
# =============================================================================
4+
5+
# Container Internal Ports (what each service listens on inside containers)
6+
BACKEND_CONTAINER_PORT=3000 # FastAPI server internal port
7+
FRONTEND_CONTAINER_PORT=3001 # Vite dev server internal port
8+
9+
# External Access (what users/browsers connect to)
10+
CADDY_EXTERNAL_PORT=80 # External port exposed to host system
11+
12+
# URLs (how different components reference each other)
13+
BASE_HOSTNAME=http://localhost
14+
PUBLIC_FRONTEND_URL=${BASE_HOSTNAME}:${CADDY_EXTERNAL_PORT}
15+
LEGACY_BACKEND_DIRECT_URL=${BASE_HOSTNAME}:${BACKEND_CONTAINER_PORT} # Deprecated: direct backend access
16+
VITE_BROWSER_API_URL=${BASE_HOSTNAME}:${CADDY_EXTERNAL_PORT}/api # Frontend API calls through Caddy
17+
18+
# Environment
19+
NODE_ENV=development
20+
# Supabase
21+
SUPABASE_PROJECT_ID=979090c33e5da06f67921e70
22+
SUPABASE_PASSWORD=1bbad0861dbca0bad3bd58ac90fd87e1cfd13ebbbeaed730868a11fa38bf6a65
23+
SUPABASE_URL=https://${SUPABASE_PROJECT_ID}.supabase.co
24+
DATABASE_URL=postgresql://postgres.${SUPABASE_PROJECT_ID}:${SUPABASE_PASSWORD}@aws-0-us-west-1.pooler.supabase.com:5432/postgres
25+
SUPABASE_SERVICE_KEY=f37f35e070475d4003ea0973cc15ef8bd9956fd140c80d247a187f8e5b0d69d70a9555decd28ea405051bf31d1d1f949dba277f058ba7c0279359ccdeda0f0696ea803403b8ad76dbbf45c4220b45a44a66e643bf0ca575dffc69f22a57c7d6c693e4d55b5f02e8a0da192065a38b24cbed2234d005661beba6d58e3ef234e0f
26+
SUPABASE_S3_STORAGE_ENDPOINT=${SUPABASE_URL}/storage/v1/s3
27+
SUPABASE_STORAGE_BUCKET=my-bucket
28+
SUPABASE_REGION=us-west-1
29+
SUPABASE_S3_ACCESS_KEY_ID=323157dcde28202bda94ff4db4be5266
30+
SUPABASE_S3_SECRET_ACCESS_KEY=d37c900e43e9dfb2c9998fa65aaeea703014504bbfebfddbcf286ee7197dc975
31+
32+
# Storage (aliases for compatibility)
33+
STORAGE_URL=https://b8991834720f5477910eded7.supabase.co/storage/v1/s3
34+
STORAGE_BUCKET=my-bucket
35+
STORAGE_ACCESS_KEY=FEvMws2HMGW96oBMx6Cg98pP8k3h4eki
36+
STORAGE_SECRET_KEY=shq7peEUeYkdzuUDohoK6qx9Zpjvjq6Zz2coUDvyQARM3qk9QryKZmQqRmz4szzM
37+
STORAGE_REGION=us-west-1
38+
STORAGE_SKIP_BUCKET_CHECK=true
39+
40+
# Authentication
41+
ACCESS_TOKEN_SECRET=ghp_c9d4307ceb82d06b522c1a5e37a8b5d0BMwJpgMT
42+
REFRESH_TOKEN_SECRET=09cb1b7920aea0d2b63ae3264e27595225ca7132f92f4cc5eff6dc066957118d
43+
JWT_ALGORITHM=HS256
44+
45+
# Mail
46+
47+
48+
# Chrome Browser
49+
CHROME_TOKEN=ac126eb015837628b05ff2f0f568ff46
50+
CHROME_PROXY_HOST=chrome
51+
CHROME_PROXY_PORT=3002
52+
CHROME_PROXY_SSL=false
53+
CHROME_HEALTH=true
54+
CHROME_PORT=8080
55+
56+
# Test Configuration (for e2e)
57+
TEST_HOST=${BASE_HOSTNAME}
58+
TEST_TIMEOUT=35
59+
60+
TEST_PASSWORD=changeme
61+
POSTGRES_PORT=5432
62+
MINIO_PORT=9000
63+
REDIS_PORT=6379
64+
65+
# Database and Storage Paths
66+
SQLITE_DB_PATH=database.db
67+
TEST_DB_PATH=tests/testdb.duckdb
68+
STATIC_FILES_DIR=/app/static
69+
70+
# AI
71+
OPENAI_API_KEY = "sk-proj-a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
72+
COHERE_API_KEY = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
73+
OR_API_KEY = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
74+
AZURE_API_KEY = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
75+
GEMINI_API_KEY = "AIzaSyA1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r"
76+
VERTEXAI_API_KEY = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
77+
REPLICATE_API_KEY = "r8_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9"
78+
REPLICATE_API_TOKEN = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
79+
ANTHROPIC_API_KEY = "sk-ant-a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
80+
INFISICAL_TOKEN = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
81+
NOVITA_API_KEY = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"
82+
INFINITY_API_KEY = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"

tests/catch_dotenv_test.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,8 @@ def boom(*a, **k):
311311
os.chdir(cwd)
312312
# hook still blocks; but example creation failed -> message should not claim Example file generated
313313
assert ok is True
314-
out = capsys.readouterr().out
314+
captured = capsys.readouterr()
315+
out = captured.out
316+
err = captured.err
315317
assert 'Example file generated' not in out
316-
assert 'ERROR: unable to write' in out
318+
assert 'ERROR: unable to write' in err

0 commit comments

Comments
 (0)