From 770de6b0caa77f0e2c9a389ab90c38faf456a5b4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 03:13:58 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20script:=20diff=C3=A9rer=20les=20annotat?= =?UTF-8?q?ions,=20la=2012=20tourne=20en=20Python=203.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La migration lance ses outils avec le venv de la version Odoo courante. Au premier palier c'est celui de la 12, en 3.7, où « dict | None » (3.10) et « tuple[str, str] » (3.9) sont ÉVALUÉS au chargement du module. La restauration du zip mourait donc sur un TypeError avant d'avoir rien fait, dans execute.py — importé par db_restore.py. `from __future__ import annotations` existe depuis 3.7 et le dépôt s'en sert déjà dans treize fichiers. Un test le vérifie maintenant sur toute la fermeture d'imports des outils que le pilote lance, points d'entrée lus dans le pilote pour que le script ajouté demain soit couvert. --- EN --- The migration runs its tools with the venv of the current Odoo version. At the first step that is 12's, on 3.7, where « dict | None » (3.10) and « tuple[str, str] » (3.9) are EVALUATED when the module loads. Restoring the zip therefore died on a TypeError before doing anything at all, in execute.py — imported by db_restore.py. `from __future__ import annotations` has existed since 3.7 and the repo already uses it in thirteen files. A test now checks the whole import closure of the tools the driver launches, with the entry points read from the driver so tomorrow's script is covered too. Assisted-by: Claude Opus 5 --- script/execute/execute.py | 6 + script/git/git_tool.py | 6 + script/git/github_api.py | 6 + script/git/repo_url.py | 6 + test/test_old_python_annotations.py | 201 ++++++++++++++++++++++++++++ 5 files changed, 225 insertions(+) create mode 100644 test/test_old_python_annotations.py diff --git a/script/execute/execute.py b/script/execute/execute.py index a042323..b7826de 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -2,6 +2,12 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# Annotations différées : la migration charge ce module sous le Python +# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent +# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la +# migration meurt sur un TypeError avant d'avoir rien fait. +from __future__ import annotations + import datetime import logging import os diff --git a/script/git/git_tool.py b/script/git/git_tool.py index ccaabe3..cbf68fd 100644 --- a/script/git/git_tool.py +++ b/script/git/git_tool.py @@ -2,6 +2,12 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# Annotations différées : la migration charge ce module sous le Python +# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent +# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la +# migration meurt sur un TypeError avant d'avoir rien fait. +from __future__ import annotations + import os import pathlib import webbrowser diff --git a/script/git/github_api.py b/script/git/github_api.py index 9ebbadf..a806eae 100644 --- a/script/git/github_api.py +++ b/script/git/github_api.py @@ -2,6 +2,12 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# Annotations différées : la migration charge ce module sous le Python +# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent +# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la +# migration meurt sur un TypeError avant d'avoir rien fait. +from __future__ import annotations + import git from agithub.GitHub import GitHub from colorama import Fore, Style diff --git a/script/git/repo_url.py b/script/git/repo_url.py index 99e6bc1..8bdeb84 100644 --- a/script/git/repo_url.py +++ b/script/git/repo_url.py @@ -2,6 +2,12 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# Annotations différées : la migration charge ce module sous le Python +# d'Odoo 12 — 3.7 — où « dict | None » et « tuple[str, str] » n'existent +# pas encore. Sans ceci, l'annotation est ÉVALUÉE au chargement et la +# migration meurt sur un TypeError avant d'avoir rien fait. +from __future__ import annotations + import os diff --git a/test/test_old_python_annotations.py b/test/test_old_python_annotations.py new file mode 100644 index 0000000..acd65f2 --- /dev/null +++ b/test/test_old_python_annotations.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce que la migration charge doit se charger sous le plus vieux Python. + +Le pilote lance ses outils avec le venv de la version Odoo COURANTE : au +premier palier, c'est celui d'Odoo 12, en Python 3.7. Une annotation +« dict | None » (3.10) ou « tuple[str, str] » (3.9) y est ÉVALUÉE au +chargement du module et lève un TypeError avant que l'outil ait rien +fait — la migration meurt sur la restauration du zip. + +`from __future__ import annotations` (disponible depuis 3.7) diffère +l'évaluation : le module se charge partout, et le typage reste lisible. +""" + +import ast +import io +import json +import os +import re +import unittest + +RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +PILOTE = os.path.join(RACINE, "script", "todo", "todo_upgrade.py") +VERSIONS = os.path.join(RACINE, "conf", "supported_version_erplibre.json") + +DIFFERE = "from __future__ import annotations" +GENERIQUES = ("list", "dict", "tuple", "set", "type", "frozenset") + + +def lire(chemin): + with io.open(chemin, encoding="utf-8") as handle: + return handle.read() + + +def annotations_evaluees(arbre): + """Les annotations que Python évalue au chargement du module. + + Celles d'une signature le sont toujours. Une `x: T` de corps de + fonction ne l'est pas — la signaler produirait un faux échec. + """ + for noeud in ast.walk(arbre): + if isinstance(noeud, (ast.FunctionDef, ast.AsyncFunctionDef)): + a = noeud.args + for arg in ( + list(getattr(a, "posonlyargs", [])) + + list(a.args) + + list(a.kwonlyargs) + + [a.vararg, a.kwarg] + ): + if arg is not None and arg.annotation is not None: + yield arg.annotation + if noeud.returns is not None: + yield noeud.returns + elif isinstance(noeud, (ast.Module, ast.ClassDef)): + for petit in noeud.body: + if isinstance(petit, ast.AnnAssign): + yield petit.annotation + + +def trop_recent(source): + """Les annotations qu'un Python 3.7 ne saurait pas évaluer.""" + if DIFFERE in source: + return [] + trouves = [] + for annotation in annotations_evaluees(ast.parse(source)): + for noeud in ast.walk(annotation): + if isinstance(noeud, ast.BinOp) and isinstance( + noeud.op, ast.BitOr + ): + trouves.append(f"« a | b » ligne {noeud.lineno}") + elif ( + isinstance(noeud, ast.Subscript) + and isinstance(noeud.value, ast.Name) + and noeud.value.id in GENERIQUES + ): + trouves.append(f"« {noeud.value.id}[…] » ligne {noeud.lineno}") + return trouves + + +def module_vers_chemin(module): + chemin = os.path.join(RACINE, module.replace(".", os.sep) + ".py") + return chemin if os.path.isfile(chemin) else None + + +def importes(source): + for noeud in ast.walk(ast.parse(source)): + if isinstance(noeud, ast.ImportFrom): + if noeud.module and noeud.module.startswith("script"): + yield noeud.module + for alias in noeud.names: + yield f"{noeud.module}.{alias.name}" + elif isinstance(noeud, ast.Import): + for alias in noeud.names: + if alias.name.startswith("script"): + yield alias.name + + +def points_entree(): + """Les scripts que le pilote lance — lus dans le pilote, pas listés. + + Une liste écrite à la main vieillit en silence : le script ajouté + demain ne serait pas couvert, et c'est justement celui qui casse. + """ + trouves = set() + for ref in re.findall(r"\./script/[a-z0-9_/]+\.py", lire(PILOTE)): + chemin = os.path.join(RACINE, ref[2:]) + if os.path.isfile(chemin): + trouves.add(chemin) + return sorted(trouves) + + +def fermeture(): + vus, pile = set(), list(points_entree()) + while pile: + chemin = pile.pop() + if chemin in vus: + continue + vus.add(chemin) + for module in importes(lire(chemin)): + suivant = module_vers_chemin(module) + if suivant: + pile.append(suivant) + return sorted(vus) + + +class TestTheDetectorDetects(unittest.TestCase): + """Un détecteur qui ne détecte rien ferait passer le test à vide.""" + + def test_it_flags_a_union_in_a_signature(self): + self.assertTrue(trop_recent("def f(x: dict | None = None): pass\n")) + + def test_it_flags_a_builtin_generic_return(self): + self.assertTrue(trop_recent("def f() -> tuple[int, str]: pass\n")) + + def test_a_runtime_union_is_not_an_annotation(self): + # « set(a) | b » est une union d'ENSEMBLES, valide depuis toujours. + self.assertEqual(trop_recent("x = set('ab') | set('cd')\n"), []) + + def test_deferring_makes_it_legal(self): + self.assertEqual( + trop_recent(f"{DIFFERE}\ndef f(x: dict | None = None): pass\n"), [] + ) + + def test_a_body_annotation_is_never_evaluated(self): + self.assertEqual( + trop_recent("def f():\n x: dict | None = None\n return x\n"), + [], + ) + + +class TestTheFloorIsWhatTheProjectDeclares(unittest.TestCase): + def test_the_oldest_supported_python_is_still_pre_3_9(self): + # Le jour où la 12 et la 13 disparaissent, ce garde-fou n'a plus + # de raison d'être : qu'il le dise plutôt que de survivre seul. + pythons = [] + for valeur in json.loads(lire(VERSIONS)).values(): + if isinstance(valeur, dict) and valeur.get("python_version"): + pythons.append( + tuple( + int(x) for x in valeur["python_version"].split(".")[:2] + ) + ) + self.assertTrue(pythons) + self.assertLess( + min(pythons), + (3, 9), + "plus aucune version sous 3.9 : ce test peut disparaître", + ) + + +class TestWhatTheMigrationLoads(unittest.TestCase): + def test_the_scan_is_not_empty(self): + self.assertGreater(len(points_entree()), 10) + self.assertGreater(len(fermeture()), 15) + + def test_execute_is_in_the_closure(self): + # Le module par lequel l'incident est arrivé : s'il sortait de la + # fermeture, le test passerait sans plus rien garder. + self.assertIn( + os.path.join(RACINE, "script", "execute", "execute.py"), + fermeture(), + ) + + def test_every_loaded_module_survives_python_3_7(self): + coupables = {} + for chemin in fermeture(): + trouves = trop_recent(lire(chemin)) + if trouves: + coupables[os.path.relpath(chemin, RACINE)] = trouves + self.assertEqual( + coupables, + {}, + "annotation évaluée au chargement ; ajouter" + f" « {DIFFERE} » en tête de ces fichiers", + ) + + +if __name__ == "__main__": + unittest.main()