Skip to content

Refactor

refactor

CST/AST helpers and rewriter for Odoo model files.

Reads model files, classifies fields and methods against a project KB, and rewrites class bodies to apply canonical section headers and Google-style docstring skeletons. Pure file-I/O — no git, no CLI.

Classes:

Name Description
ClassInfo

Information about an Odoo model class found in a source file.

SymbolInfo

Information about a single field or method within an Odoo model class.

Functions:

Name Description
analyse_file

Classify every Odoo model class and its symbols in a Python source file.

rewrite_file

Rewrite a Python source file by injecting section headers and docstring skeletons.

ClassInfo dataclass

ClassInfo(class_name: str, model_name: Optional[str], inherit: List[str], is_new_model: bool, lineno: int, symbols: List[SymbolInfo] = (lambda: [])())

Information about an Odoo model class found in a source file.

Attributes:

Name Type Description
class_name str

Python class name.

model_name Optional[str]

Value of _name, or None when only _inherit is set.

inherit List[str]

Values of _inherit (may be empty).

is_new_model bool

True when this module is the creator of the model, per the KB model_origins table.

lineno int

Source line number of the class definition.

symbols List[SymbolInfo]

Ordered list of fields and methods in the class.

is_inherit property

is_inherit: bool

Return True if this class only extends existing models via _inherit.

is_new_model instance-attribute

is_new_model: bool

True when this class is the creator of the model, as determined by the KB model_origins table.

SymbolInfo dataclass

SymbolInfo(name: str, kind: str, section: str, lineno: int, has_docstring: bool = False, has_super: bool = False, super_methods: List[str] = (lambda: [])(), kb_entry: Optional[Dict[str, Any]] = None, is_override: bool = False, field_type: Optional[str] = None)

Information about a single field or method within an Odoo model class.

Attributes:

Name Type Description
name str

Symbol name.

kind str

'field' or 'method'.

section str

Canonical section header (e.g. 'COMPUTE METHODS').

lineno int

Source line number of the definition.

has_docstring bool

True if the method already has a docstring.

has_super bool

True if the method calls super().

super_methods List[str]

Names of methods called via super().<name>().

kb_entry Optional[Dict[str, Any]]

Matching KB record, or None if not found.

is_override bool

True when the symbol is in the KB but has no super() call.

field_type Optional[str]

fields.XXX type string; only set when kind == 'field'.

analyse_file

analyse_file(py_file: Path, kb: KBReader, modules_index: Dict[str, Any], custom_module: str, module_local_refs: Optional[Dict[Tuple[str, str], List[str]]] = None) -> List[ClassInfo]

Classify every Odoo model class and its symbols in a Python source file.

Reads the file, parses it with ast, and for each model class found resolves its fields and methods against the KB. Syntax errors are logged and produce an empty result rather than raising.

Parameters:

Name Type Description Default

py_file

Path

Path to the Python source file to analyse.

required

kb

KBReader

Open KB reader used for symbol and model lookups.

required

modules_index

Dict[str, Any]

Pre-loaded modules dict from KBReader.get_modules().

required

custom_module

str

Name of the module being analysed (used for KB lookups).

required

module_local_refs

Optional[Dict[Tuple[str, str], List[str]]]

Optional {(model, method): [kwarg, ...]} index of cross-file field→method links within the same module.

None

Returns:

Type Description
List[ClassInfo]

Ordered list of ClassInfo objects, one per Odoo model class found.

List[ClassInfo]

Empty when the file contains no Odoo model classes or fails to parse.

Source code in src/oops/io/refactor.py
def analyse_file(
    py_file: Path,
    kb: KBReader,
    modules_index: Dict[str, Any],
    custom_module: str,
    module_local_refs: Optional[Dict[Tuple[str, str], List[str]]] = None,
) -> List[ClassInfo]:
    """Classify every Odoo model class and its symbols in a Python source file.

    Reads the file, parses it with ``ast``, and for each model class found
    resolves its fields and methods against the KB. Syntax errors are logged
    and produce an empty result rather than raising.

    Args:
        py_file: Path to the Python source file to analyse.
        kb: Open KB reader used for symbol and model lookups.
        modules_index: Pre-loaded modules dict from ``KBReader.get_modules()``.
        custom_module: Name of the module being analysed (used for KB lookups).
        module_local_refs: Optional ``{(model, method): [kwarg, ...]}`` index
            of cross-file field→method links within the same module.

    Returns:
        Ordered list of ``ClassInfo`` objects, one per Odoo model class found.
        Empty when the file contains no Odoo model classes or fails to parse.
    """
    source = py_file.read_text(encoding="utf-8", errors="replace")
    try:
        tree = ast.parse(source, filename=str(py_file))
    except SyntaxError as exc:
        logging.getLogger(__name__).warning("Syntax error in %s: %s", py_file, exc)
        return []

    results: List[ClassInfo] = []

    for node in ast.walk(tree):
        if not isinstance(node, ast.ClassDef):
            continue
        if not is_odoo_model_class(node):
            continue

        _name, _inherit = get_model_names(node)
        target_models = [_name] if _name else _inherit
        if not target_models:
            continue

        model_name = target_models[0]
        # _name absent → pure _inherit class; always an extender regardless of KB.
        # _name in _inherit → Odoo "reopen same model" extension pattern.
        # Only query the KB when _name is set and not self-referential.
        is_new_model = (
            _name is not None
            and _name not in _inherit
            and kb.is_model_creator(model_name, custom_module)
        )
        if is_new_model:
            other_creators = [
                c for c in kb.get_model_creators(model_name)
                if c["module"] != custom_module
            ]
            if other_creators:
                logging.getLogger(__name__).warning(
                    "Model '%s' claimed by multiple creators: %s (also in %s). "
                    "These modules may be mutually exclusive.",
                    model_name,
                    custom_module,
                    [c["module"] for c in other_creators],
                )
        has_class_doc = _has_class_docstring(node)

        ci = ClassInfo(
            class_name=node.name,
            model_name=_name,
            inherit=_inherit,
            is_new_model=is_new_model,
            lineno=node.lineno,
        )
        ci._needs_class_docstring = is_new_model and not has_class_doc  # type: ignore[attr-defined]

        # Pass 1: collect field→method refs within this class.
        local_refs: Dict[str, List[str]] = {}
        for stmt in node.body:
            if isinstance(stmt, ast.Assign):
                for kwarg, target in extract_field_refs(stmt).items():
                    local_refs.setdefault(target, []).append(kwarg)

        # Pass 2: emit symbols, classifying methods with resolved refs.
        for stmt in node.body:
            fld = is_field_assignment(stmt)
            if fld:
                fname, lineno, ftype = fld
                kb_entries = kb.get_symbol(model_name, fname, "field")
                kb_entry = resolve_symbol(kb_entries, custom_module, modules_index)
                section = "BASE FIELDS" if is_new_model else ("INHERITED FIELDS" if kb_entry else "NEW FIELDS")
                ci.symbols.append(
                    SymbolInfo(
                        name=fname,
                        kind="field",
                        section=section,
                        lineno=lineno,
                        kb_entry=kb_entry,
                        field_type=ftype,
                    )
                )
                continue

            if not isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
                continue

            dec_names = _get_decorator_names(stmt)
            # Resolve field→method refs: same-class first, then module-level, then KB.
            if stmt.name in local_refs:
                ref_kwargs = local_refs[stmt.name]
            elif module_local_refs is not None:
                ref_kwargs = module_local_refs.get((model_name, stmt.name), [])
            else:
                ref_kwargs = [k["kwarg"] for k in kb.get_field_refs_for_method(model_name, stmt.name)]
            section = classify_method(stmt.name, dec_names, ref_kwargs)
            has_doc = _has_docstring(stmt)
            has_super, super_methods = _detect_super(source, stmt.name)

            kb_entries = kb.get_symbol(model_name, stmt.name, "method")
            kb_entry = resolve_symbol(kb_entries, custom_module, modules_index)

            ci.symbols.append(
                SymbolInfo(
                    name=stmt.name,
                    kind="method",
                    section=section,
                    lineno=stmt.lineno,
                    has_docstring=has_doc,
                    has_super=has_super,
                    super_methods=super_methods,
                    kb_entry=kb_entry,
                    is_override=(not is_new_model) and bool(kb_entry) and not has_super,
                )
            )

        results.append(ci)

    return results

rewrite_file

rewrite_file(py_file: Path, classes: List[ClassInfo]) -> str

Rewrite a Python source file by injecting section headers and docstring skeletons.

Uses libcst for AST-preserving rewriting so comments and formatting are retained. Returns the original source unchanged when classes is empty or the file fails to parse.

Parameters:

Name Type Description Default

py_file

Path

Path to the Python source file to rewrite.

required

classes

List[ClassInfo]

Analysis result from analyse_file; drives which rewrites are applied.

required

Returns:

Type Description
str

Rewritten source code as a string, or the original source on failure.

Source code in src/oops/io/refactor.py
def rewrite_file(py_file: Path, classes: List[ClassInfo]) -> str:
    """Rewrite a Python source file by injecting section headers and docstring skeletons.

    Uses ``libcst`` for AST-preserving rewriting so comments and formatting are
    retained. Returns the original source unchanged when ``classes`` is empty or
    the file fails to parse.

    Args:
        py_file: Path to the Python source file to rewrite.
        classes: Analysis result from ``analyse_file``; drives which rewrites
            are applied.

    Returns:
        Rewritten source code as a string, or the original source on failure.
    """
    source = py_file.read_text(encoding="utf-8", errors="replace")
    if not classes:
        return source
    try:
        tree = cst.parse_module(source)
    except cst.ParserSyntaxError as exc:
        logging.getLogger(__name__).error("Cannot parse %s: %s", py_file, exc)
        return source
    new_tree = tree.visit(_ModelRewriter(classes))
    return new_tree.code