208 lines
6.9 KiB
Python
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()
|