#!/usr/bin/env python3 """ qualify-addon.py — Pillar 1 of the addon qualification gate. Static checks against an Odoo addon source tree: manifest __manifest__.py parses, has 'name', 'version' starts with '.0.' pip-deps every non-stdlib import is declared in external_dependencies['python'] app-name every element in any view XML has a name= attribute menu-icon top-level is set OR the addon ships static/description/icon.png hoot-import no JS file under static/src/ imports from '@odoo/hoot' or '@odoo/hoot-dom' webpack-name no JS file under static/lib/ uses self.webpackChunk_ — chunk array names must be addon-namespaced (e.g. webpackChunk_am5_) Usage: python3 scripts/qualify-addon.py [ ...] python3 scripts/qualify-addon.py --json Exit codes: 0 all checks passed for every addon 1 at least one addon failed at least one check 2 bad usage / I/O error Each finding is (severity, check, message). Severity: ERROR — the addon is broken-by-construction; refuse to admit to catalog WARN — likely problem but could be intentional; admit-with-warning posture """ from __future__ import annotations import ast import json import re import sys from dataclasses import dataclass, asdict from pathlib import Path from xml.etree import ElementTree as ET # Python stdlib modules. Conservative — anything imported NOT in here AND not in # ODOO_BUILTINS is flagged as needing declaration. Better to false-positive (fixable # by adding to external_dependencies) than miss a real missing dep. STDLIB = frozenset({ 'abc', 'argparse', 'ast', 'asyncio', 'base64', 'binascii', 'bisect', 'calendar', 'collections', 'configparser', 'contextlib', 'contextvars', 'copy', 'csv', 'ctypes', 'dataclasses', 'datetime', 'decimal', 'difflib', 'dis', 'email', 'enum', 'errno', 'fcntl', 'fnmatch', 'functools', 'gc', 'getpass', 'gettext', 'glob', 'gzip', 'hashlib', 'heapq', 'hmac', 'html', 'http', 'imaplib', 'importlib', 'inspect', 'io', 'ipaddress', 'itertools', 'json', 'keyword', 'locale', 'logging', 'math', 'mimetypes', 'multiprocessing', 'numbers', 'operator', 'os', 'pathlib', 'pickle', 'pkgutil', 'platform', 'pprint', 'queue', 'random', 're', 'select', 'selectors', 'shlex', 'shutil', 'signal', 'smtplib', 'socket', 'sqlite3', 'ssl', 'stat', 'string', 'struct', 'subprocess', 'sys', 'tempfile', 'textwrap', 'threading', 'time', 'timeit', 'token', 'tokenize', 'traceback', 'types', 'typing', 'unicodedata', 'unittest', 'urllib', 'uuid', 'warnings', 'weakref', 'xml', 'xmlrpc', 'zipfile', 'zlib', 'zoneinfo', '__future__', }) # Modules shipped by Odoo's base image. Never need declaration. ODOO_BUILTINS = frozenset({ 'odoo', 'psycopg2', 'lxml', 'PIL', 'requests', 'dateutil', 'pytz', 'passlib', 'werkzeug', 'jinja2', 'markupsafe', 'docutils', 'reportlab', 'babel', 'xlsxwriter', 'xlrd', 'xlwt', 'qrcode', 'vobject', 'polib', 'PyPDF2', 'cryptography', 'pyOpenSSL', 'OpenSSL', 'suds', 'num2words', 'pyldap', 'ldap', 'xmltodict', 'zeep', 'gevent', 'greenlet', 'libsass', 'idna', 'pyusb', 'serial', 'qrcode', 'mock', 'freezegun', 'phonenumbers', }) # Bare-name webpackChunk arrays we know collide. Detector flags any # `self.webpackChunk_` where doesn't have an addon-derived suffix. # We allow the canonical chunk array names listed here ONLY if the addon's # directory name matches — i.e. we suggest namespacing. WEBPACK_CHUNK_RE = re.compile(r'self\.(webpackChunk[a-zA-Z0-9_]+)\b') # JS imports of the hoot test framework that should never appear in production code. HOOT_IMPORT_RE = re.compile( r'''(?:from\s+['"]@odoo/hoot[a-z\-]*['"]|require\s*\(\s*['"]@odoo/hoot[a-z\-]*['"]\s*\))''' ) @dataclass class Finding: severity: str # 'ERROR' | 'WARN' check: str # short check id message: str # human-readable file: str | None = None # relative path, if applicable line: int | None = None # 1-indexed, if applicable # ---------------------------------------------------------------------------- # # Check 1 — manifest parses + has required keys # ---------------------------------------------------------------------------- # def check_manifest(addon_dir: Path) -> tuple[list[Finding], dict | None]: findings: list[Finding] = [] mf_path = addon_dir / '__manifest__.py' if not mf_path.exists(): findings.append(Finding('ERROR', 'manifest', 'no __manifest__.py')) return findings, None try: manifest = ast.literal_eval(mf_path.read_text()) except (SyntaxError, ValueError) as e: findings.append(Finding('ERROR', 'manifest', f'__manifest__.py does not parse as Python literal: {e}', file='__manifest__.py')) return findings, None if not isinstance(manifest, dict): findings.append(Finding('ERROR', 'manifest', '__manifest__.py top-level is not a dict', file='__manifest__.py')) return findings, None if not manifest.get('name'): findings.append(Finding('ERROR', 'manifest', "missing 'name' key (Odoo refuses install)", file='__manifest__.py')) version = manifest.get('version', '') if not re.match(r'^\d+\.0\.\d+\.\d+\.\d+$', version): findings.append(Finding('WARN', 'manifest', f"version {version!r} is not in '.0.x.y.z' form — " "Odoo will prepend the running Odoo major and may refuse install " "on a different major (incident #9)", file='__manifest__.py')) return findings, manifest # ---------------------------------------------------------------------------- # # Check 2 — pip deps: every non-stdlib import is in external_dependencies # ---------------------------------------------------------------------------- # def check_pip_deps(addon_dir: Path, manifest: dict) -> list[Finding]: findings: list[Finding] = [] declared = set(manifest.get('external_dependencies', {}).get('python', [])) addon_name = addon_dir.name # Pre-scan: collect this addon's submodule names so we don't flag intra-addon imports. own_submodules = {p.stem for p in addon_dir.rglob('*.py') if p.stem != '__init__'} own_submodules.add(addon_name) seen_imports: set[tuple[str, str, int]] = set() # (toplevel, file, line) for py_file in addon_dir.rglob('*.py'): if any(part.startswith('.') for part in py_file.parts): continue try: tree = ast.parse(py_file.read_text()) except (SyntaxError, UnicodeDecodeError): continue rel = py_file.relative_to(addon_dir).as_posix() for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: seen_imports.add((alias.name.split('.')[0], rel, node.lineno)) elif isinstance(node, ast.ImportFrom): if node.level: # relative import — intra-addon, skip continue if node.module: seen_imports.add((node.module.split('.')[0], rel, node.lineno)) for top, rel, lineno in sorted(seen_imports): if top in STDLIB or top in ODOO_BUILTINS or top in own_submodules: continue if top in declared: continue # PEP 8 names that are clearly local helpers (e.g. utils, models) — skip if # they look like a sibling module we missed in own_submodules. if (addon_dir / top).is_dir() or (addon_dir / f'{top}.py').exists(): continue findings.append(Finding( 'ERROR', 'pip-deps', f"imports '{top}' but it is not in external_dependencies['python'] " "(install will fail with ModuleNotFoundError — incident #5)", file=rel, line=lineno, )) return findings # ---------------------------------------------------------------------------- # # Check 3 — every element has a name= attribute # ---------------------------------------------------------------------------- # def check_app_name(addon_dir: Path) -> list[Finding]: findings: list[Finding] = [] # XML files in views/ + data/ may contain res_config_settings elements. for xml_file in list(addon_dir.rglob('views/*.xml')) + list(addon_dir.rglob('data/*.xml')): try: text = xml_file.read_text() except UnicodeDecodeError: continue rel = xml_file.relative_to(addon_dir).as_posix() # Multi-line tolerant regex: with everything between. for m in re.finditer(r']*?>', text, re.DOTALL): tag = m.group() if 'name=' in tag: continue line = text[:m.start()].count('\n') + 1 findings.append(Finding( 'ERROR', 'app-name', " element missing name= attribute. Odoo 18 SettingsFormCompiler " "calls toStringExpression(null) and crashes the entire Settings page " "(incident #7)", file=rel, line=line, )) return findings # ---------------------------------------------------------------------------- # # Check 4 — top-level menus declare web_icon OR addon ships static/description/icon.png # ---------------------------------------------------------------------------- # def check_menu_icon(addon_dir: Path) -> list[Finding]: findings: list[Finding] = [] has_default_icon = (addon_dir / 'static' / 'description' / 'icon.png').exists() for xml_file in addon_dir.rglob('*.xml'): try: text = xml_file.read_text() except UnicodeDecodeError: continue rel = xml_file.relative_to(addon_dir).as_posix() # Find whose XML has no parent= attribute (top-level menu). for m in re.finditer(r']*?/?>', text, re.DOTALL): tag = m.group() if 'parent=' in tag: continue if 'web_icon=' in tag: continue if has_default_icon: # Odoo 18's auto-fallback path. Soft warning since it works for top-level # menus that get web_icon auto-populated from the module's icon.png. # But our incident #6 showed even with icon.png present, web_icon often # ends up empty in DB. So WARN, not ERROR. line = text[:m.start()].count('\n') + 1 findings.append(Finding( 'WARN', 'menu-icon', "top-level has no web_icon=. Will fall back to " "static/description/icon.png IF Odoo's auto-populate fires; " "if not, menu shows blank (incident #6). Set web_icon explicitly.", file=rel, line=line, )) else: line = text[:m.start()].count('\n') + 1 findings.append(Finding( 'ERROR', 'menu-icon', "top-level has no web_icon= AND addon ships no " "static/description/icon.png — menu will render blank.", file=rel, line=line, )) return findings # ---------------------------------------------------------------------------- # # Check 5 — no @odoo/hoot* imports in static/src/ # ---------------------------------------------------------------------------- # def check_hoot_import(addon_dir: Path) -> list[Finding]: findings: list[Finding] = [] src_dir = addon_dir / 'static' / 'src' if not src_dir.exists(): return findings for js_file in src_dir.rglob('*.js'): try: text = js_file.read_text() except UnicodeDecodeError: continue rel = js_file.relative_to(addon_dir).as_posix() for m in HOOT_IMPORT_RE.finditer(text): line = text[:m.start()].count('\n') + 1 findings.append(Finding( 'ERROR', 'hoot-import', "imports from @odoo/hoot* in production code (static/src/). " "@odoo/hoot is the test framework; the production bundle does not " "register it. Page will white-screen (incident #3 class)", file=rel, line=line, )) return findings # ---------------------------------------------------------------------------- # # Check 6 — webpack chunk arrays in static/lib/ must be addon-namespaced # ---------------------------------------------------------------------------- # def check_webpack_chunk(addon_dir: Path) -> list[Finding]: findings: list[Finding] = [] lib_dir = addon_dir / 'static' / 'lib' if not lib_dir.exists(): return findings addon_name = addon_dir.name seen: set[str] = set() for js_file in lib_dir.rglob('*.js'): try: text = js_file.read_text() except UnicodeDecodeError: continue rel = js_file.relative_to(addon_dir).as_posix() for m in WEBPACK_CHUNK_RE.finditer(text): chunk_name = m.group(1) if chunk_name in seen: continue seen.add(chunk_name) # Acceptable if chunk name contains: full addon name OR any 4+ char # sub-token of the addon name (e.g. 'ksdn' for 'ks_dashboard_ninja') # OR a known-namespaced suffix (anything past the standard library # prefix). We just need confidence the chunk array is unique-per-addon. addon_lower = addon_name.lower() chunk_lower = chunk_name.lower() tokens = [addon_lower.replace('_', '')] + [ t for t in addon_lower.split('_') if len(t) >= 4 ] # Also accept any short 4+ char abbrev derived from initials of # underscore-separated parts (ks_dashboard_ninja -> ksdn) initials = ''.join(t[0] for t in addon_lower.split('_') if t) if len(initials) >= 3: tokens.append(initials) if any(t in chunk_lower for t in tokens): continue line = text[:m.start()].count('\n') + 1 findings.append(Finding( 'ERROR', 'webpack-chunk', f"uses bare webpack chunk array '{chunk_name}'. Two addons that ship " f"the same library (e.g. amCharts) collide on this global → bundle " f"execution aborts (incident #4). Rename to '{chunk_name}_{addon_name}' " "or similar.", file=rel, line=line, )) return findings # ---------------------------------------------------------------------------- # # Runner # ---------------------------------------------------------------------------- # def qualify_addon(addon_dir: Path) -> dict: findings: list[Finding] = [] manifest_findings, manifest = check_manifest(addon_dir) findings.extend(manifest_findings) if manifest is not None: findings.extend(check_pip_deps(addon_dir, manifest)) findings.extend(check_app_name(addon_dir)) findings.extend(check_menu_icon(addon_dir)) findings.extend(check_hoot_import(addon_dir)) findings.extend(check_webpack_chunk(addon_dir)) errors = sum(1 for f in findings if f.severity == 'ERROR') warns = sum(1 for f in findings if f.severity == 'WARN') return { 'addon': addon_dir.name, 'path': str(addon_dir), 'qualified': errors == 0, 'errors': errors, 'warns': warns, 'findings': [asdict(f) for f in findings], } def main(argv: list[str]) -> int: json_out = False args: list[str] = [] for a in argv[1:]: if a == '--json': json_out = True elif a in ('-h', '--help'): print(__doc__) return 0 else: args.append(a) if not args: print(__doc__, file=sys.stderr) return 2 results = [] for path_str in args: path = Path(path_str).resolve() if not path.is_dir() or not (path / '__manifest__.py').exists(): print(f'ERROR: {path} is not an Odoo addon directory ' '(missing __manifest__.py)', file=sys.stderr) return 2 results.append(qualify_addon(path)) if json_out: print(json.dumps(results, indent=2)) else: for r in results: badge = '\033[32mQUALIFIED\033[0m' if r['qualified'] else '\033[31mFAILED\033[0m' print(f"\n{badge} {r['addon']} ({r['errors']} error(s), {r['warns']} warning(s))") if not r['findings']: continue for f in r['findings']: tag = '\033[31m' if f['severity'] == 'ERROR' else '\033[33m' loc = '' if f['file']: loc = f" [{f['file']}" + (f":{f['line']}" if f['line'] else '') + ']' print(f" {tag}{f['severity']:5}\033[0m {f['check']:<14} {f['message']}{loc}") any_failed = any(not r['qualified'] for r in results) return 1 if any_failed else 0 if __name__ == '__main__': sys.exit(main(sys.argv))