erplibre/test/test_tooling_requirements.py
Mathieu Benoit fcad37c24f [FIX] suite unitaire : bit d'exécution, script/*.py balayé, clé i18n
Trois contrôles préexistants passaient au rouge sur cette branche.

Un fichier qui porte un shebang doit être exécutable : commité en 644, il
casse deux invariants, le mode stocké par git et le bit sur disque. Ses six
frères de script/analyse/ sont en 755.

Le contrôle d'outillage ne balayait pas script/*.py : un module partagé posé
là passait pour un paquet tiers non déclaré. Trois fichiers de plus entrent
dans le balayage, sans révéler d'autre dépendance. Et une clé de traduction
appelée six fois n'était pas déclarée, l'écran rendant un mot anglais.

Vérifié : 4105 tests, tout vert.

--- EN ---

Three pre-existing checks were turning red on this branch.

A file carrying a shebang must be executable: committed as 644, it breaks two
invariants, the mode git stores and the bit on disk. Its six siblings in
script/analyse/ are 755.

The tooling check never scanned script/*.py: a shared module placed there was
taken for an undeclared third-party package. Three more files enter the scan,
revealing no other dependency. And a translation key called six times was
undeclared, the screen rendering an English word.

Checked: 4105 tests, all green.

Assisted-by: Claude Opus 5
2026-08-31 00:03:50 -04:00

156 lines
5.7 KiB
Python
Executable file

#!/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/*.py",
"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()