Converter for __init__.py to trytond.cfg (7.8)?

Tryton 7.8 moved the registration of models, wizards and reports to the tryton.cfg module file.

I wonder whether a tools for doing this conversion has been published anywhere. It might b very useful for all integrators and for the Tryton Community.

If noi such tools has been published: Anyone to contribute to sponsoring the development?

I just vibe coded the following script:

#!/usr/bin/env python3
"""
convert_tryton_register.py

Convert Tryton 7.6-style __init__.py Pool.register() declarations into a
Tryton 7.8+ tryton.cfg [register] section.

Usage:
  # Print the [register] section to stdout
  python convert_tryton_register.py --init path/to/__init__.py

  # Replace (or insert) [register] in an existing tryton.cfg and write to output
  python convert_tryton_register.py --init path/to/__init__.py \
      --cfg-in path/to/tryton.cfg --cfg-out path/to/tryton.cfg

Notes:
- This script only generates the [register] section (model/wizard/report),
  because that is what can be derived from __init__.py reliably.
- It parses source code using AST; it does not import or execute your module.
"""

from __future__ import annotations

import argparse
import ast
import re
from collections import OrderedDict
from pathlib import Path
from typing import Dict, List, Tuple


REGISTER_TYPES = ("model", "wizard", "report")


def dotted_name(node: ast.AST) -> str:
    """
    Return a dotted name like 'account.Account' from an AST node.
    Falls back to ast.unparse for unexpected shapes.
    """
    if isinstance(node, ast.Name):
        return node.id
    if isinstance(node, ast.Attribute):
        left = dotted_name(node.value)
        return f"{left}.{node.attr}" if left else node.attr
    if isinstance(node, ast.Call):
        # Unlikely in Pool.register args; fall back
        return ast.unparse(node)
    if isinstance(node, ast.Constant) and isinstance(node.value, str):
        return node.value
    return ast.unparse(node)


def is_pool_register_call(call: ast.Call) -> bool:
    """
    True for Pool.register(...)
    """
    f = call.func
    return (
        isinstance(f, ast.Attribute)
        and f.attr == "register"
        and isinstance(f.value, ast.Name)
        and f.value.id == "Pool"
    )


def extract_register_calls(init_source: str) -> List[ast.Call]:
    """
    Find Pool.register(...) calls inside def register(): ...
    """
    tree = ast.parse(init_source)

    register_func: ast.FunctionDef | None = None
    for node in tree.body:
        if isinstance(node, ast.FunctionDef) and node.name == "register":
            register_func = node
            break

    if register_func is None:
        raise ValueError("Could not find a function named register() in __init__.py")

    calls: List[ast.Call] = []
    for stmt in register_func.body:
        # Expect statements like: Pool.register(...)
        if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
            call = stmt.value
            if is_pool_register_call(call):
                calls.append(call)

    if not calls:
        raise ValueError("No Pool.register(...) calls found inside register()")

    return calls


def extract_type_keyword(call: ast.Call) -> str:
    """
    Read keyword type_='model'/'wizard'/'report'
    """
    for kw in call.keywords:
        if kw.arg == "type_":
            if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
                return kw.value.value
            return ast.unparse(kw.value).strip("'\"")
    raise ValueError("Pool.register(...) call is missing keyword argument type_=")


def extract_registered_objects(call: ast.Call) -> List[str]:
    """
    Positional args are the model/wizard/report classes, e.g. account.Account, move.Move, ...
    Keywords (module/type_) are ignored for list generation.
    """
    items: List[str] = []
    for arg in call.args:
        if isinstance(arg, ast.Starred):
            raise ValueError("Starred arguments (*something) are not supported in Pool.register args.")
        items.append(dotted_name(arg))
    return items


def build_register_map(calls: List[ast.Call]) -> Dict[str, List[str]]:
    """
    Return {"model":[...], "wizard":[...], "report":[...]} preserving order and removing duplicates.
    """
    buckets: Dict[str, "OrderedDict[str, None]"] = {
        t: OrderedDict() for t in REGISTER_TYPES
    }

    for call in calls:
        t = extract_type_keyword(call)
        if t not in buckets:
            # Keep unknown types too, but place them after known ones
            buckets.setdefault(t, OrderedDict())
        for item in extract_registered_objects(call):
            buckets[t][item] = None

    return {t: list(od.keys()) for t, od in buckets.items() if od}


def render_register_section(reg: Dict[str, List[str]]) -> str:
    """
    Render in tryton.cfg style:

    [register]
    model:
        a.b
        c.d
    wizard:
        ...
    """
    lines: List[str] = ["[register]"]
    for t in REGISTER_TYPES:
        if t in reg and reg[t]:
            lines.append(f"{t}:")
            lines.extend([f"    {name}" for name in reg[t]])
    # Include any non-standard types (if ever present), in stable order at the end
    for t in reg.keys():
        if t not in REGISTER_TYPES:
            lines.append(f"{t}:")
            lines.extend([f"    {name}" for name in reg[t]])
    return "\n".join(lines) + "\n"


REGISTER_SECTION_RE = re.compile(
    r"(?ms)^\[register\]\s*\n.*?(?=^\[[^\]]+\]\s*$|\Z)"
)


def replace_or_insert_register(cfg_text: str, new_register_section: str) -> str:
    """
    Replace existing [register] section if present; otherwise append at end with a blank line.
    """
    if REGISTER_SECTION_RE.search(cfg_text):
        return REGISTER_SECTION_RE.sub(new_register_section.rstrip() + "\n", cfg_text)

    # If there's no [register], append it at the end with spacing
    suffix = "" if cfg_text.endswith("\n") else "\n"
    return cfg_text + suffix + "\n" + new_register_section


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--init", required=True, help="Path to __init__.py (Tryton 7.6 style)")
    ap.add_argument("--cfg-in", help="Optional: existing tryton.cfg to update")
    ap.add_argument("--cfg-out", help="Optional: output path for updated tryton.cfg")
    args = ap.parse_args()

    init_path = Path(args.init)
    init_source = init_path.read_text(encoding="utf-8")

    calls = extract_register_calls(init_source)
    regmap = build_register_map(calls)
    register_section = render_register_section(regmap)

    if args.cfg_in:
        cfg_in = Path(args.cfg_in)
        cfg_text = cfg_in.read_text(encoding="utf-8")
        updated = replace_or_insert_register(cfg_text, register_section)

        if args.cfg_out:
            Path(args.cfg_out).write_text(updated, encoding="utf-8")
        else:
            # If cfg-in is provided but cfg-out is not, print updated cfg to stdout
            print(updated, end="")
    else:
        # No cfg input: just print the register section
        print(register_section, end="")


if __name__ == "__main__":
    main()

Feel free to run it, improve it and share it as you wish.

For now I just tested with account module, I will test on our custom modules when we start migrating them to 7.8 series.

If you want to share it under some tryton community repository I will take care of maintaining it if I found some issues on any modules.

2 Likes

Cool. Here we go: Tryton Community / tools / convert-init-to-cfg-78 · GitLab
The tool now works on modules/directories and canges the tryton.cfg file. IMHO this makes much more sense than specifying tree filenames.

I also tried cleaning up __init__.py, but lib230t is no longer part of Python (and implement a cleaner is quite some work). Thus I abstained rom that.

1 Like

This topic was automatically closed 24 hours after the last reply. New replies are no longer allowed.