Skip to content

pyvelm.views

views

View arch normalization, inheritance, and resolution.

The arch stored in ir.ui.view.arch is JSON, but its authoring form is deliberately terse ("fields": ["name", "age"]). On the way in, the normalizer promotes those shortcuts to addressable shapes ({"name": "name"}); on the way out, the resolver walks the parent chain and applies operations from every extension view in ascending priority order.

Operation shape

{"op": "", "target": [, ...], "value": }

Target segments (Stage 7 Slice C — pyvelm/docs/web-layer.md):

- `str`  + dict node      → dict key lookup
- `str`  + list of dicts  → match the dict whose `name` equals it
                            (shorthand for `{"name": "<str>"}`)
- `int`  + list           → positional index
- `dict` + list of dicts  → predicate; first list entry where every
                            key/value in the dict matches. Matches
                            Odoo's `xpath="//tag[@a='1'][@b='2']"`
                            attribute-filter idiom.
- `"**"` as the **first** segment  → start the lookup anywhere in
                            the arch (depth-first). The next
                            segment selects the entry point;
                            remaining segments resolve normally
                            from there. Equivalent to Odoo's
                            `xpath="//.../foo"` prefix.

callable_ref

callable_ref(fn: Callable[..., Any]) -> str

Serialize a view-schema callable as module:qualname.

Source code in pyvelm/views.py
def callable_ref(fn: Callable[..., Any]) -> str:
    """Serialize a view-schema callable as ``module:qualname``."""
    mod = getattr(fn, "__module__", None)
    qual = getattr(fn, "__qualname__", None)
    if not mod or not qual or qual == "<lambda>":
        raise TypeError(
            "View schema callables must be module-level named functions; "
            f"cannot serialize {fn!r}"
        )
    return f"{mod}:{qual}"

resolve_callable_ref

resolve_callable_ref(ref: str) -> Callable[..., Any]

Import a callable previously stored via :func:callable_ref.

Source code in pyvelm/views.py
def resolve_callable_ref(ref: str) -> Callable[..., Any]:
    """Import a callable previously stored via :func:`callable_ref`."""
    if ":" not in ref:
        raise ValueError(f"Invalid callable ref {ref!r}")
    mod_name, attr = ref.rsplit(":", 1)
    mod = importlib.import_module(mod_name)
    obj: Any = mod
    for part in attr.split("."):
        obj = getattr(obj, part)
    if not callable(obj):
        raise TypeError(f"Callable ref {ref!r} did not resolve to a function")
    return obj

encode_arch_callables

encode_arch_callables(node: Any) -> Any

Replace callables with JSON-safe refs before persisting view arch.

Source code in pyvelm/views.py
def encode_arch_callables(node: Any) -> Any:
    """Replace callables with JSON-safe refs before persisting view arch."""
    if callable(node):
        return {_CALLABLE_MARKER: callable_ref(node)}
    if isinstance(node, dict):
        return {k: encode_arch_callables(v) for k, v in node.items()}
    if isinstance(node, list):
        return [encode_arch_callables(item) for item in node]
    return node

decode_arch_callables

decode_arch_callables(node: Any) -> Any

Restore callables from refs produced by :func:encode_arch_callables.

Source code in pyvelm/views.py
def decode_arch_callables(node: Any) -> Any:
    """Restore callables from refs produced by :func:`encode_arch_callables`."""
    if isinstance(node, dict):
        if list(node.keys()) == [_CALLABLE_MARKER]:
            return resolve_callable_ref(node[_CALLABLE_MARKER])
        return {k: decode_arch_callables(v) for k, v in node.items()}
    if isinstance(node, list):
        return [decode_arch_callables(item) for item in node]
    return node

normalize_arch

normalize_arch(arch: dict, view_type: str) -> dict

Promote authoring-sugar strings to dicts in known list positions.

Returns a new dict; never mutates the input. Idempotent — passing an already-normalized arch is a no-op.

Source code in pyvelm/views.py
def normalize_arch(arch: dict, view_type: str) -> dict:
    """Promote authoring-sugar strings to dicts in known list positions.

    Returns a new dict; never mutates the input. Idempotent — passing an
    already-normalized arch is a no-op.
    """
    if arch is None:
        return arch
    result = copy.deepcopy(arch)
    for path in _LIST_PROMOTION_PATHS.get(view_type, []):
        _promote_list_at(result, list(path))
    return result

apply_operations

apply_operations(arch: dict, operations: list[dict]) -> dict

Apply each op to arch in order, in place. Returns the same dict.

Caller should pass a deepcopy if they need the original preserved.

Source code in pyvelm/views.py
def apply_operations(arch: dict, operations: list[dict]) -> dict:
    """Apply each op to `arch` in order, in place. Returns the same dict.

    Caller should pass a deepcopy if they need the original preserved.
    """
    for op in operations:
        kind = op["op"]
        target = list(op["target"])

        # `**` as the first segment finds any descendant whose next
        # step matches. Effectively: "ignore the path, just find this
        # node anywhere." Resolves to a normal target rooted at the
        # match's parent so the rest of the segment list keeps the
        # existing semantics.
        if target and target and target[0] == "**":
            if len(target) < 2:
                raise ValueError(
                    "'**' must be followed by at least one selector "
                    "(`name` string or predicate dict)"
                )
            entry_seg = target[1]
            try:
                root, _ = _find_descendant(arch, entry_seg)
            except KeyError:
                raise KeyError(
                    f"`**` lookup found no descendant matching {entry_seg!r}"
                )
            # Splice: drop `**`, anchor at the discovered subtree.
            # The remaining target list (entry_seg + rest) resolves
            # normally against `root`.
            target = target[1:]
            arch_root = root
        else:
            arch_root = arch

        # `update` walks all the way INTO the target (must end on a dict)
        # and merges in op["value"]. Equivalent to Odoo's
        # `position="attributes"` with multiple <attribute> children.
        if kind == "update":
            node = arch_root
            for seg in target:
                node = _step_into(node, seg)
            if not isinstance(node, dict):
                raise ValueError(
                    f"'update' target must resolve to a dict, got "
                    f"{type(node).__name__} at {target!r}"
                )
            value = op["value"]
            if not isinstance(value, dict):
                raise ValueError(
                    f"'update' value must be a dict, got {type(value).__name__}"
                )
            node.update(value)
            continue

        if not target:
            raise ValueError(f"Operation {op!r} has empty target")
        # Walk into the parent container.
        parent = arch_root
        for seg in target[:-1]:
            parent = _step_into(parent, seg)
        last = target[-1]

        if kind == "remove":
            position = _resolve_position(parent, last)
            del parent[position]
        elif kind in ("set", "replace"):
            # Granular attribute set: the final segment may be a new key
            # on a dict parent (think: add `readonly` to a field). Existing
            # keys are overwritten. Lists still require the entry to exist
            # — use `before`/`after` to grow a list.
            if isinstance(parent, dict) and isinstance(last, str):
                parent[last] = op["value"]
            else:
                position = _resolve_position(parent, last)
                parent[position] = op["value"]
        elif kind == "before":
            if not isinstance(parent, list):
                raise ValueError(
                    f"'before' requires a list parent, got "
                    f"{type(parent).__name__} at target {target!r}"
                )
            position = _resolve_position(parent, last)
            parent.insert(position, op["value"])
        elif kind == "after":
            if not isinstance(parent, list):
                raise ValueError(
                    f"'after' requires a list parent, got "
                    f"{type(parent).__name__} at target {target!r}"
                )
            position = _resolve_position(parent, last)
            parent.insert(position + 1, op["value"])
        else:
            raise ValueError(f"Unknown view-arch op: {kind!r}")
    return arch

resolve_arch

resolve_arch(view) -> dict

Return the fully-resolved arch for view.

If view is an extension, walks up to the root base view first. Then applies every extension in the family in (priority, id) order. Depth-first: extensions-of-extensions are applied after their parent extension's ops, in the same priority sweep.

ACL is bypassed for the whole resolution — view arch is system metadata (same policy as render._search_ui_views / _menu).

Source code in pyvelm/views.py
def resolve_arch(view) -> dict:
    """Return the fully-resolved arch for `view`.

    If `view` is an extension, walks up to the root base view first.
    Then applies every extension in the family in (priority, id) order.
    Depth-first: extensions-of-extensions are applied after their parent
    extension's ops, in the same priority sweep.

    ACL is bypassed for the whole resolution — view arch is system metadata
    (same policy as ``render._search_ui_views`` / ``_menu``).
    """
    env = view.env
    prev = env._acl_bypass
    env._acl_bypass = True
    try:
        root = view
        while root.inherit_id:
            root = root.inherit_id
        if not root.arch:
            raise ValueError(
                f"View {root.module}.{root.name} has no arch (extension views "
                f"need an inherit_id to a base view)"
            )
        arch = decode_arch_callables(copy.deepcopy(json.loads(root.arch)))
        _apply_chain(root, arch)
        # Normalize the resolved arch so that any plain-string entries
        # inserted by before/after/replace operations are promoted to dicts.
        view_type = root.view_type
        if view_type:
            arch = normalize_arch(arch, view_type)
        return arch
    finally:
        env._acl_bypass = prev