/
/
/
1"""
2Fail when code calls ``datetime.now()`` / ``datetime.utcnow()`` instead of the shared helpers.
3
4``music_assistant.helpers.datetime`` centralizes "current time" handling (``utc()``, ``now()``,
5``utc_timestamp()``, ...) so the whole codebase agrees on timezone-awareness and is trivially
6mockable in tests. Reaching for ``datetime.datetime.now(...)`` / ``.utcnow()`` directly bypasses
7that. New code must use the helpers; the call sites that predate this check are grandfathered
8through ``scripts/lint_baselines/naive_datetime_usage.txt`` and are cleaned up separately.
9
10Usage:
11 uv run -m scripts.check_datetime_helpers
12 uv run -m scripts.check_datetime_helpers --update-baseline # after migrating call sites
13"""
14
15from __future__ import annotations
16
17import ast
18import sys
19from pathlib import Path
20
21from scripts.lint_baseline import diff_baseline, load_baseline, render_baseline
22
23# ruff: noqa: T201
24
25# repo paths (this file lives at <repo>/scripts/check_datetime_helpers.py)
26REPO_ROOT = Path(__file__).resolve().parents[1]
27PACKAGE_ROOT = REPO_ROOT / "music_assistant"
28BASELINE_PATH = REPO_ROOT / "scripts" / "lint_baselines" / "naive_datetime_usage.txt"
29
30# The helper module itself is the one legitimate place to call datetime.now()/utcnow().
31EXCLUDED_FILES = frozenset(
32 {
33 PACKAGE_ROOT / "helpers" / "datetime.py",
34 }
35)
36
37_BASELINE_HEADER = (
38 "Direct datetime.now()/utcnow() call sites that predate the helpers check.\n"
39 "New code must use music_assistant.helpers.datetime (utc(), now(), ...).\n"
40 "Regenerate with: uv run -m scripts.check_datetime_helpers --update-baseline"
41)
42
43
44def find_violations() -> dict[str, list[str]]:
45 """Return ``{repo-relative path: [messages]}`` for every direct ``now()``/``utcnow()`` call."""
46 violations: dict[str, list[str]] = {}
47 for path in _iter_python_files():
48 try:
49 tree = ast.parse(path.read_text(encoding="utf-8"))
50 except SyntaxError:
51 continue
52 rel = path.relative_to(REPO_ROOT).as_posix()
53 for node in ast.walk(tree):
54 if isinstance(node, ast.Call) and (call := _naive_now_call(node)) is not None:
55 violations.setdefault(rel, []).append(
56 f"{rel}:{node.lineno}: {call}() â use music_assistant.helpers.datetime instead"
57 )
58 return violations
59
60
61def main(argv: list[str] | None = None) -> int:
62 """Report new direct datetime call sites; return 1 when any were found."""
63 argv = sys.argv[1:] if argv is None else argv
64 violations = find_violations()
65 counts = {path: len(messages) for path, messages in violations.items()}
66 if "--update-baseline" in argv:
67 BASELINE_PATH.write_text(render_baseline(counts, _BASELINE_HEADER), encoding="utf-8")
68 print(f"Wrote {sum(counts.values())} call sites to {BASELINE_PATH.relative_to(REPO_ROOT)}")
69 return 0
70 regressions, improvements = diff_baseline(counts, load_baseline(BASELINE_PATH))
71 if not regressions and not improvements:
72 return 0
73 if regressions:
74 print("Use music_assistant.helpers.datetime instead of calling datetime directly:")
75 for path in sorted(regressions):
76 for message in violations[path]:
77 print(f" {message}")
78 if improvements:
79 print(
80 "These files have fewer direct datetime calls than the baseline; update it with "
81 "`uv run -m scripts.check_datetime_helpers --update-baseline`:"
82 )
83 for path in sorted(improvements):
84 print(f" {path}")
85 return 1
86
87
88def _iter_python_files() -> list[Path]:
89 """Return all shipped Python files except the datetime helper implementation itself."""
90 return [path for path in sorted(PACKAGE_ROOT.rglob("*.py")) if path not in EXCLUDED_FILES]
91
92
93def _naive_now_call(node: ast.Call) -> str | None:
94 """
95 Return the dotted call name when a node is a direct ``datetime`` now/utcnow call, else None.
96
97 Matches ``datetime.now``, ``datetime.utcnow``, ``datetime.datetime.now`` and
98 ``datetime.datetime.utcnow`` (the common shapes), regardless of arguments.
99 """
100 func = node.func
101 if not isinstance(func, ast.Attribute) or func.attr not in ("now", "utcnow"):
102 return None
103 base = func.value
104 if isinstance(base, ast.Name) and base.id == "datetime":
105 return f"datetime.{func.attr}"
106 if isinstance(base, ast.Attribute) and base.attr == "datetime":
107 return f"datetime.datetime.{func.attr}"
108 return None
109
110
111if __name__ == "__main__":
112 raise SystemExit(main())
113