-
-
Notifications
You must be signed in to change notification settings - Fork 4.6k
feat(dev): Prevent absolute date times #110671
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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]: | ||
|
|
@@ -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", | ||
| filename="tests/x.py", | ||
| ) == [f"t.py:3:0: {msg}"] | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| f"def t():\n pass\n", | ||
| filename="tests/x.py", | ||
| ) == [f"t.py:4:1: {msg}"] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
|
|
||
| import ast | ||
| from collections.abc import Generator | ||
| from datetime import datetime, timezone | ||
| from typing import Any | ||
|
|
||
| S001_fmt = ( | ||
|
|
@@ -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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
|
@@ -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") | ||
|
|
@@ -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: | ||
|
|
||
There was a problem hiding this comment.
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.