Skip to content

Manifest Rules

manifest

Fixit lint rules for Odoo __manifest__.py files.

Rules are auto-discovered by fixit when the module is referenced as a QualifiedRule (see commands/manifest/common.py). Every public class that inherits from LintRule in this module becomes an active rule.


HOW TO ADD A RULE

  1. Subclass LintRule and give it a descriptive name::

    class MyNewRule(LintRule): MESSAGE = "Short default message shown when no message= is given."

  2. Declare AUTOFIX = True only if your rule can always provide a replacement node. Fixit will warn if you set it but don't supply one.

  3. Add VALID / INVALID lists of code snippets — fixit runs them as inline unit tests (uv run python -m pytest picks them up automatically via the fixit pytest plugin).

  4. Declare class-level config attributes with safe defaults so the rule works even without an .oops.yaml file. Override them in __init__ from _load_manifest_cfg()::

    some_setting: str = "" # "" → check disabled when no config some_list: List[str] = [] # empty → permissive fallback

  5. Implement visit_<NodeType>(self, node) where <NodeType> matches a libcst node class (e.g. Dict, SimpleString, Call). The visitor is called for every matching node in the file, so use self._checked to process only the first top-level dict (the manifest dict itself)::

    def visit_Dict(self, node: cst.Dict) -> None: if self._checked: # ignore nested dicts (depends, data, …) return self._checked = True ...

  6. Call self.report(node, message="…") to flag a violation. For autofixes, pass replacement=node.with_changes(…)::

    fixed = node.with_changes(value=cst.SimpleString('"corrected"')) self.report(node, message="Wrong value.", replacement=fixed)

  7. That's it — no registration needed. The rule is picked up automatically because common.py uses QualifiedRule("oops.rules.manifest").


LIBCST QUICK REFERENCE

Manifest dicts are Python dict literals, so the relevant CST nodes are:

cst.Dict — the { … } literal itself cst.DictElement — one key: value pair inside it cst.SimpleString — a plain string like "Acme" or '16.0.1.0.0' cst.List — a list literal like ["alice", "bob"] cst.Element — one item inside a cst.List

Reading a string value safely

_string_value(node) → returns the Python str or None

Building a replacement node

node.with_changes(field=new_value) — returns a new immutable node


AUTOFIX CONFLICT NOTE

When two rules both provide a replacement on overlapping nodes (e.g. ManifestKeyOrder replaces the whole dict while OdooManifestAuthorMaintainers replaces a child string), fixit can only apply one per pass. The outermost node wins (ManifestKeyOrder). The inner fix is applied cleanly on the next oops-man-fix pass. This is expected and acceptable behaviour.

Classes:

Name Description
ManifestKeyOrder

Enforce the canonical key order in __manifest__.py dict literals.

ManifestNoExtraKeys

Reject keys not present in the configured allowed list.

ManifestRequiredKeys

Ensure all required keys are present in the manifest dict.

ManifestVersionBump

Verify that the manifest version is bumped when addon files are staged.

OdooManifestAuthorMaintainers

Validate manifest field values: author, maintainers, summary, version.

ManifestKeyOrder

ManifestKeyOrder(*args, **kwargs)

              flowchart TD
              oops.rules.manifest.ManifestKeyOrder[ManifestKeyOrder]

              

              click oops.rules.manifest.ManifestKeyOrder href "" "oops.rules.manifest.ManifestKeyOrder"
            

Enforce the canonical key order in __manifest__.py dict literals.

The replacement node preserves all comments and trailing commas; only the element sequence is reordered. Keys absent from key_order are sorted after all known keys (stable, alphabetical).

Because this rule replaces the whole dict node, its autofix takes priority over child-node fixes from other rules in the same fixit pass. Run oops-man-fix a second time to apply those remaining fixes.

Source code in oops/rules/manifest.py
def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    cfg = _load_manifest_cfg()
    if cfg is not None:
        self.key_order = cfg.key_order

ManifestNoExtraKeys

ManifestNoExtraKeys(*args, **kwargs)

              flowchart TD
              oops.rules.manifest.ManifestNoExtraKeys[ManifestNoExtraKeys]

              

              click oops.rules.manifest.ManifestNoExtraKeys href "" "oops.rules.manifest.ManifestNoExtraKeys"
            

Reject keys not present in the configured allowed list.

Unknown keys are likely typos or leftover debug entries. The allowed list is the same as key_order from .oops.yaml so both rules stay in sync.

Source code in oops/rules/manifest.py
def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    self._checked = False
    cfg = _load_manifest_cfg()
    if cfg is not None:
        self.allowed_keys = cfg.key_order

ManifestRequiredKeys

ManifestRequiredKeys(*args, **kwargs)

              flowchart TD
              oops.rules.manifest.ManifestRequiredKeys[ManifestRequiredKeys]

              

              click oops.rules.manifest.ManifestRequiredKeys href "" "oops.rules.manifest.ManifestRequiredKeys"
            

Ensure all required keys are present in the manifest dict.

The list of required keys is read from manifest.required_keys in .oops.yaml; the class-level default covers standalone usage.

Source code in oops/rules/manifest.py
def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    self._checked = False  # guard: only inspect the first dict in the file
    cfg = _load_manifest_cfg()
    if cfg is not None:
        self.required_keys = cfg.required_keys

ManifestVersionBump

ManifestVersionBump(*args, **kwargs)

              flowchart TD
              oops.rules.manifest.ManifestVersionBump[ManifestVersionBump]

              

              click oops.rules.manifest.ManifestVersionBump href "" "oops.rules.manifest.ManifestVersionBump"
            

Verify that the manifest version is bumped when addon files are staged.

This rule is git-aware: it reads the list of staged files from the index, determines which addons are affected, and only activates for those. Addons that have no staged files are silently skipped, so the rule is safe to run on all manifests via oops-man-check.

Two strategies are supported (set manifest.version_bump_strategy in .oops.yaml):

strict (default when not off) The staged version must be strictly greater than the version at HEAD. Enforces a version bump on every commit that touches the addon.

trunk The staged version must be strictly greater than the version at the last git tag. One bump per release cycle is sufficient — fits trunk-based / squash-merge workflows.

off (default) Rule is disabled. Set explicitly to activate::

    manifest:
      version_bump_strategy: strict   # or trunk

New addons (absent from the reference commit / tag) are always exempt. Only the module-specific tail of the version is compared (last 3 parts of the 5-part Odoo string), so migrating to a new Odoo major version without bumping the module version does not trigger a false positive.

Source code in oops/rules/manifest.py
def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    self._checked = False
    self._ref_version: Optional[Tuple[int, ...]] = None

    # Load strategy from config (falls back to class default "off").
    cfg = _load_manifest_cfg()
    strategy = cfg.version_bump_strategy if cfg is not None else self.version_bump_strategy

    if strategy == "off":
        return  # rule disabled

    # Determine the git reference to compare against.
    if strategy == "trunk":
        ref: Optional[str] = _last_tag()
        if ref is None:
            return  # no tag yet, nothing to compare against
    else:
        ref = "HEAD"

    # Identify which file we're currently linting.
    # set_lint_path() is called by run_fixit() before fixit_file() per path.
    path = _get_lint_path()
    if path is None:
        return

    repo_root = _git_repo_root()
    if repo_root is None:
        return

    try:
        rel_path = str(path.relative_to(repo_root))
    except ValueError:
        return

    # Only activate for manifests whose addon has staged changes.
    if rel_path not in _staged_addon_manifest_relpaths():
        return

    # Fetch the reference manifest and extract its version.
    ref_src = _file_at_ref(rel_path, ref)
    if ref_src is None:
        return  # new addon at this ref → exempt

    self._ref_version = _parse_version_str(ref_src)

OdooManifestAuthorMaintainers

OdooManifestAuthorMaintainers(*args, **kwargs)

              flowchart TD
              oops.rules.manifest.OdooManifestAuthorMaintainers[OdooManifestAuthorMaintainers]

              

              click oops.rules.manifest.OdooManifestAuthorMaintainers href "" "oops.rules.manifest.OdooManifestAuthorMaintainers"
            

Validate manifest field values: author, maintainers, summary, version.

All four checks run in a single visit_Dict pass; each is extracted into its own _check_* method so they can be read and extended independently.

Autofixable checks ~~~~~~~~~~~~~~~~~~ - author — replaced with the configured value (preserves quote style) - version — digit-lookalike typos corrected (O→0, l/I→1) when the corrected string passes the pattern

Source code in oops/rules/manifest.py
def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    self._checked = False
    cfg = _load_manifest_cfg()
    if cfg is not None:
        self.expected_author = cfg.author
        self.odoo_version = cfg.odoo_version
        self.allowed_maintainers = cfg.allowed_maintainers