7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""
|
|
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
|