Files
enterprise-addons/_classify.py
T

208 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""Classify every OEEL-original module by 'community-safety'.
For each <module>/__manifest__.py in enterprise-addons/, determine:
class:
- safe : depends only on OCB modules, AND its Python/XML doesn't
reference other OEEL-original modules
- keep : depends on OCB + some OEEL modules but those OEEL deps
also depend on OCB (chain) — i.e. we can ship as a bundle
- doomed : depends on an Enterprise-only feature that OCB lacks
(studio, sign, planning, documents, spreadsheet, voip, ...)
"""
from __future__ import annotations
import ast
import os
import re
import json
from pathlib import Path
OEEL_ROOT = Path("/root/Documents/trae_projects/one_b_os/enterprise-addons")
OCB_PATH = Path("/tmp/odoo_ocb/addons")
ENTERPRISE_REPO_URL = (
"https://raw.githubusercontent.com/odoo/odoo/19.0/addons"
)
OCB_MODULES: set[str] = set()
for p in OCB_PATH.iterdir():
if p.is_dir() and (p / "__manifest__.py").is_file():
OCB_MODULES.add(p.name)
OEEL_MODULES: set[str] = set()
for p in OEEL_ROOT.iterdir():
if p.is_dir() and (p / "__manifest__.py").is_file():
OEEL_MODULES.add(p.name)
# Truly Enterprise-locked feature modules (no OCB equivalent).
# Decided heuristically: modules whose filename or purpose has no Community
# counterpart, AND which are referenced by many other modules.
TRULY_ENTERPRISE = {
# Application-level whole apps with no OCB equivalent
"web_studio", "website_studio",
"documents", "documents_spreadsheet", "spreadsheet", "spreadsheet_edition",
"spreadsheet_dashboard", "spreadsheet_enterprise",
"sign", "sign_itsme",
"planning", "appointment",
"voip", "voip_onsip",
"knowledge", "website_knowledge", "ai",
"whatsapp",
"marketing_automation",
"approvals", "approvals_purchase",
"frontdesk",
"helpdesk", "helpdesk_*",
"hr_appraisal", "hr_payroll", "timesheet_grid", "project_enterprise",
"website_helpdesk",
"industry_fsm", "worksheet",
"event_social", "event_enterprise",
"delivery_fedex", "delivery_dhl", "delivery_bpost", "delivery_easypost",
"delivery_sendcloud", "delivery_usps", "delivery_ups",
"delivery_starshipit", "delivery_envia", "delivery_shiprocket",
"sale_subscription", "sale_renting",
"account_accountant", "account_avatax", "account_sepa_direct_debit",
"account_budget", "account_batch_payment", "account_followup",
"account_asset", "account_reports", "account_saft",
"account_extract", "account_invoice_extract", "account_bank_statement_extract",
"hr_attendance_gantt", "hr_gantt", "hr_holidays_gantt",
"web_gantt", "web_grid", "web_map", "web_cohort",
"sale_temporal", "sale_amazon", "sale_shopee", "sale_ebay",
"website_sale_renting",
"crm_enterprise", "crm_helpdesk",
"iap_alternative", "iap_credit",
"stock_barcode", "stock_fleet_enterprise",
"mail_*", # mail_enterprise, mail_gantt, ...
"data_recycle",
"project_forecast",
"website_helpdesk_knowledge",
"ai_*",
"barcodes_gs1_nomenclature",
"l10n_*:hr_payroll", # payroll localization
"test_*",
}
def parse_manifest(path: Path) -> dict | None:
src = path.read_text(encoding="utf-8")
try:
tree = ast.parse(src)
except SyntaxError:
return None
for node in ast.walk(tree):
if isinstance(node, ast.Dict):
d = {}
for k, v in zip(node.keys, node.values):
if isinstance(k, ast.Constant):
d.setdefault(k.value, _to_py(v))
return d
return None
def _to_py(node):
if isinstance(node, ast.Constant):
return node.value
if isinstance(node, ast.List):
return [_to_py(e) for e in node.elts]
if isinstance(node, ast.Tuple):
return tuple(_to_py(e) for e in node.elts)
if isinstance(node, ast.Dict):
return {
_to_py(k): _to_py(v)
for k, v in zip(node.keys, node.values)
}
return None
def get_depends(manifest: dict | None) -> list[str]:
if not manifest:
return []
d = manifest.get("depends", [])
return list(d) if isinstance(d, list) else []
# --- Scan Python files for cross-module imports/reference. ---
IMPORT_RE = re.compile(r"from\s+odoo\.addons\.([\w.]+)\s+import")
ENV_RE = re.compile(r"self\.env\[?\s*['\"]([\w.]+)['\"]\s*\]?")
def module_requires_oeel(mod: str) -> list[str]:
"""Return list of OEEL-original modules referenced in code/XML."""
base = OEEL_ROOT / mod
requires: set[str] = set()
for r, _, files in os.walk(base):
for fn in files:
if not fn.endswith((".py", ".xml")):
continue
try:
src = (Path(r) / fn).read_text(
encoding="utf-8", errors="ignore"
)
except OSError:
continue
for m in IMPORT_RE.finditer(src):
target = m.group(1).split(".")[0]
if target in OEEL_MODULES and target != mod:
requires.add(target)
for m in ENV_RE.finditer(src):
target = m.group(1)
if "." in target:
root = target.split(".")[0]
else:
root = target
# Most models live under their own module's namespace.
# Rough heuristic: if the model name starts with a
# likely-OEEL word the module already needs an OEEL module.
return sorted(requires)
# --- Classify. ---
def categorize(mod: str) -> tuple[str, list[str]]:
manifest_path = OEEL_ROOT / mod / "__manifest__.py"
manifest = parse_manifest(manifest_path)
deps = get_depends(manifest)
# Deps that point outside OCB
non_ocb_deps = [d for d in deps if d not in OCB_MODULES]
# Truly enterprise-only dependency
doomed = [d for d in non_ocb_deps if d in TRULY_ENTERPRISE]
if doomed:
return "doomed", non_ocb_deps
code_oeel = module_requires_oeel(mod)
if not non_ocb_deps and not code_oeel:
return "safe", []
if non_ocb_deps or code_oeel:
return "keep", sorted(set(non_ocb_deps + code_oeel))
return "doomed", non_ocb_deps
def main():
buckets: dict[str, list[tuple[str, list[str]]]] = {
"safe": [], "keep": [], "doomed": [],
}
for mod in sorted(OEEL_MODULES):
cls, deps = categorize(mod)
buckets[cls].append((mod, deps))
summary = {
cls: {"count": len(v),
"samples": v[:5],
"total": len(v)}
for cls, v in buckets.items()
}
print(json.dumps(summary, indent=2))
out = Path("/tmp/classification.txt")
with out.open("w") as f:
for cls, items in buckets.items():
f.write(f"=== {cls} ({len(items)}) ===\n")
for m, deps in items:
f.write(f" {m}: {deps}\n")
print(f"\nFull classification: {out}")
if __name__ == "__main__":
main()