# Copyright (C) 2025 The ODUIT Authors.
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at https://mozilla.org/MPL/2.0/.
import os
from pathlib import Path
from typing import Any
from manifestoo_core.exceptions import InvalidManifest as ManifestooInvalidManifest
from manifestoo_core.manifest import Manifest as ManifestooManifest
from manifestoo_core.manifest import get_manifest_path
[docs]
class ManifestError(Exception):
"""Base exception for manifest-related errors."""
[docs]
class InvalidManifestError(ManifestError):
"""Raised when manifest contains invalid syntax or structure."""
[docs]
class ManifestNotFoundError(ManifestError):
"""Raised when manifest file is not found."""
[docs]
class Manifest:
"""Represents an Odoo module manifest (__manifest__.py).
This is a wrapper around manifestoo-core's Manifest class that provides
backward compatibility with the original oduit API.
"""
[docs]
def __init__(self, module_path: str):
"""Initialize Manifest from a module directory path.
Args:
module_path: Absolute path to the module directory
Raises:
ManifestNotFoundError: If __manifest__.py is not found
InvalidManifestError: If manifest contains invalid syntax or structure
"""
self.module_path = module_path
self.module_name = os.path.basename(module_path)
self._manifestoo = self._load_manifest()
[docs]
@classmethod
def from_dict(
cls, data: dict[str, Any], module_name: str = "test_module"
) -> "Manifest":
"""Create a Manifest instance from a dictionary (primarily for testing).
Args:
data: Dictionary containing manifest data
module_name: Name of the module (for testing purposes)
Returns:
Manifest instance
"""
instance = cls.__new__(cls)
instance.module_path = f"/mock/path/{module_name}"
instance.module_name = module_name
try:
instance._manifestoo = ManifestooManifest.from_dict(data)
except ManifestooInvalidManifest as e:
raise InvalidManifestError(str(e)) from e
return instance
def _load_manifest(self) -> ManifestooManifest:
"""Load and parse the __manifest__.py file.
Returns:
ManifestooManifest instance
Raises:
ManifestNotFoundError: If __manifest__.py is not found
InvalidManifestError: If manifest contains invalid syntax or structure
"""
module_path_obj = Path(self.module_path)
manifest_path = get_manifest_path(module_path_obj)
if manifest_path is None:
raise ManifestNotFoundError(
f"Manifest file not found in: {self.module_path}"
)
try:
return ManifestooManifest.from_file(manifest_path)
except ManifestooInvalidManifest as e:
raise InvalidManifestError(
f"Invalid manifest in {self.module_name}: {e}"
) from e
except Exception as e:
raise InvalidManifestError(
f"Error parsing manifest for {self.module_name}: {e}"
) from e
@property
def name(self) -> str:
"""Get the module name from manifest or use directory name as fallback."""
manifest_name = self._manifestoo.name
return manifest_name if manifest_name else self.module_name
@property
def version(self) -> str:
"""Get the module version."""
version = self._manifestoo.version
return version if version else "1.0.0"
@property
def codependencies(self) -> list[str]:
"""Get codependencies from manifest 'depends' field.
Codependencies are modules that this module depends on, meaning changes
to those modules may impact this module.
Returns:
List of codependency module names, empty list if no codependencies
"""
depends = self._manifestoo.manifest_dict.get("depends", [])
if not isinstance(depends, list):
return []
return [
dep for dep in depends if isinstance(dep, str) and dep != self.module_name
]
@property
def installable(self) -> bool:
"""Check if the module is installable."""
return self._manifestoo.installable
@property
def auto_install(self) -> bool:
"""Check if the module is auto-installable."""
auto_install = self._manifestoo.manifest_dict.get("auto_install", False)
if isinstance(auto_install, bool):
return auto_install
return False
@property
def summary(self) -> str:
"""Get the module summary/description."""
summary = self._manifestoo.summary
return summary if summary else ""
@property
def description(self) -> str:
"""Get the module description."""
description = self._manifestoo.description
return description if description else ""
@property
def author(self) -> str:
"""Get the module author."""
author = self._manifestoo.author
return author if author else ""
@property
def website(self) -> str:
"""Get the module website."""
website = self._manifestoo.website
return website if website else ""
@property
def license(self) -> str:
"""Get the module license."""
license_str = self._manifestoo.license
return license_str if license_str else ""
@property
def external_dependencies(self) -> dict[str, list[str]]:
"""Get external dependencies (python packages, system binaries)."""
ext_deps = self._manifestoo.external_dependencies
result: dict[str, list[str]] = {}
for key, value in ext_deps.items():
if isinstance(value, list):
result[key] = [str(v) for v in value if isinstance(v, str)]
return result
@property
def python_dependencies(self) -> list[str]:
"""Get Python package dependencies."""
return self.external_dependencies.get("python", [])
@property
def binary_dependencies(self) -> list[str]:
"""Get system binary dependencies."""
return self.external_dependencies.get("bin", [])
[docs]
def get_raw_data(self) -> dict[str, Any]:
"""Get the raw manifest data dictionary."""
return self._manifestoo.manifest_dict.copy()
[docs]
def is_installable(self) -> bool:
"""Check if the module is installable (alias for installable property)."""
return self.installable
[docs]
def has_dependency(self, dependency_name: str) -> bool:
"""Check if the module has a specific codependency.
Args:
dependency_name: Name of the codependency to check for
Returns:
True if the codependency exists, False otherwise
"""
return dependency_name in self.codependencies
[docs]
def validate_structure(self) -> list[str]:
"""Validate the manifest structure and return any warnings.
Returns:
List of validation warnings (empty if no issues)
"""
warnings = []
raw_data = self._manifestoo.manifest_dict
if "name" not in raw_data:
warnings.append("Missing 'name' field")
if "version" not in raw_data:
warnings.append("Missing 'version' field")
if not self.summary and not self.description:
warnings.append("Missing 'summary' or 'description' field")
depends = raw_data.get("depends")
if depends is not None and not isinstance(depends, list):
warnings.append("'depends' field should be a list")
return warnings
[docs]
def __str__(self) -> str:
"""String representation of the manifest."""
return f"Manifest({self.module_name}: {self.name} v{self.version})"
[docs]
def __repr__(self) -> str:
"""Developer representation of the manifest."""
return f"Manifest(module_path='{self.module_path}')"