From f7f5e800fe16f628a86978b57d325e6377927e17 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 04:45:16 -0400 Subject: [PATCH] [ADD] test: what the tooling imports must be declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit textual, urwid and lxml were already in the tooling requirements; the earlier table read as if they were missing, when it only compared against the system python. psycopg2 is the one absent, and stays so: nothing under script/ imports it, the analysis tools query PostgreSQL through psql, and the Odoo venvs declare it where Odoo needs it. A test now answers this by reading the imports instead of the venv. It bites both ways: a removed declaration, and a new undeclared import. Also chmod +X -> +x in the Dockerfile: uppercase only adds the bit to directories, or to files that already carry one. On what COPY had just laid down in 644 it did nothing. --- FR --- [ADD] test : ce que l'outillage importe doit être déclaré textual, urwid et lxml y étaient déjà ; le tableau précédent laissait croire le contraire, alors qu'il comparait au python du système. psycopg2 est le seul absent, et le reste : rien sous script/ ne l'importe, les outils d'analyse passent par psql, et les venvs Odoo le déclarent là où Odoo en a besoin. Un test répond désormais à la question en lisant les imports plutôt que le venv. Il mord des deux côtés : déclaration retirée, et import non déclaré. Aussi chmod +X -> +x dans le Dockerfile : la majuscule n'ajoute le bit qu'aux répertoires, ou aux fichiers qui en portent déjà un. Sur ce que COPY venait de déposer en 644, elle ne faisait rien. Assisted-by: Claude Opus 5 --- docker/Dockerfile | 6 +- test/test_tooling_requirements.py | 155 ++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100755 test/test_tooling_requirements.py diff --git a/docker/Dockerfile b/docker/Dockerfile index a93708f..32f95f2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -86,7 +86,11 @@ ENV ODOO_RC /etc/odoo/odoo.conf COPY wait-for-psql.py /usr/local/bin/wait-for-psql.py -RUN chmod +X /usr/local/bin/wait-for-psql.py +# « +X » en majuscule n'ajoute le bit que si l'un des trois est DÉJÀ posé, +# ou sur un répertoire. Sur le fichier que COPY vient de déposer en 644, il +# ne faisait rien : le script restait non exécutable dans l'image, malgré +# sa place dans /usr/local/bin. +RUN chmod +x /usr/local/bin/wait-for-psql.py # Set default user when running the container USER odoo diff --git a/test/test_tooling_requirements.py b/test/test_tooling_requirements.py new file mode 100755 index 0000000..0457472 --- /dev/null +++ b/test/test_tooling_requirements.py @@ -0,0 +1,155 @@ +#!/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 l'outillage importe doit être déclaré, pas seulement installé. + +Un paquet peut vivre dans `.venv.erplibre` sans figurer nulle part : arrivé +par la dépendance d'un autre, ou posé à la main un jour. Rien ne le réclame, +donc rien ne le réinstalle — il disparaît au prochain venv neuf, et l'outil +qui s'en servait tombe chez le suivant, pas chez celui qui l'a écrit. + +Le fichier porte déjà ce raisonnement en commentaire pour lxml. Ce test le +rend vérifiable au lieu de le laisser à la vigilance. + +Portée : le venv d'OUTILS. Les venvs Odoo ont leurs propres requirements, et +psycopg2 y est déclaré parce qu'Odoo en a besoin — l'outillage, lui, parle à +PostgreSQL par psql en sous-processus. +""" + +import ast +import glob +import os +import re +import sys +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +REQUIREMENTS = os.path.join(REPO, "requirement", "erplibre_require-ments.txt") + +PATTERNS = ( + "script/analyse/*.py", + "script/analyse/*/*.py", + "script/odoo/migration/*.py", + "script/todo/*.py", +) + +# Ce que l'on écrit pour importer n'est pas ce que l'on écrit pour installer. +# La table est explicite : un nouveau décalage doit être ajouté ici en +# connaissance de cause, plutôt que d'être toléré par une correspondance +# approximative qui laisserait passer une vraie absence. +PACKAGE_OF_MODULE = { + "dotenv": "python-dotenv", +} + +# Modules fournis par l'environnement, jamais par ce fichier. +PROVIDED_ELSEWHERE = { + # Le venv Odoo, pas celui des outils : ces scripts tournent sous + # `odoo-bin shell`, qui apporte son propre interpréteur. + "odoo", +} + + +def tooling_files(): + return sorted( + {p for m in PATTERNS for p in glob.glob(os.path.join(REPO, m))} + ) + + +def third_party_imports(): + """{module: {fichiers qui l'importent}}, hors stdlib et hors modules locaux.""" + files = tooling_files() + local = {os.path.basename(p)[:-3] for p in files} + found = {} + for path in files: + with open(path) as handle: + tree = ast.parse(handle.read()) + for node in ast.walk(tree): + names = [] + if isinstance(node, ast.Import): + names = [alias.name.split(".")[0] for alias in node.names] + elif ( + isinstance(node, ast.ImportFrom) + and node.module + and not node.level + ): + names = [node.module.split(".")[0]] + for name in names: + if ( + name in sys.stdlib_module_names + or name in local + or name in PROVIDED_ELSEWHERE + or name == "script" + ): + continue + found.setdefault(name, set()).add(os.path.relpath(path, REPO)) + return found + + +def declared(): + with open(REQUIREMENTS) as handle: + return handle.read() + + +class TestEveryImportIsDeclared(unittest.TestCase): + def test_the_scan_finds_something(self): + # Un test qui n'inspecte rien passe toujours. + self.assertGreater(len(tooling_files()), 20) + self.assertGreater(len(third_party_imports()), 5) + + def test_no_third_party_import_is_undeclared(self): + text = declared() + missing = [] + for module, users in sorted(third_party_imports().items()): + package = PACKAGE_OF_MODULE.get(module, module) + # En début de ligne : « lxml » ne doit pas être satisfait par + # une mention en commentaire ni par « python-lxml-stubs ». + if not re.search( + rf"^\s*{re.escape(package)}\b", text, re.M | re.I + ): + missing.append(f"{module} ({package}) <- {sorted(users)[0]}") + self.assertEqual(missing, []) + + def test_the_alias_table_has_no_dead_entry(self): + # Une correspondance qui ne sert plus masque le jour où le module + # revient sous un autre nom. + imported = set(third_party_imports()) + for module in PACKAGE_OF_MODULE: + with self.subTest(module=module): + self.assertIn(module, imported) + + def test_the_check_would_notice_a_removal(self): + # La preuve que la recherche mord : un paquet réellement déclaré, + # retiré du texte, doit être vu comme manquant. + # `assertRegex` cherche SANS re.M : « ^ » n'y vaut qu'au tout début + # de la chaîne, et la vérification ci-dessus, elle, lit ligne à ligne. + # Un motif compilé fait dire au test la même chose qu'au code. + motif = re.compile(r"^\s*lxml\b", re.M) + text = declared() + self.assertRegex(text, motif) + without = motif.sub("", text) + self.assertNotRegex(without, motif) + + +class TestPsycopg2StaysOutOfTheToolingVenv(unittest.TestCase): + """Décision prise, et la raison avec — sinon elle se reprend à l'aveugle. + + Les outils d'analyse interrogent PostgreSQL par `psql` en sous-processus : + un choix délibéré, qui porte la garantie de lecture seule côté serveur + (PGOPTIONS). Déclarer psycopg2 ici ferait compiler une extension C à + chaque installation neuve, sans que rien ne l'importe. + """ + + def test_nothing_in_the_tooling_imports_psycopg2(self): + self.assertNotIn("psycopg2", third_party_imports()) + + def test_the_analysis_tools_query_through_psql(self): + path = os.path.join(REPO, "script", "analyse", "lib_analyse.py") + with open(path) as handle: + source = handle.read() + self.assertIn('"psql"', source) + self.assertIn("default_transaction_read_only=on", source) + + +if __name__ == "__main__": + unittest.main()