Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions tests/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ Notes:
- Tests should ALWAYS be procedural with NO branching logic. It is very rare
that you will need an if statement as part of a backend test.

## Date-stable tests (this year only)

Do not use the **current UTC calendar year** as a hardcoded test “now” at **module or class** scope (or in `freeze_time(datetime(...))`)—that date drifts into Snuba retention. Use **`before_now(...)`** (or `now - timedelta`) for relative time. Fixed timestamps in **function bodies** (fixtures, assertions) are fine.

Flake8 **S015** flags only literals equal to the current UTC year in those scopes.

## Use Factories Instead of Directly Calling `Model.objects.create`

In Sentry Python tests, you MUST use factory methods in this priority order:
Expand Down
34 changes: 33 additions & 1 deletion tests/tools/test_flake8_plugin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

import ast
from datetime import datetime, timezone

from tools.flake8_plugin import SentryCheck
from tools.flake8_plugin import SentryCheck, _s015_msg


def _run(src: str, filename: str = "getsentry/t.py") -> list[str]:
Expand Down Expand Up @@ -240,3 +241,34 @@ def test(monkeypatch) -> None: pass
"""
expected = ["t.py:1:9: S014 Use `unittest.mock` instead"]
assert _run(src) == expected


def test_S015_current_year_only() -> None:
cy = datetime.now(timezone.utc).year
msg = _s015_msg(cy)
assert _run(
f"from datetime import datetime, timezone\n\n"
f"X = datetime({cy}, 1, 1, tzinfo=timezone.utc)\n",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since it uses cy (current year) the test fill fail.

filename="tests/x.py",
) == [f"t.py:3:0: {msg}"]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A non-empty list means an error was caught.

assert (
_run(
"from datetime import datetime, timezone\n\n"
"X = datetime(2020, 1, 1, tzinfo=timezone.utc)\n",
filename="tests/x.py",
)
== []
)
assert (
_run(
f"def f():\n x = datetime({cy}, 1, 1)\n",
filename="tests/x.py",
)
== []
)
assert _run(
f"from freezegun import freeze_time\nfrom datetime import datetime, timezone\n\n"
f"@freeze_time(datetime({cy}, 1, 1, tzinfo=timezone.utc))\n"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

freeze_time + current year -> failure

f"def t():\n pass\n",
filename="tests/x.py",
) == [f"t.py:4:1: {msg}"]
85 changes: 83 additions & 2 deletions tools/flake8_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import ast
from collections.abc import Generator
from datetime import datetime, timezone
from typing import Any

S001_fmt = (
Expand Down Expand Up @@ -41,12 +42,47 @@
S014_msg = "S014 Use `unittest.mock` instead"


# --- S015: do not hardcode this UTC calendar year as test "now" ---
# Only year == current UTC year at lint time. Module/class scope + freeze_time(datetime(...)).
def _s015_msg(year: int) -> str:
return (
f"S015 Do not hardcode datetime(..., {year}, ...) (current UTC year) at module/class "
"scope or in freeze_time(...); use before_now(...), now-timedelta, or another year"
)


def _is_tests_path(filename: str) -> bool:
return "tests/" in filename or "testutils/" in filename
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function will be used to only check test files.



# Returns the literal year when this is a datetime(...) call shape we lint for.
def _wall_clock_year_from_datetime_call(node: ast.Call) -> int | None:
if not node.args:
return None
y = node.args[0]
if not isinstance(y, ast.Constant) or not isinstance(y.value, int):
return None
if isinstance(node.func, ast.Name) and node.func.id == "datetime":
return y.value
if (
isinstance(node.func, ast.Attribute)
and node.func.attr == "datetime"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "datetime"
):
return y.value
return None


class SentryVisitor(ast.NodeVisitor):
def __init__(self, filename: str) -> None:
def __init__(self, filename: str, s015_year: int, s015_msg: str) -> None:
self.errors: list[tuple[int, int, str]] = []
self.filename = filename
self._s015_year = s015_year
self._s015_msg = s015_msg

self._except_vars: list[str | None] = []
self._function_depth = 0

def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
if node.module and not node.level:
Expand Down Expand Up @@ -141,7 +177,51 @@ def visit_Try(self, node: ast.Try) -> None:

self.generic_visit(node)

def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
self._function_depth += 1
try:
self.generic_visit(node)
finally:
self._function_depth -= 1

def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self._function_depth += 1
try:
self.generic_visit(node)
finally:
self._function_depth -= 1

def visit_Lambda(self, node: ast.Lambda) -> None:
self._function_depth += 1
try:
self.generic_visit(node)
finally:
self._function_depth -= 1

def visit_Assign(self, node: ast.Assign) -> None:
if (
_is_tests_path(self.filename)
and self._function_depth == 0
and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and isinstance(node.value, ast.Call)
):
y = _wall_clock_year_from_datetime_call(node.value)
if y is not None and y == self._s015_year:
self.errors.append((node.lineno, node.col_offset, self._s015_msg))
self.generic_visit(node)

def visit_Call(self, node: ast.Call) -> None:
if _is_tests_path(self.filename):
if (
isinstance(node.func, ast.Name)
and node.func.id == "freeze_time"
and node.args
and isinstance(node.args[0], ast.Call)
):
y = _wall_clock_year_from_datetime_call(node.args[0])
if y is not None and y == self._s015_year:
self.errors.append((node.lineno, node.col_offset, self._s015_msg))
if (
# override_settings(...)
(isinstance(node.func, ast.Name) and node.func.id == "override_settings")
Expand All @@ -167,7 +247,8 @@ def __init__(self, tree: ast.AST, filename: str) -> None:
self.filename = filename

def run(self) -> Generator[tuple[int, int, str, type[Any]]]:
visitor = SentryVisitor(self.filename)
cy = datetime.now(timezone.utc).year
visitor = SentryVisitor(self.filename, cy, _s015_msg(cy))
visitor.visit(self.tree)

for e in visitor.errors:
Expand Down
Loading