diff --git a/pre_commit_hooks/catch_dotenv.py b/pre_commit_hooks/catch_dotenv.py index 1131f6d..f85c23b 100644 --- a/pre_commit_hooks/catch_dotenv.py +++ b/pre_commit_hooks/catch_dotenv.py @@ -6,16 +6,16 @@ import os import re import sys import tempfile +from collections.abc import Iterable from collections.abc import Sequence -from typing import Iterable # 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" +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*=") +_KEY_REGEX = re.compile(r'^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=') def _atomic_write(path: str, data: str) -> None: @@ -28,16 +28,16 @@ def _atomic_write(path: str, data: str) -> None: parallel (tests exercise concurrent normalization). Keeping this helper local avoids adding any dependency. """ - fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or ".") + fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(path) or '.') try: - with os.fdopen(fd, "w", encoding="utf-8", newline="") as tmp_f: + 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: + except OSError: pass @@ -45,38 +45,51 @@ 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, "r", encoding="utf-8") as f: + with open(gitignore_file, encoding='utf-8') as f: original_text = f.read() lines = original_text.splitlines() else: - original_text = "" + original_text = '' lines = [] except OSError as exc: - print(f"ERROR: unable to read {gitignore_file}: {exc}", file=sys.stderr) + print( + f"ERROR: unable to read {gitignore_file}: {exc}", + file=sys.stderr, + ) raise - return original_text if lines else "", lines + return original_text if lines else '', lines -def _normalize_gitignore_lines(lines: list[str], env_file: str, banner: str) -> list[str]: - """Normalize .gitignore lines by removing duplicates and adding canonical tail.""" +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}] + 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 + filtered.append('') # ensure single blank before banner elif not filtered: # empty file -> still separate section visually - filtered.append("") + 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: +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 @@ -89,27 +102,30 @@ def ensure_env_in_gitignore(env_file: str, gitignore_file: str, banner: str) -> return False filtered = _normalize_gitignore_lines(lines, env_file, banner) - new_content = "\n".join(filtered) + "\n" + 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 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) + 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, "r", encoding="utf-8") as f_env: + 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) @@ -136,33 +152,51 @@ def create_example_env(src_env: str, example_file: str) -> bool: ] body = [f"{k}=" for k in keys] try: - _atomic_write(example_file, "\n".join(header + body) + "\n") + _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) + 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 a target env file by basename.""" + """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: +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('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).') + 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 @@ -175,14 +209,26 @@ def main(argv: Sequence[str] | None = None) -> int: if not _has_env(args.filenames, env_file): return 0 - gitignore_modified = ensure_env_in_gitignore(env_file, gitignore_file, GITIGNORE_BANNER) + 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) + example_created = create_example_env( + env_abspath, + example_file, + ) - _print_failure(env_file, gitignore_file, example_created, gitignore_modified) + _print_failure( + env_file, + gitignore_file, + example_created, + gitignore_modified, + ) return 1 # Block commit diff --git a/setup.cfg b/setup.cfg index 14f7a91..65a319c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,6 +29,7 @@ exclude = [options.entry_points] console_scripts = + catch-dotenv = pre_commit_hooks.catch_dotenv:main check-added-large-files = pre_commit_hooks.check_added_large_files:main check-ast = pre_commit_hooks.check_ast:main check-builtin-literals = pre_commit_hooks.check_builtin_literals:main diff --git a/tests/catch_dotenv_test.py b/tests/catch_dotenv_test.py index 5e54560..4f0363d 100644 --- a/tests/catch_dotenv_test.py +++ b/tests/catch_dotenv_test.py @@ -1,18 +1,24 @@ from __future__ import annotations import os +import re +import shutil import threading import time from pathlib import Path -import shutil -import re + import pytest -from pre_commit_hooks.catch_dotenv import main, ensure_env_in_gitignore, GITIGNORE_BANNER, DEFAULT_ENV_FILE, DEFAULT_EXAMPLE_ENV_FILE, DEFAULT_GITIGNORE_FILE +from pre_commit_hooks.catch_dotenv import DEFAULT_ENV_FILE +from pre_commit_hooks.catch_dotenv import DEFAULT_EXAMPLE_ENV_FILE +from pre_commit_hooks.catch_dotenv import DEFAULT_GITIGNORE_FILE +from pre_commit_hooks.catch_dotenv import ensure_env_in_gitignore +from pre_commit_hooks.catch_dotenv import GITIGNORE_BANNER +from pre_commit_hooks.catch_dotenv import main -# Tests cover hook behavior: detection gating, .gitignore normalization, example -# file generation parsing edge cases, idempotency, and preservation of existing -# content. Each test isolates a single behavioral contract. +# Tests cover hook behavior: detection gating, .gitignore normalization, +# example file generation parsing edge cases, idempotency, and preservation of +# existing content. Each test isolates a single behavioral contract. @pytest.fixture() @@ -25,13 +31,18 @@ def env_file(tmp_path: Path) -> Path: # __file__ => /tests/catch_dotenv_test.py # parents[0] = /tests, parents[1] = # Source file stored as test.env in repo (cannot commit a real .env in CI) - resource_env = Path(__file__).resolve().parents[1] / 'testing' / 'resources' / 'test.env' + resource_env = ( + Path(__file__).resolve().parents[1] / + 'testing' / 'resources' / 'test.env' + ) dest = tmp_path / DEFAULT_ENV_FILE shutil.copyfile(resource_env, dest) return dest -def run_hook(tmp_path: Path, staged: list[str], create_example: bool = False) -> int: +def run_hook( + tmp_path: Path, staged: list[str], create_example: bool = False, +) -> int: cwd = os.getcwd() os.chdir(tmp_path) try: @@ -43,13 +54,15 @@ def run_hook(tmp_path: Path, staged: list[str], create_example: bool = False) -> os.chdir(cwd) -def test_no_env_file(tmp_path: Path, env_file: Path): +def test_no_env_file(tmp_path: Path, env_file: Path) -> None: """Hook should no-op (return 0) if .env not staged even if it exists.""" (tmp_path / 'foo.txt').write_text('x') assert run_hook(tmp_path, ['foo.txt']) == 0 -def test_blocks_env_and_updates_gitignore(tmp_path: Path, env_file: Path): +def test_blocks_env_and_updates_gitignore( + tmp_path: Path, env_file: Path, +) -> None: """Staging .env triggers block (exit 1) and appends banner + env entry.""" ret = run_hook(tmp_path, [DEFAULT_ENV_FILE]) assert ret == 1 @@ -58,12 +71,12 @@ def test_blocks_env_and_updates_gitignore(tmp_path: Path, env_file: Path): assert gi[-1] == DEFAULT_ENV_FILE -def test_env_present_but_not_staged(tmp_path: Path, env_file: Path): +def test_env_present_but_not_staged(tmp_path: Path, env_file: Path) -> None: """Existing .env on disk but not staged should not block commit.""" assert run_hook(tmp_path, ['unrelated.txt']) == 0 -def test_idempotent_gitignore(tmp_path: Path, env_file: Path): +def test_idempotent_gitignore(tmp_path: Path, env_file: Path) -> None: """Re-running after initial normalization leaves .gitignore unchanged.""" g = tmp_path / DEFAULT_GITIGNORE_FILE g.write_text(f"{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n") @@ -75,10 +88,14 @@ def test_idempotent_gitignore(tmp_path: Path, env_file: Path): assert g.read_text() == content1 # unchanged -def test_gitignore_with_existing_content_preserved(tmp_path: Path, env_file: Path): +def test_gitignore_with_existing_content_preserved( + tmp_path: Path, env_file: Path, +) -> None: """Existing entries stay intact; banner/env appended at end cleanly.""" g = tmp_path / DEFAULT_GITIGNORE_FILE - g.write_text('node_modules/\n# comment line\n') # no trailing newline section markers + g.write_text( + 'node_modules/\n# comment line\n', + ) # no trailing newline section markers run_hook(tmp_path, [DEFAULT_ENV_FILE]) lines = g.read_text().splitlines() # original content should still be at top @@ -88,10 +105,15 @@ def test_gitignore_with_existing_content_preserved(tmp_path: Path, env_file: Pat assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE] -def test_gitignore_duplicates_are_collapsed(tmp_path: Path, env_file: Path): +def test_gitignore_duplicates_are_collapsed( + tmp_path: Path, env_file: Path, +) -> None: """Multiple prior duplicate banner/env lines collapse to single pair.""" g = tmp_path / DEFAULT_GITIGNORE_FILE - g.write_text(f"other\n{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n\n\n") + g.write_text( + f"other\n{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n" + f"{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n\n\n", + ) run_hook(tmp_path, [DEFAULT_ENV_FILE]) lines = g.read_text().splitlines() assert lines.count(GITIGNORE_BANNER) == 1 @@ -99,7 +121,7 @@ def test_gitignore_duplicates_are_collapsed(tmp_path: Path, env_file: Path): assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE] -def test_create_example(tmp_path: Path, env_file: Path): +def test_create_example(tmp_path: Path, env_file: Path) -> None: """Example file includes discovered keys; values stripped to KEY=.""" ret = run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True) assert ret == 1 @@ -108,12 +130,20 @@ def test_create_example(tmp_path: Path, env_file: Path): # All key lines should be KEY= assert all(re.match(r'^[A-Za-z_][A-Za-z0-9_]*=$', ln) for ln in key_lines) # Spot check a few known keys from resource file - for k in ['BACKEND_CONTAINER_PORT=', 'ACCESS_TOKEN_SECRET=', 'SUPABASE_SERVICE_KEY=']: + for k in [ + 'OPENAI_API_KEY=', + 'ACCESS_TOKEN_SECRET=', + 'SUPABASE_SERVICE_KEY=', + ]: assert k in key_lines -def test_create_example_duplicate_key_variant_ignored(tmp_path: Path, env_file: Path): - """Appending whitespace duplicate of existing key should not duplicate in example.""" +def test_create_example_duplicate_key_variant_ignored( + tmp_path: Path, env_file: Path, +) -> None: + """Appending whitespace duplicate of existing key should not duplicate + in example. + """ with open(env_file, 'a', encoding='utf-8') as f: f.write('BACKEND_CONTAINER_PORT =999 # duplicate variant\n') run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True) @@ -122,7 +152,9 @@ def test_create_example_duplicate_key_variant_ignored(tmp_path: Path, env_file: assert key_lines.count('BACKEND_CONTAINER_PORT=') == 1 -def test_gitignore_without_trailing_newline(tmp_path: Path, env_file: Path): +def test_gitignore_without_trailing_newline( + tmp_path: Path, env_file: Path, +) -> None: """Normalization works when original .gitignore lacks trailing newline.""" g = tmp_path / DEFAULT_GITIGNORE_FILE g.write_text('existing_line') # no newline at EOF @@ -132,11 +164,20 @@ def test_gitignore_without_trailing_newline(tmp_path: Path, env_file: Path): assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE] -def test_ensure_env_in_gitignore_normalizes(tmp_path: Path, env_file: Path): - """Direct API call collapses duplicates and produces canonical tail layout.""" +def test_ensure_env_in_gitignore_normalizes( + tmp_path: Path, env_file: Path, +) -> None: + """Direct API call collapses duplicates and produces canonical tail + layout. + """ g = tmp_path / DEFAULT_GITIGNORE_FILE - g.write_text(f"{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n\n") - modified = ensure_env_in_gitignore(DEFAULT_ENV_FILE, str(g), GITIGNORE_BANNER) + g.write_text( + f"{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n" + f"{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n\n", + ) + modified = ensure_env_in_gitignore( + DEFAULT_ENV_FILE, str(g), GITIGNORE_BANNER, + ) assert modified is True lines = g.read_text().splitlines() # final two lines should be banner + env @@ -146,37 +187,55 @@ def test_ensure_env_in_gitignore_normalizes(tmp_path: Path, env_file: Path): assert lines.count(DEFAULT_ENV_FILE) == 1 -def test_source_env_file_not_modified(tmp_path: Path, env_file: Path): +def test_source_env_file_not_modified( + tmp_path: Path, env_file: Path, +) -> None: """Hook must not alter original .env (comments and formatting stay).""" original = env_file.read_text() run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True) assert env_file.read_text() == original -def test_large_resource_env_parsing(tmp_path: Path, env_file: Path): - """Generate example from resource env; assert broad key coverage & format.""" +def test_large_resource_env_parsing( + tmp_path: Path, env_file: Path, +) -> None: + """Generate example from resource env; assert broad key coverage & + format. + """ ret = run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True) assert ret == 1 - example_lines = (tmp_path / DEFAULT_EXAMPLE_ENV_FILE).read_text().splitlines() + example_lines = ( + (tmp_path / DEFAULT_EXAMPLE_ENV_FILE).read_text().splitlines() + ) key_lines = [ln for ln in example_lines if ln and not ln.startswith('#')] assert len(key_lines) > 20 assert all(re.match(r'^[A-Za-z_][A-Za-z0-9_]*=$', ln) for ln in key_lines) - for k in ['BACKEND_CONTAINER_PORT=', 'SUPABASE_SERVICE_KEY=', 'ACCESS_TOKEN_SECRET=']: + for k in [ + 'BACKEND_CONTAINER_PORT=', + 'SUPABASE_SERVICE_KEY=', + 'ACCESS_TOKEN_SECRET=', + ]: assert k in key_lines -def test_failure_message_content(tmp_path: Path, env_file: Path, capsys): +def test_failure_message_content( + tmp_path: Path, + env_file: Path, + capsys: pytest.CaptureFixture[str], +) -> None: """Hook stdout message should contain key phrases when blocking commit.""" ret = run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True) assert ret == 1 out = capsys.readouterr().out.strip() - assert "Blocked committing" in out + assert 'Blocked committing' in out assert DEFAULT_GITIGNORE_FILE in out # updated path appears - assert "Generated .env.example." in out - assert "Remove .env" in out + assert 'Generated .env.example.' in out + assert 'Remove .env' in out -def test_create_example_when_env_missing(tmp_path: Path, env_file: Path): +def test_create_example_when_env_missing( + tmp_path: Path, env_file: Path, +) -> None: """--create-example with no .env staged or present should no-op (exit 0). Uses env_file fixture (requirement: all tests use fixture) then removes the @@ -188,18 +247,28 @@ def test_create_example_when_env_missing(tmp_path: Path, env_file: Path): assert not (tmp_path / DEFAULT_EXAMPLE_ENV_FILE).exists() -def test_gitignore_is_directory_error(tmp_path: Path, env_file: Path, capsys): - """If .gitignore path is a directory, hook should print error and still block.""" +def test_gitignore_is_directory_error( + tmp_path: Path, + env_file: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """If .gitignore path is a directory, hook should print error and still + block. + """ gitignore_dir = tmp_path / DEFAULT_GITIGNORE_FILE gitignore_dir.mkdir() ret = run_hook(tmp_path, [DEFAULT_ENV_FILE]) assert ret == 1 # still blocks commit captured = capsys.readouterr() - assert "ERROR:" in captured.err # error now printed to stderr + assert 'ERROR:' in captured.err # error now printed to stderr -def test_env_example_overwrites_existing(tmp_path: Path, env_file: Path): - """Pre-existing example file with junk should be overwritten with header & keys.""" +def test_env_example_overwrites_existing( + tmp_path: Path, env_file: Path, +) -> None: + """Pre-existing example file with junk should be overwritten with header + & keys. + """ example = tmp_path / DEFAULT_EXAMPLE_ENV_FILE example.write_text('junk=1\nSHOULD_NOT_REMAIN=2\n') run_hook(tmp_path, [DEFAULT_ENV_FILE], create_example=True) @@ -210,12 +279,17 @@ def test_env_example_overwrites_existing(tmp_path: Path, env_file: Path): assert 'SHOULD_NOT_REMAIN=2' not in content -def test_large_gitignore_normalization_performance(tmp_path: Path, env_file: Path): +def test_large_gitignore_normalization_performance( + tmp_path: Path, env_file: Path, +) -> None: """Very large .gitignore remains normalized quickly (functional smoke).""" g = tmp_path / DEFAULT_GITIGNORE_FILE # Generate many lines with scattered duplicates of banner/env - lines = [f"file_{i}" for i in range(3000)] + [GITIGNORE_BANNER, DEFAULT_ENV_FILE] * 3 - g.write_text("\n".join(lines) + "\n") + lines = ( + [f"file_{i}" for i in range(3000)] + + [GITIGNORE_BANNER, DEFAULT_ENV_FILE] * 3 + ) + g.write_text('\n'.join(lines) + '\n') start = time.time() run_hook(tmp_path, [DEFAULT_ENV_FILE]) elapsed = time.time() - start @@ -223,12 +297,17 @@ def test_large_gitignore_normalization_performance(tmp_path: Path, env_file: Pat assert result_lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE] assert result_lines.count(GITIGNORE_BANNER) == 1 assert result_lines.count(DEFAULT_ENV_FILE) == 1 - # Soft performance expectation: should finish fast (< 0.5s on typical dev machine) + # Soft performance expectation: should finish fast + # (< 0.5s on typical dev machine) assert elapsed < 0.5 -def test_concurrent_gitignore_writes(tmp_path: Path, env_file: Path): - """Concurrent ensure_env_in_gitignore calls result in canonical final state.""" +def test_concurrent_gitignore_writes( + tmp_path: Path, env_file: Path, +) -> None: + """Concurrent ensure_env_in_gitignore calls result in canonical final + state. + """ g = tmp_path / DEFAULT_GITIGNORE_FILE # Seed with messy duplicates g.write_text(f"other\n{GITIGNORE_BANNER}\n{DEFAULT_ENV_FILE}\n\n") @@ -247,8 +326,12 @@ def test_concurrent_gitignore_writes(tmp_path: Path, env_file: Path): assert lines.count(DEFAULT_ENV_FILE) == 1 -def test_mixed_staged_files(tmp_path: Path, env_file: Path): - """Staging .env with other files still blocks and only normalizes gitignore once.""" +def test_mixed_staged_files( + tmp_path: Path, env_file: Path, +) -> None: + """Staging .env with other files still blocks and only normalizes + gitignore once. + """ other = tmp_path / 'README.md' other.write_text('hi') ret = run_hook(tmp_path, [DEFAULT_ENV_FILE, 'README.md']) @@ -257,18 +340,29 @@ def test_mixed_staged_files(tmp_path: Path, env_file: Path): assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE] -def test_already_ignored_env_with_variations(tmp_path: Path, env_file: Path): - """Pre-existing ignore lines with spacing normalize to single canonical pair.""" +def test_already_ignored_env_with_variations( + tmp_path: Path, env_file: Path, +) -> None: + """Pre-existing ignore lines with spacing normalize to single + canonical pair. + """ g = tmp_path / DEFAULT_GITIGNORE_FILE - g.write_text(f" {DEFAULT_ENV_FILE} \n{GITIGNORE_BANNER}\n {DEFAULT_ENV_FILE}\n") + g.write_text( + f" {DEFAULT_ENV_FILE} \n{GITIGNORE_BANNER}\n" + f" {DEFAULT_ENV_FILE}\n", + ) run_hook(tmp_path, [DEFAULT_ENV_FILE]) lines = g.read_text().splitlines() assert lines[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE] assert lines.count(DEFAULT_ENV_FILE) == 1 -def test_subdirectory_invocation(tmp_path: Path, env_file: Path): - """Running from a subdirectory now writes .gitignore relative to CWD (simplified behavior).""" +def test_subdirectory_invocation( + tmp_path: Path, env_file: Path, +) -> None: + """Running from a subdirectory now writes .gitignore relative to CWD + (simplified behavior). + """ sub = tmp_path / 'subdir' sub.mkdir() # simulate repository root marker @@ -277,7 +371,9 @@ def test_subdirectory_invocation(tmp_path: Path, env_file: Path): cwd = os.getcwd() os.chdir(sub) try: - ret = main(['../' + DEFAULT_ENV_FILE]) # staged path relative to subdir + ret = main( + ['../' + DEFAULT_ENV_FILE], + ) # staged path relative to subdir gi = (sub / DEFAULT_GITIGNORE_FILE).read_text().splitlines() finally: os.chdir(cwd) @@ -285,31 +381,49 @@ def test_subdirectory_invocation(tmp_path: Path, env_file: Path): assert gi[-2:] == [GITIGNORE_BANNER, DEFAULT_ENV_FILE] -def test_atomic_write_failure_gitignore(monkeypatch, tmp_path: Path, env_file: Path, capsys): - """Simulate os.replace failure during gitignore write to exercise error path.""" - def boom(*a, **k): +def test_atomic_write_failure_gitignore( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + env_file: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Simulate os.replace failure during gitignore write to exercise error + path. + """ + def boom(*_a: object, **_k: object) -> None: raise OSError('replace-fail') monkeypatch.setattr('pre_commit_hooks.catch_dotenv.os.replace', boom) - modified = ensure_env_in_gitignore(DEFAULT_ENV_FILE, str(tmp_path / DEFAULT_GITIGNORE_FILE), GITIGNORE_BANNER) + modified = ensure_env_in_gitignore( + DEFAULT_ENV_FILE, + str(tmp_path / DEFAULT_GITIGNORE_FILE), + GITIGNORE_BANNER, + ) assert modified is False captured = capsys.readouterr() assert 'ERROR: unable to write' in captured.err -def test_atomic_write_failure_example(monkeypatch, tmp_path: Path, env_file: Path, capsys): +def test_atomic_write_failure_example( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + env_file: Path, + capsys: pytest.CaptureFixture[str], +) -> None: """Simulate os.replace failure when writing example env file.""" - def boom(*a, **k): + def boom(*_a: object, **_k: object) -> None: raise OSError('replace-fail') monkeypatch.setattr('pre_commit_hooks.catch_dotenv.os.replace', boom) ok = False - # create_example_env requires source .env to exist; env_file fixture provides it in tmp_path root + # create_example_env requires source .env to exist; env_file fixture + # provides it in tmp_path root cwd = os.getcwd() os.chdir(tmp_path) try: ok = main([DEFAULT_ENV_FILE, '--create-example']) == 1 finally: os.chdir(cwd) - # hook still blocks; but example creation failed -> message should not claim Example file generated + # hook still blocks; but example creation failed -> message should + # not claim Example file generated assert ok is True captured = capsys.readouterr() out = captured.out