From da0c843bdd67bdb7e085fcb0badf95fcd9d52ebb Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:39:55 +0200 Subject: [PATCH] fix: default profile report containers to empty --- scripts/profile_sync.py | 10 +++---- tests/test_dataclass_defaults.py | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 tests/test_dataclass_defaults.py diff --git a/scripts/profile_sync.py b/scripts/profile_sync.py index a11425ff..580f677b 100644 --- a/scripts/profile_sync.py +++ b/scripts/profile_sync.py @@ -18,7 +18,7 @@ import re import subprocess import sys import tempfile -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field from datetime import date from pathlib import Path @@ -998,19 +998,19 @@ class EntryReport: class ProfileReport: name: str repo: str | None = None - repos: list[str] = None + repos: list[str] = field(default_factory=list) pinned_tag: str | None = None host: str | None = None pin: str | None = None pin_origin: str | None = None head: str | None = None - entries: list[EntryReport] = None + entries: list[EntryReport] = field(default_factory=list) skipped: str | None = None - counts: dict[str, int] = None + counts: dict[str, int] = field(default_factory=dict) # Declared repositories whose forge the tool cannot read. They are named # rather than dropped: a `source` on an unknown host used to fall back to # `upstream` in silence, and a divergence between the two went unseen. - unread: list[str] = None + unread: list[str] = field(default_factory=list) def needs_review(self) -> int: counts = self.counts or {} diff --git a/tests/test_dataclass_defaults.py b/tests/test_dataclass_defaults.py new file mode 100644 index 00000000..53019b75 --- /dev/null +++ b/tests/test_dataclass_defaults.py @@ -0,0 +1,46 @@ +"""A dataclass field typed as a container defaults to an empty one. + +ProfileReport declared `entries: list[EntryReport] = None`: the annotation +promised a list, the public constructor gave None, and format_report raised +TypeError on the first loop. +""" + +from __future__ import annotations + +import ast +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _is_dataclass(node: ast.ClassDef) -> bool: + for deco in node.decorator_list: + target = deco.func if isinstance(deco, ast.Call) else deco + name = target.attr if isinstance(target, ast.Attribute) else getattr(target, "id", "") + if name == "dataclass": + return True + return False + + +class ContainerFieldsAreNotNone(unittest.TestCase): + def test_no_container_annotation_defaults_to_none(self): + for path in sorted([*(REPO_ROOT / "scripts").rglob("*.py"), REPO_ROOT / "install.py"]): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not (isinstance(node, ast.ClassDef) and _is_dataclass(node)): + continue + for stmt in node.body: + if not isinstance(stmt, ast.AnnAssign) or stmt.value is None: + continue + annotation = ast.unparse(stmt.annotation) + is_none = isinstance(stmt.value, ast.Constant) and stmt.value.value is None + if is_none and annotation.split("[")[0] in ("list", "dict", "set") \ + and "None" not in annotation: + with self.subTest(module=path.name, cls=node.name, + field=ast.unparse(stmt.target)): + self.fail(f"{annotation} defaults to None") + + +if __name__ == "__main__": + unittest.main()