""" Tests for the check-datetime-timezone pre-commit hook. Verifies that the hook flags any ``Column(DateTime(...))`` / ``sa.Column(..., DateTime(...))`` pattern in both model files under ``src/local_deep_research/database/models/`` and migration files under ``src/local_deep_research/database/migrations/versions/``, and allows ``UtcDateTime`` when the ``sqlalchemy_utc`` import is present. Notable regressions locked in here: * No migration exemption for ``sa.DateTime(timezone=True)`` (see ``test_no_migration_exemption_for_timezone_true``). This is the inversion of the superseded PR #3515 approach. * ``ast.IfExp`` branches are BOTH inspected — a violation in either ``body`` or ``orelse`` is caught. * Path filter anchors on ``src/local_deep_research/`` so ``tests/database/models/`` is NOT scanned. """ import subprocess import sys import tempfile from pathlib import Path HOOK_SCRIPT = ( Path(__file__).parent.parent.parent / "scripts" / "pre_commit" / "check_datetime_timezone.py" ) PACKAGE_PREFIX = ("src", "local_deep_research") def _run_hook_on_source( source: str, subpath: tuple = PACKAGE_PREFIX + ("database", "models"), filename: str = "example.py", ) -> subprocess.CompletedProcess: """Write *source* into a directory tree matching *subpath* and run the hook.""" with tempfile.TemporaryDirectory() as base: nested = Path(base).joinpath(*subpath) nested.mkdir(parents=True) target = nested / filename target.write_text(source) return subprocess.run( [sys.executable, str(HOOK_SCRIPT), str(target)], capture_output=True, text=True, timeout=30, ) def _run_hook_on_files( files: list, ) -> subprocess.CompletedProcess: """Run the hook against multiple prepared files. ``files`` is a list of ``(subpath_tuple, filename, source)`` triples. Returns after running the hook once with all file paths as arguments. """ with tempfile.TemporaryDirectory() as base: paths = [] for subpath, filename, source in files: nested = Path(base).joinpath(*subpath) nested.mkdir(parents=True, exist_ok=True) target = nested / filename target.write_text(source) paths.append(str(target)) return subprocess.run( [sys.executable, str(HOOK_SCRIPT), *paths], capture_output=True, text=True, timeout=30, ) MODEL_PATH = PACKAGE_PREFIX + ("database", "models") MIGRATION_PATH = PACKAGE_PREFIX + ("database", "migrations", "versions") OUT_OF_SCOPE_PATH = ("tests", "database", "models") IMPORT_LINE = "from sqlalchemy_utc import UtcDateTime\n" IMPORT_LINE_COMBO = "from sqlalchemy_utc import utcnow, UtcDateTime\n" # --------------------------------------------------------------------------- # Flagged patterns # --------------------------------------------------------------------------- class TestFlagsViolations: """Patterns that must exit with returncode 1.""" def test_model_bare_datetime_call_flagged(self): result = _run_hook_on_source( "ts = Column(DateTime(), nullable=False)\n", subpath=MODEL_PATH, ) assert result.returncode == 1 assert "UtcDateTime" in result.stdout def test_model_bare_datetime_name_flagged(self): """Bare ``Column(DateTime)`` without parens — new stricter handling.""" result = _run_hook_on_source( "ts = Column(DateTime)\n", subpath=MODEL_PATH, ) assert result.returncode == 1 def test_model_utcdatetime_missing_import_flagged(self): result = _run_hook_on_source( "ts = Column(UtcDateTime, nullable=False)\n", subpath=MODEL_PATH, ) assert result.returncode == 1 assert "Missing import" in result.stdout def test_no_migration_exemption_for_timezone_true(self): """PR #3515's migration exemption is intentionally removed. A migration that uses ``sa.DateTime(timezone=True)`` is a violation under the ``UtcDateTime``-everywhere rule, even though the old hook behaviour accepted it. """ result = _run_hook_on_source( 'created_at = sa.Column("created_at", sa.DateTime(timezone=True), nullable=False)\n', subpath=MIGRATION_PATH, filename="0006_example.py", ) assert result.returncode == 1 assert "UtcDateTime" in result.stdout def test_migration_bare_datetime_flagged(self): result = _run_hook_on_source( 'ts = sa.Column("ts", sa.DateTime())\n', subpath=MIGRATION_PATH, filename="0006_example.py", ) assert result.returncode == 1 def test_conditional_type_violation_in_body(self): """``Column(DateTime() if cond else UtcDateTime())`` — body branch.""" source = IMPORT_LINE + ( "ts = Column(DateTime() if flag else UtcDateTime())\n" ) result = _run_hook_on_source(source, subpath=MODEL_PATH) assert result.returncode == 1 def test_conditional_type_violation_in_orelse(self): """``Column(UtcDateTime() if cond else DateTime())`` — orelse branch. Regression guard for the asymmetric-branch bug: if the helper short-circuits on the first resolved branch, this case slips through silently. """ source = IMPORT_LINE + ( "ts = Column(UtcDateTime() if flag else DateTime())\n" ) result = _run_hook_on_source(source, subpath=MODEL_PATH) assert result.returncode == 1 def test_two_violations_in_one_file_both_reported(self): source = ( "a = Column(DateTime(), nullable=False)\n" "b = Column(DateTime(), nullable=False)\n" ) result = _run_hook_on_source(source, subpath=MODEL_PATH) assert result.returncode == 1 assert "Line 1" in result.stdout assert "Line 2" in result.stdout def test_batch_only_dirty_file_reported(self): dirty = "ts = Column(DateTime(), nullable=False)\n" clean = IMPORT_LINE + "ts = Column(UtcDateTime, nullable=False)\n" result = _run_hook_on_files( [ (MODEL_PATH, "dirty.py", dirty), (MODEL_PATH, "clean.py", clean), ] ) assert result.returncode == 1 assert "dirty.py" in result.stdout assert "clean.py" not in result.stdout def test_path_filter_scans_src_dir(self): """Positive side of the path-filter boundary.""" result = _run_hook_on_source( "ts = Column(DateTime(), nullable=False)\n", subpath=MODEL_PATH, ) assert result.returncode == 1 # --------------------------------------------------------------------------- # Allowed patterns # --------------------------------------------------------------------------- class TestAllowsCorrectPatterns: """Patterns that must exit with returncode 0.""" def test_model_utcdatetime_bare_with_import(self): source = IMPORT_LINE + ( "ts = Column(UtcDateTime, default=utcnow(), nullable=False)\n" ) result = _run_hook_on_source(source, subpath=MODEL_PATH) assert result.returncode == 0 def test_migration_utcdatetime_call_with_import(self): source = IMPORT_LINE + ( 'ts = sa.Column("ts", UtcDateTime(), nullable=False)\n' ) result = _run_hook_on_source( source, subpath=MIGRATION_PATH, filename="0006_example.py" ) assert result.returncode == 0 def test_combo_import_order_detected(self): """``from sqlalchemy_utc import utcnow, UtcDateTime`` is accepted.""" source = IMPORT_LINE_COMBO + ( "ts = Column(UtcDateTime, default=utcnow(), nullable=False)\n" ) result = _run_hook_on_source(source, subpath=MODEL_PATH) assert result.returncode == 0 def test_empty_file(self): result = _run_hook_on_source("", subpath=MODEL_PATH) assert result.returncode == 0 def test_syntax_error_file_does_not_crash(self): """Hook's ast.parse swallows SyntaxError and returns 0 for that file.""" result = _run_hook_on_source("def foo(:\n", subpath=MODEL_PATH) assert result.returncode == 0 # --------------------------------------------------------------------------- # Path filter # --------------------------------------------------------------------------- class TestPathFilter: """The hook must only scan files under the package prefix.""" def test_file_outside_both_dirs_is_skipped(self): """Arbitrary path — hook does not scan.""" with tempfile.TemporaryDirectory() as base: target = Path(base) / "random" / "file.py" target.parent.mkdir(parents=True) target.write_text("ts = Column(DateTime(), nullable=False)\n") result = subprocess.run( [sys.executable, str(HOOK_SCRIPT), str(target)], capture_output=True, text=True, timeout=30, ) assert result.returncode == 0 def test_tests_dir_with_database_models_segment_is_skipped(self): """``tests/database/models/foo.py`` must NOT be scanned. Prevents the pre-existing substring fallback from false-firing on test fixtures that happen to declare schema-like code. """ result = _run_hook_on_source( "ts = Column(DateTime(), nullable=False)\n", subpath=OUT_OF_SCOPE_PATH, ) assert result.returncode == 0 def test_models_backup_substring_boundary(self): """``database/models_backup/`` must NOT match.""" result = _run_hook_on_source( "ts = Column(DateTime(), nullable=False)\n", subpath=PACKAGE_PREFIX + ("database", "models_backup"), ) assert result.returncode == 0 # --------------------------------------------------------------------------- # Output format # --------------------------------------------------------------------------- class TestOutputFormat: """Lock in the user-facing output so refactors don't silently change it.""" def test_violation_stdout_includes_filename(self): result = _run_hook_on_source( "ts = Column(DateTime(), nullable=False)\n", subpath=MODEL_PATH, filename="my_model.py", ) assert result.returncode == 1 assert "my_model.py" in result.stdout def test_violation_stdout_includes_fix_hint(self): result = _run_hook_on_source( "ts = Column(DateTime(), nullable=False)\n", subpath=MODEL_PATH, ) assert "UtcDateTime" in result.stdout assert "sqlalchemy_utc" in result.stdout