Skip to content

Scanner

scanner

AST-based scanner for Odoo source trees.

Sections
  • Constants: ODOO_BASE_CLASSES, FIELD_TYPES, METHOD_SECTION_*, tier marker helpers
  • AST helpers: parse, extract, classify Odoo model nodes
  • Manifest parsing: delegated to oops.io.manifest
  • Scanning: scan_module, scan_tier, odoo_addons_roots
  • Root addon discovery: discover_root_addons, tier_root_from_real_path

Functions:

Name Description
build_module_field_refs

Build a {(model, method_name): [kwarg, ...]} index from a list of model files.

classify_method

Decide a method's section.

discover_root_addons

Walk repo_path for root-level Odoo addons and group them by tier.

extract_field_refs

Return {kwarg: target_method_name} for string-literal kwargs in FIELD_REF_KWARGS.

get_inherits

Extract _inherits dict {parent_model: fk_field} from a class body.

get_model_names

Extract _name and _inherit values from a class body.

get_model_type

Return the Odoo model kind for a class node.

is_field_assignment

If stmt assigns a fields.XXX, return (field_name, lineno, field_type). Else None.

is_odoo_model_class

Return True if the class directly subclasses an Odoo model base.

odoo_addons_roots

Return the standard addons roots inside an Odoo community tree.

scan_module

Scan a single Odoo module directory.

scan_tier

Scan all addon modules under tier_root.

tier_root_from_real_path

Derive the tier root directory from a module's real path.

build_module_field_refs

build_module_field_refs(py_files: List[Path]) -> Dict[Tuple[str, str], List[str]]

Build a {(model, method_name): [kwarg, ...]} index from a list of model files.

Used by the refactor CLI to pre-compute cross-file field→method links within a single module before running per-file analysis.

Parameters:

Name Type Description Default

py_files

List[Path]

Python source files from a single Odoo module to index.

required

Returns:

Type Description
Dict[Tuple[str, str], List[str]]

Mapping of (model_name, method_name) to the list of field kwargs

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

that reference the method (e.g. ["compute", "inverse"]).

Source code in src/oops/kb/scanner.py
def build_module_field_refs(
    py_files: List[Path],
) -> Dict[Tuple[str, str], List[str]]:
    """Build a {(model, method_name): [kwarg, ...]} index from a list of model files.

    Used by the refactor CLI to pre-compute cross-file field→method links within
    a single module before running per-file analysis.

    Args:
        py_files: Python source files from a single Odoo module to index.

    Returns:
        Mapping of ``(model_name, method_name)`` to the list of field kwargs
        that reference the method (e.g. ``["compute", "inverse"]``).
    """
    refs: Dict[Tuple[str, str], List[str]] = {}
    for py_file in py_files:
        try:
            tree = ast.parse(py_file.read_text(encoding="utf-8", errors="replace"))
        except SyntaxError:
            continue
        for node in ast.walk(tree):
            if not isinstance(node, ast.ClassDef) or not is_odoo_model_class(node):
                continue
            _name, _inherit = get_model_names(node)
            for model_name in ([_name] if _name else _inherit):
                for stmt in node.body:
                    if not isinstance(stmt, ast.Assign):
                        continue
                    for kwarg, target in extract_field_refs(stmt).items():
                        key = (model_name, target)
                        refs.setdefault(key, []).append(kwarg)
    return refs

classify_method

classify_method(name: str, decorator_names: List[str], referencing_kwargs: Iterable[str] = ()) -> str

Decide a method's section.

Parameters:

Name Type Description Default

name

str

Method name.

required

decorator_names

List[str]

Flat list of decorator name strings (see _get_decorator_names).

required

referencing_kwargs

Iterable[str]

Field kwargs that reference this method on the same model (across all classes/files/modules available at classification time), e.g. {"compute", "inverse"}.

()

Returns:

Type Description
str

One of the METHOD_SECTION_* constants (first matching rule wins).

Priority (first match wins): 1. CRUD name. 2. Standard default-provider name (default_get) → DEFAULT METHODS. 3. @api.depends → COMPUTE METHODS. 4. @api.onchange → ONCHANGE METHODS. 5. @api.constrains → CONSTRAINT METHODS. 6. Referenced by a field via compute=/inverse=/search= → COMPUTE METHODS. 7. Referenced by a field via default= → DEFAULT METHODS. 8. Referenced by a field via selection= → SELECTION METHODS. 9. action_ or button_ prefix → ACTION METHODS. 10. _ prefix → HELPER METHODS. 11. Default → BUSINESS METHODS.

@api.model is intentionally NOT a classification signal. It only marks that the method receives the model class rather than a recordset as self; this orthogonal property appears across all sections. Treating it as a signal would misclassify or produce a new meaningless section.

SELECTION METHODS is ONLY reachable via the referencing_kwargs path ("selection" in the set). There is no Odoo decorator for selection methods and no established naming convention — selection= on a field declaration is the sole detection signal.

See docs/reference/method-classification.md for the full rationale behind each rule and guidance on extending the system.

Source code in src/oops/kb/scanner.py
def classify_method(
    name: str,
    decorator_names: List[str],
    referencing_kwargs: Iterable[str] = (),
) -> str:
    """Decide a method's section.

    Args:
        name: Method name.
        decorator_names: Flat list of decorator name strings (see
            ``_get_decorator_names``).
        referencing_kwargs: Field kwargs that reference this method on the same
            model (across all classes/files/modules available at classification
            time), e.g. ``{"compute", "inverse"}``.

    Returns:
        One of the ``METHOD_SECTION_*`` constants (first matching rule wins).

    Priority (first match wins):
      1. CRUD name.
      2. Standard default-provider name (default_get) → DEFAULT METHODS.
      3. @api.depends → COMPUTE METHODS.
      4. @api.onchange → ONCHANGE METHODS.
      5. @api.constrains → CONSTRAINT METHODS.
      6. Referenced by a field via compute=/inverse=/search= → COMPUTE METHODS.
      7. Referenced by a field via default= → DEFAULT METHODS.
      8. Referenced by a field via selection= → SELECTION METHODS.
      9. action_ or button_ prefix → ACTION METHODS.
     10. _ prefix → HELPER METHODS.
     11. Default → BUSINESS METHODS.

    `@api.model` is intentionally NOT a classification signal. It only marks that
    the method receives the model class rather than a recordset as `self`; this
    orthogonal property appears across all sections. Treating it as a signal would
    misclassify or produce a new meaningless section.

    `SELECTION METHODS` is ONLY reachable via the `referencing_kwargs` path
    (`"selection"` in the set). There is no Odoo decorator for selection methods
    and no established naming convention — `selection=` on a field declaration is
    the sole detection signal.

    See docs/reference/method-classification.md for the full rationale behind
    each rule and guidance on extending the system.
    """
    if name in CRUD_NAMES:
        return METHOD_SECTION_CRUD
    if name in DEFAULT_NAMES:
        return METHOD_SECTION_DEFAULT
    if any(d in ("api.depends", "depends") for d in decorator_names):
        return METHOD_SECTION_COMPUTE
    if any(d in ("api.onchange", "onchange") for d in decorator_names):
        return METHOD_SECTION_ONCHANGE
    if any(d in ("api.constrains", "constrains") for d in decorator_names):
        return METHOD_SECTION_CONSTRAINT
    refs = set(referencing_kwargs)
    if refs & {"compute", "inverse", "search"}:
        return METHOD_SECTION_COMPUTE
    if "default" in refs:
        return METHOD_SECTION_DEFAULT
    if "selection" in refs:
        return METHOD_SECTION_SELECTION
    if name.startswith(("action_", "button_")):
        return METHOD_SECTION_ACTION
    if name.startswith("_"):
        return METHOD_SECTION_HELPER
    return METHOD_SECTION_BUSINESS

discover_root_addons

discover_root_addons(repo_path: Path, allowed_modules: Optional[Set[str]] = None) -> Dict[str, List[Tuple[str, Path]]]

Walk repo_path for root-level Odoo addons and group them by tier.

Three tiers are recognised
  • 'third-party': symlink whose real path contains '/.third-party/'.
  • 'apik': symlink whose real path contains '/apik-addons/'.
  • 'local': real directory at the repo root with a manifest.

Symlinks that resolve outside the known submodule tiers are logged and skipped.

Returns:

Type Description
Dict[str, List[Tuple[str, Path]]]

{ origin: [(module_name, real_module_path), ...] }

Source code in src/oops/kb/scanner.py
def discover_root_addons(
    repo_path: Path,
    allowed_modules: Optional[Set[str]] = None,
) -> Dict[str, List[Tuple[str, Path]]]:
    """Walk repo_path for root-level Odoo addons and group them by tier.

    Three tiers are recognised:
        - 'third-party': symlink whose real path contains '/.third-party/'.
        - 'apik':        symlink whose real path contains '/apik-addons/'.
        - 'local':       real directory at the repo root with a manifest.

    Symlinks that resolve outside the known submodule tiers are logged and
    skipped.

    Returns:
        { origin: [(module_name, real_module_path), ...] }
    """
    markers = _tier_markers()
    tiers: Dict[str, List[Tuple[str, Path]]] = {origin: [] for origin in markers}
    tiers["local"] = []

    # Search at depth 1 under repo_path and its immediate non-hidden children.
    candidates: List[Path] = [repo_path]
    for child in repo_path.iterdir():
        if child.is_dir() and not child.name.startswith("."):
            candidates.append(child)

    seen_real: Set[Path] = set()

    for search_dir in candidates:
        if not search_dir.is_dir():
            continue
        try:
            entries = list(search_dir.iterdir())
        except PermissionError:
            continue
        for entry in entries:
            if not entry.is_dir():
                continue
            module_name = entry.name
            if allowed_modules and module_name not in allowed_modules:
                continue
            real = entry.resolve()
            if real in seen_real:
                continue

            if entry.is_symlink():
                seen_real.add(real)
                real_str = str(real)
                matched = False
                for origin, marker in markers.items():
                    if marker in real_str or marker.replace("/", "\\") in real_str:
                        tiers[origin].append((module_name, real))
                        matched = True
                        break
                if not matched:
                    logging.warning(
                        "Symlink %s%s does not match any known tier root, skipping.",
                        entry,
                        real,
                    )
                continue

            # Non-symlink real directory: only counts as 'local' if it has a
            # manifest AND it sits directly under the repo root. We deliberately
            # do not descend into nested real directories.
            if search_dir != repo_path:
                continue
            if not (entry / "__manifest__.py").exists() and not (entry / "__openerp__.py").exists():
                continue
            seen_real.add(real)
            tiers["local"].append((module_name, real))

    return tiers

extract_field_refs

extract_field_refs(stmt: Assign) -> Dict[str, str]

Return {kwarg: target_method_name} for string-literal kwargs in FIELD_REF_KWARGS.

Parameters:

Name Type Description Default

stmt

Assign

An AST Assign node for a field declaration.

required

Returns:

Type Description
Dict[str, str]

Mapping of kwarg name to the referenced method name string.

Bare callables and lambdas are skipped silently.

Source code in src/oops/kb/scanner.py
def extract_field_refs(stmt: ast.Assign) -> Dict[str, str]:
    """Return {kwarg: target_method_name} for string-literal kwargs in FIELD_REF_KWARGS.

    Args:
        stmt: An AST ``Assign`` node for a field declaration.

    Returns:
        Mapping of kwarg name to the referenced method name string.

    Bare callables and lambdas are skipped silently.
    """
    refs: Dict[str, str] = {}
    if not isinstance(stmt.value, ast.Call):
        return refs
    for kw in stmt.value.keywords:
        if kw.arg in FIELD_REF_KWARGS and isinstance(kw.value, ast.Constant):
            if isinstance(kw.value.value, str):
                refs[kw.arg] = kw.value.value
    return refs

get_inherits

get_inherits(class_node: ClassDef) -> Dict[str, str]

Extract _inherits dict {parent_model: fk_field} from a class body.

Returns:

Type Description
Dict[str, str]

Mapping of parent model name to the local FK field name, empty if absent.

Source code in src/oops/kb/scanner.py
def get_inherits(class_node: ast.ClassDef) -> Dict[str, str]:
    """Extract _inherits dict {parent_model: fk_field} from a class body.

    Returns:
        Mapping of parent model name to the local FK field name, empty if absent.
    """
    for stmt in class_node.body:
        if not isinstance(stmt, ast.Assign):
            continue
        for target in stmt.targets:
            if not isinstance(target, ast.Name) or target.id != "_inherits":
                continue
            val = stmt.value
            if not isinstance(val, ast.Dict):
                continue
            result: Dict[str, str] = {}
            for k, v in zip(val.keys, val.values):
                if (
                    isinstance(k, ast.Constant) and isinstance(k.value, str)
                    and isinstance(v, ast.Constant) and isinstance(v.value, str)
                ):
                    result[k.value] = v.value
            return result
    return {}

get_model_names

get_model_names(class_node: ClassDef) -> Tuple[Optional[str], List[str]]

Extract _name and _inherit values from a class body.

Returns:

Type Description
Tuple[Optional[str], List[str]]

(_name value or None, list of _inherit values — empty if absent)

Source code in src/oops/kb/scanner.py
def get_model_names(class_node: ast.ClassDef) -> Tuple[Optional[str], List[str]]:
    """Extract _name and _inherit values from a class body.

    Returns:
        (_name value or None, list of _inherit values — empty if absent)
    """
    _name: Optional[str] = None
    _inherit: List[str] = []

    for stmt in class_node.body:
        if not isinstance(stmt, ast.Assign):
            continue
        for target in stmt.targets:
            if not isinstance(target, ast.Name):
                continue
            if target.id == "_name":
                _name = _extract_string_value(stmt.value)
            elif target.id == "_inherit":
                val = stmt.value
                if isinstance(val, ast.Constant) and isinstance(val.value, str):
                    _inherit = [val.value]
                elif isinstance(val, ast.List):
                    _inherit = [
                        elt.value for elt in val.elts if isinstance(elt, ast.Constant) and isinstance(elt.value, str)
                    ]
    return _name, _inherit

get_model_type

get_model_type(node: ClassDef) -> str

Return the Odoo model kind for a class node.

Returns:

Type Description
str

'abstract', 'transient', or 'model'.

Source code in src/oops/kb/scanner.py
def get_model_type(node: ast.ClassDef) -> str:
    """Return the Odoo model kind for a class node.

    Returns:
        ``'abstract'``, ``'transient'``, or ``'model'``.
    """
    for base in node.bases:
        name = None
        if isinstance(base, ast.Attribute):
            name = base.attr
        elif isinstance(base, ast.Name):
            name = base.id
        if name == "AbstractModel":
            return "abstract"
        if name == "TransientModel":
            return "transient"
    return "model"

is_field_assignment

is_field_assignment(stmt: stmt) -> Optional[Tuple[str, int, str]]

If stmt assigns a fields.XXX, return (field_name, lineno, field_type). Else None.

Parameters:

Name Type Description Default

stmt

stmt

An AST statement node to inspect.

required

Returns:

Type Description
Optional[Tuple[str, int, str]]

(field_name, lineno, field_type) if the statement is a single-target

Optional[Tuple[str, int, str]]

fields.XXX(...) assignment, None otherwise.

Source code in src/oops/kb/scanner.py
def is_field_assignment(stmt: ast.stmt) -> Optional[Tuple[str, int, str]]:
    """If stmt assigns a fields.XXX, return (field_name, lineno, field_type). Else None.

    Args:
        stmt: An AST statement node to inspect.

    Returns:
        ``(field_name, lineno, field_type)`` if the statement is a single-target
        ``fields.XXX(...)`` assignment, ``None`` otherwise.
    """
    if not isinstance(stmt, ast.Assign):
        return None
    if len(stmt.targets) != 1 or not isinstance(stmt.targets[0], ast.Name):
        return None
    value = stmt.value
    if isinstance(value, ast.Call):
        func = value.func
        if isinstance(func, ast.Attribute) and func.attr in FIELD_TYPES:
            return stmt.targets[0].id, stmt.lineno, func.attr
        if isinstance(func, ast.Name) and func.id in FIELD_TYPES:
            return stmt.targets[0].id, stmt.lineno, func.id
    return None

is_odoo_model_class

is_odoo_model_class(node: ClassDef) -> bool

Return True if the class directly subclasses an Odoo model base.

Returns:

Type Description
bool

True if any base class name is in ODOO_BASE_CLASSES, False otherwise.

Source code in src/oops/kb/scanner.py
def is_odoo_model_class(node: ast.ClassDef) -> bool:
    """Return True if the class directly subclasses an Odoo model base.

    Returns:
        True if any base class name is in ``ODOO_BASE_CLASSES``, False otherwise.
    """
    for base in node.bases:
        name = None
        if isinstance(base, ast.Attribute):
            name = base.attr
        elif isinstance(base, ast.Name):
            name = base.id
        if name in ODOO_BASE_CLASSES:
            return True
    return False

odoo_addons_roots

odoo_addons_roots(odoo_path: Path) -> List[Path]

Return the standard addons roots inside an Odoo community tree.

Odoo community keeps modules in two places:

  • <root>/addons/ standard modules (sale, account…)
  • <root>/odoo/addons/ core modules (base, web, mail…)

Parameters:

Name Type Description Default

odoo_path

Path

Path to the root of an Odoo community checkout.

required

Returns:

Type Description
List[Path]

List of existing addons root paths. Falls back to [odoo_path]

List[Path]

if neither standard subdirectory exists.

Source code in src/oops/kb/scanner.py
def odoo_addons_roots(odoo_path: Path) -> List[Path]:
    """Return the standard addons roots inside an Odoo community tree.

    Odoo community keeps modules in two places:

    - ``<root>/addons/``        standard modules (sale, account…)
    - ``<root>/odoo/addons/``   core modules (base, web, mail…)

    Args:
        odoo_path: Path to the root of an Odoo community checkout.

    Returns:
        List of existing addons root paths. Falls back to ``[odoo_path]``
        if neither standard subdirectory exists.
    """
    candidates = [odoo_path / "addons", odoo_path / "odoo" / "addons"]
    roots = [p for p in candidates if p.is_dir()]
    if not roots:
        logging.warning("No addons/ or odoo/addons/ found under %s — using path directly.", odoo_path)
        roots = [odoo_path]
    return roots

scan_module

scan_module(module_dir: Path, origin: str, tier_root: Path) -> Dict[str, Any]

Scan a single Odoo module directory.

Parameters:

Name Type Description Default

module_dir

Path

absolute path to the module (must contain manifest.py).

required

origin

str

tier label ('odoo', 'enterprise', 'third-party', 'apik').

required

tier_root

Path

root used to compute relative source_file paths.

required

Returns:

Type Description
Dict[str, Any]

A ScanResult dict with keys 'modules', 'symbols', and 'field_refs'.

Source code in src/oops/kb/scanner.py
def scan_module(  # noqa: C901
    module_dir: Path,
    origin: str,
    tier_root: Path,
) -> Dict[str, Any]:
    """Scan a single Odoo module directory.

    Args:
        module_dir: absolute path to the module (must contain __manifest__.py).
        origin:     tier label ('odoo', 'enterprise', 'third-party', 'apik').
        tier_root:  root used to compute relative source_file paths.

    Returns:
        A ScanResult dict with keys 'modules', 'symbols', and 'field_refs'.
    """
    module_name = module_dir.name
    result: Dict[str, Any] = {"modules": {}, "symbols": [], "field_refs": [], "model_origins": []}

    # --- manifest ---
    depends = load_manifest(module_dir).get("depends", [])
    result["modules"][module_name] = {"origin": origin, "depends": depends}

    # --- models ---
    models_dir = module_dir / "models"
    if not models_dir.is_dir():
        return result

    # Parse all model files up front so we can do two passes.
    parsed_files: List[Tuple[Path, str, ast.Module]] = []
    for py_file in models_dir.rglob("*.py"):
        tree = _parse_file(py_file)
        if tree is None:
            continue
        try:
            rel_path = str(py_file.relative_to(tier_root))
        except ValueError:
            rel_path = str(py_file)
        parsed_files.append((py_file, rel_path, tree))

    # ---- Pass 1: collect field symbols and field_refs across the whole module. ----
    # Keyed by (model, target_method) → list of kwargs
    refs_by_target: Dict[Tuple[str, str], List[str]] = {}
    field_symbols: List[Dict[str, Any]] = []
    pending_methods: List[Tuple[str, str, ast.FunctionDef, str, int]] = []

    for _, rel_path, tree in parsed_files:
        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)
            _inherits_dict = get_inherits(node)
            model_type = get_model_type(node)

            if _name is not None:
                role = "extend" if _name in _inherit else "create"
                result["model_origins"].append({
                    "model": _name,
                    "module": module_name,
                    "origin": origin,
                    "role": role,
                    "model_type": model_type,
                    "inherit_json": json.dumps(_inherit),
                    "inherits_json": json.dumps(_inherits_dict),
                    "source_file": rel_path,
                    "source_line": node.lineno,
                })
            else:
                for inh in _inherit:
                    result["model_origins"].append({
                        "model": inh,
                        "module": module_name,
                        "origin": origin,
                        "role": "extend",
                        "model_type": model_type,
                        "inherit_json": json.dumps([]),
                        "inherits_json": json.dumps({}),
                        "source_file": rel_path,
                        "source_line": node.lineno,
                    })

            target_models: List[str] = [_name] if _name else _inherit

            for model_name in target_models:
                for stmt in node.body:
                    fld = is_field_assignment(stmt)
                    if fld:
                        fname, lineno, ftype = fld
                        field_symbols.append({
                            "model": model_name, "name": fname, "kind": "field",
                            "origin": origin, "module": module_name,
                            "source_file": rel_path, "source_line": lineno,
                            "field_type": ftype, "section": None,
                        })
                        for kwarg, target in extract_field_refs(stmt).items():
                            refs_by_target.setdefault((model_name, target), []).append(kwarg)
                            result["field_refs"].append({
                                "model": model_name, "field_name": fname,
                                "module": module_name, "kwarg": kwarg,
                                "target_method": target,
                            })
                        continue
                    if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
                        pending_methods.append(
                            (model_name, rel_path, stmt, stmt.name, stmt.lineno)
                        )

    # ---- Pass 2: classify methods using the collected field refs. ----
    method_symbols: List[Dict[str, Any]] = []
    for model_name, rel_path, fn_node, mname, lineno in pending_methods:
        ref_kwargs = refs_by_target.get((model_name, mname), [])
        decs = _get_decorator_names(fn_node)
        section = classify_method(mname, decs, ref_kwargs)
        method_symbols.append({
            "model": model_name, "name": mname, "kind": "method",
            "origin": origin, "module": module_name,
            "source_file": rel_path, "source_line": lineno,
            "field_type": None, "section": section,
        })

    result["symbols"] = field_symbols + method_symbols
    return result

scan_tier

scan_tier(tier_root: Path, origin: str, allowed_modules: Optional[Set[str]] = None) -> Dict[str, Any]

Scan all addon modules under tier_root.

Parameters:

Name Type Description Default

tier_root

Path

directory whose immediate children are Odoo modules.

required

origin

str

tier label.

required

allowed_modules

Optional[Set[str]]

if set, only modules whose name is in this set are scanned.

None

Returns:

Type Description
Dict[str, Any]

Merged ScanResult for all scanned modules.

Source code in src/oops/kb/scanner.py
def scan_tier(
    tier_root: Path,
    origin: str,
    allowed_modules: Optional[Set[str]] = None,
) -> Dict[str, Any]:
    """Scan all addon modules under tier_root.

    Args:
        tier_root:       directory whose immediate children are Odoo modules.
        origin:          tier label.
        allowed_modules: if set, only modules whose name is in this set are scanned.

    Returns:
        Merged ScanResult for all scanned modules.
    """
    merged: Dict[str, Any] = {"modules": {}, "symbols": [], "field_refs": [], "model_origins": []}

    if not tier_root.is_dir():
        logging.warning("Tier root not found, skipping: %s", tier_root)
        return merged

    count = 0
    for entry in sorted(tier_root.iterdir()):
        if not entry.is_dir():
            continue
        if allowed_modules and entry.name not in allowed_modules:
            continue
        if not load_manifest(entry):
            continue

        result = scan_module(entry, origin, tier_root)
        merged["modules"].update(result["modules"])
        merged["symbols"].extend(result["symbols"])
        merged["field_refs"].extend(result.get("field_refs", []))
        merged["model_origins"].extend(result.get("model_origins", []))
        count += 1

    logging.info("  [%s] %s%d modules", origin, tier_root, count)
    return merged

tier_root_from_real_path

tier_root_from_real_path(origin: str, real_path: Path) -> Optional[Path]

Derive the tier root directory from a module's real path.

Parameters:

Name Type Description Default

origin

str

Tier name (e.g. 'third-party' or 'apik').

required

real_path

Path

Resolved (non-symlink) path of the module directory.

required

Returns:

Type Description
Optional[Path]

The tier root path, or None if the marker is not found in the path.

Optional[Path]

e.g. /repo/.third-party/sale-workflow/sale_order_type/repo/.third-party

Source code in src/oops/kb/scanner.py
def tier_root_from_real_path(origin: str, real_path: Path) -> Optional[Path]:
    """Derive the tier root directory from a module's real path.

    Args:
        origin: Tier name (e.g. ``'third-party'`` or ``'apik'``).
        real_path: Resolved (non-symlink) path of the module directory.

    Returns:
        The tier root path, or ``None`` if the marker is not found in the path.

        e.g. ``/repo/.third-party/sale-workflow/sale_order_type``
             → ``/repo/.third-party``
    """
    marker = _tier_markers().get(origin)
    if not marker:
        return None
    real_str = str(real_path)
    idx = real_str.find(marker)
    if idx == -1:
        return None
    return Path(real_str[: idx + len(marker) - 1])