Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion odev/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@
# or merged change.
# ------------------------------------------------------------------------------

__version__ = "4.29.2"
__version__ = "4.29.3"

Check notice on line 25 in odev/_version.py

View workflow job for this annotation

GitHub Actions / version-bump

Patch Update
5 changes: 2 additions & 3 deletions odev/commands/database/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from odev.common.databases import LocalDatabase
from odev.common.errors.odev import OdevError
from odev.common.odev import logger
from odev.common.odoobin import OdoobinProcess
from odev.common.version import OdooVersion


Expand Down Expand Up @@ -176,14 +175,14 @@ def initialize_database(self) -> None:
if not re.search(r"--st(op-after-init)?", joined_args):
args.append("--stop-after-init")

process = self.odoobin or OdoobinProcess(self._database)
process = self.odoobin or self.odev.odoobin_process_class(self._database)
process.with_edition("enterprise" if self.args.enterprise else "community")
process.with_version(self.version)
process.with_venv(self.venv)
process.with_worktree(self.worktree)

try:
run_process = process.run(args=args, progress=self.odoobin_progress, prepare=True)
run_process = process.run(args=args, stream_filter=self.odoobin_progress, prepare=True)
self.console.print()
except OdevError as error:
logger.error(str(error))
Expand Down
2 changes: 1 addition & 1 deletion odev/commands/database/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def run(self):
odoobin: OdoobinProcess = (
self._database.process
if isinstance(self._database, LocalDatabase)
else OdoobinProcess(LocalDatabase(self.odev.name), version=self._database.version)
else self.odev.odoobin_process_class(LocalDatabase(self.odev.name), version=self._database.version)
)

url = self._database.url if isinstance(self._database, RemoteDatabase) else None
Expand Down
2 changes: 1 addition & 1 deletion odev/commands/database/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def run(self):
if self.odoobin.is_running:
raise self.error(f"Database {self._database.name!r} is already running")

process = self.odoobin.run(args=self.args.odoo_args, progress=self.odoobin_progress)
process = self.odoobin.run(args=self.args.odoo_args, stream_filter=self.odoobin_progress)

if process and process.returncode:
raise self.error("Odoo process failed")
4 changes: 2 additions & 2 deletions odev/commands/database/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ def run_test_database(self):
if not self.test_database.exists:
self.create_test_database()

odoobin = self.test_database.process or OdoobinProcess(self.test_database)
odoobin = self.test_database.process or self.odev.odoobin_process_class(self.test_database)
odoobin.with_version(self.version)

edition = (
Expand All @@ -138,7 +138,7 @@ def run_test_database(self):
odoobin.additional_addons_paths = cast(OdoobinProcess, self.odoobin).additional_addons_paths

try:
process = odoobin.run(args=args, progress=self.odoobin_progress)
process = odoobin.run(args=args, stream_filter=self.odoobin_progress)

if process is not None and process.returncode != 0 and not self.test_buffer:
raise self.error("Odoo crashed during initialization. Check the logs above.")
Expand Down
2 changes: 1 addition & 1 deletion odev/commands/database/upgrade_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def run(self):
self.odoobin.odoobin_path,
cmd_args,
stream=True,
progress=lambda line: self.odoobin.console.print(line, end=""),
stream_filter=lambda line: (self.odoobin.console.print(line, end=""), line)[1],
)
if result.returncode not in (0, 1):
raise self.error(f"Odoo exited with code {result.returncode}")
Expand Down
12 changes: 6 additions & 6 deletions odev/common/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,12 +300,12 @@ def _rescue_positional_from_unknown_flag(
if captured is None:
continue

raw_val = (
captured[0]
if isinstance(captured, list) and captured
else (str(captured) if not isinstance(captured, list) else None)
)
if raw_val is None:
if isinstance(captured, list):
raw_val = ",".join(map(str, captured))
else:
raw_val = str(captured)

if not raw_val:
continue

try:
Expand Down
46 changes: 6 additions & 40 deletions odev/common/commands/odoobin.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,41 +83,6 @@ class OdoobinCommand(LocalDatabaseCommand, ABC):
# Properties
# --------------------------------------------------------------------------

ODOO_LOG_REGEX: re.Pattern = re.compile(
r"""
(?:
((?P<date>\d{4}-\d{2}-\d{2})\s)?
(?P<time>\d{2}:\d{2}:\d{2},\d{3})\s
((?P<pid>\d+)\s)?
(?P<level>[A-Z]+)\s
(?P<database>[^\s]+)\s
(?P<logger>
((?:odoo\.addons\.)(?P<module>[^\.]+))?[^:]+
):\s
(?P<description>.*)
)
""",
re.VERBOSE | re.IGNORECASE,
)
"""Regular expression to match the output of odoo-bin."""

ODOO_LOG_WERKZEUG_REGEX: re.Pattern = re.compile(
r"""
(?:
(?P<ip>(?:\d{1,3}\.){3}\d{1,3}).+?\]\s\"
(?P<verb>\w+)\s
(?P<url>.+?(?=\s))\s
(?P<http>.+?(?=\"))\"\s
(?P<code>\d+)\s-\s
(?P<count_query>\d+)\s
(?P<time_query>[\d\.]+)\s
(?P<time_remaining>[\d\.]+)
)
""",
re.VERBOSE | re.IGNORECASE,
)
"""Regular expression to match the output of odoo-bin Werkzeug-specific logs."""

last_level: str = "INFO"
"""Log-level level of the last line printed by the odoo-bin process."""

Expand Down Expand Up @@ -180,16 +145,17 @@ def combined_odoo_args(self) -> list[str]:
args.insert(0, ",".join(self.args.addons))
return args

def odoobin_progress(self, line: str):
def odoobin_progress(self, line: str) -> str | None:
"""Beautify odoo logs on the fly."""
match = self._parse_progress_log_line(line)

if match is None or not self.args.pretty:
self.print(markup.escape(line), highlight=False, soft_wrap=False)
return
return line

self.last_level = match.group("level").lower()
self._print_progress_log_line(match)
return line

def _guess_addons_paths(self) -> list[Path]:
"""Guess the addons path."""
Expand Down Expand Up @@ -249,7 +215,7 @@ def _set_odoobin_process(self, force=False) -> None:
edition: Literal["community", "enterprise"] = (
"enterprise" if self.args.enterprise or self._database.edition == "enterprise" else "community"
)
process = OdoobinProcess(
process = self.odev.odoobin_process_class(
database=self._database,
version=version,
venv=venv.name,
Expand All @@ -264,7 +230,7 @@ def _print_progress_log_line(self, match: re.Match):
logger = match.group("logger")
description = match.group("description")

if logger == "werkzeug" and (http_match := re.match(self.ODOO_LOG_WERKZEUG_REGEX, description)):
if logger == "werkzeug" and (http_match := re.match(OdoobinProcess.LOG_WERKZEUG_REGEX, description)):
dash = string.stylize("-", "color.black")
code = http_match.group("code")

Expand Down Expand Up @@ -308,7 +274,7 @@ def _print_progress_log_line(self, match: re.Match):

def _parse_progress_log_line(self, line: str) -> re.Match | None:
"""Parse a line of odoo-bin output."""
return re.match(self.ODOO_LOG_REGEX, string.strip_ansi_colors(line).replace("\r", ""))
return re.match(OdoobinProcess.LOG_REGEX, string.strip_ansi_colors(line).replace("\r", ""))

def _colorize_duration_by_threshold(self, time: str | float, thresholds: Mapping[float, str]) -> str:
"""Colorize the textual representation of a duration according to thresholds.
Expand Down
2 changes: 1 addition & 1 deletion odev/common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ def get_date(self, version: str) -> datetime:
"""Last time a specific version was pulled from GitHub."""
value = self.get(f"date_{version}")
if not value:
return self.date
return datetime.fromtimestamp(0)
return datetime.strptime(value, DATETIME_FORMAT)

def set_date(self, version: str, value: datetime):
Expand Down
2 changes: 1 addition & 1 deletion odev/common/databases/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ def installed_modules(self) -> list[str]:

def _get_process_instance(self) -> OdoobinProcess:
"""Get the Odoo process for the database."""
return OdoobinProcess(
return self.odev.odoobin_process_class(
self,
(self.venv and self.venv.path.name) or str(self.version),
self.worktree or (str(self.version) if self.version else None),
Expand Down
4 changes: 3 additions & 1 deletion odev/common/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ def find_debuggers(root: str | Path) -> Generator[tuple[Path, int], None, None]:
raise NotADirectoryError(f"{root} is not a directory")

try:
grep = bash.execute(rf"""grep -RnE "((i?pu?db)\.set_trace\(|pu\.db)" {root.as_posix()} --include='*.py'""")
grep = bash.execute(
rf"""grep -RnE "^[[:space:]]*[^#]*((i?pu?db)\.set_trace\(|pu\.db|breakpoint\(\))" {root.as_posix()} --include='*.py'"""
)
output = grep.stdout.decode() if grep is not None else ""
except subprocess.CalledProcessError:
output = ""
Expand Down
2 changes: 1 addition & 1 deletion odev/common/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
DEBUG_SQL = False

__log_level = re.search(
r"\s(?:-v\s?|--log-level(?:\s|=){1})([a-zA-Z-_]+)",
r"\s(?:-v|--log-level)(?:\s+|=)([a-zA-Z_]+)",
" ".join(sys.argv),
)

Expand Down
4 changes: 2 additions & 2 deletions odev/common/mixins/framework/framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ class OdevFrameworkMixin(ABC):
def __init__(self, *args, **kwargs):
"""Initialize the mixin."""
super().__init__(*args, **kwargs)
from odev.common import framework # noqa: PLC0415 - avoid circular import at top level
from odev import common as _common # noqa: PLC0415 - avoid circular import at top level

self.__class__._framework = framework
self.__class__._framework = _common.framework

@property
def odev(self) -> "Odev":
Expand Down
95 changes: 71 additions & 24 deletions odev/common/odev.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from time import monotonic, sleep
from types import ModuleType
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Generic,
Expand Down Expand Up @@ -47,6 +48,10 @@
from odev.common.telemetry import Telemetry


if TYPE_CHECKING:
from odev.common.odoobin import OdoobinProcess as OdoobinProcessType


try:
from datetime import UTC
except ImportError: # UTC is only available in Python 3.11+
Expand Down Expand Up @@ -224,6 +229,7 @@ def plugins(self) -> Generator[Plugin, None, None]:
"""Yields enabled plugins sorted topologically."""
for plugin_name in self._plugins_dependency_tree():
plugin_path = self.plugins_path / plugin_name.split("/")[-1].replace("-", "_")

plugin_manifest = self._load_plugin_manifest(plugin_path)
yield Plugin(plugin_name, plugin_path, plugin_manifest)

Expand All @@ -243,6 +249,17 @@ def release(self) -> str:

return f"dev:{branch}"

@property
def odoobin_process_class(self) -> "type[OdoobinProcessType]":
"""The class used to spawn Odoo processes. Can be overridden by plugins."""
from odev.common.odoobin import OdoobinProcess # noqa: PLC0415

return getattr(self, "_odoobin_process_class", OdoobinProcess)

@odoobin_process_class.setter
def odoobin_process_class(self, value: "type[OdoobinProcessType]") -> None:
self._odoobin_process_class = value

def start(self, start_time: float | None = None) -> None:
"""Start the framework, check for updates and load plugins and commands.

Expand All @@ -262,11 +279,12 @@ def start(self, start_time: float | None = None) -> None:

self.plugins_path.mkdir(parents=True, exist_ok=True)

if self.__should_update_now():
if self._should_update_now():
self.check_release()
self.update()

with progress.spinner("Loading commands"):
self.load_plugins()
self.register_commands()
self.register_plugin_commands()

Expand Down Expand Up @@ -555,6 +573,57 @@ def register_commands(self) -> None:
command_class.prepare_command(self)
self.commands.update(dict.fromkeys(command_names, command_class))

def load_plugins(self) -> None:
"""Import all enabled plugins to allow them to patch the framework."""
import odev # noqa: PLC0415

# Ensure odev.plugins exists as a module so legacy imports work
if "odev.plugins" not in sys.modules:
plugins_module = ModuleType("odev.plugins")
plugins_module.__path__ = [str(self.plugins_path)]
plugins_module.__package__ = "odev.plugins"
plugins_module.__file__ = None
plugins_module.__spec__ = ModuleSpec("odev.plugins", None, is_package=True)
sys.modules["odev.plugins"] = plugins_module
if hasattr(odev, "__path__"):
odev.plugins = plugins_module

# Add plugins_path to sys.path to allow direct imports of plugin modules
if str(self.plugins_path) not in sys.path:
sys.path.insert(0, str(self.plugins_path))

for plugin in self.plugins:
logger.debug(
f"Loading plugin {plugin.name!r} version {string.stylize(plugin.manifest['version'], 'repr.version')}"
)

try:
# Module names MUST use underscores even if directories use dashes
plugin_module_name = plugin.path.name.replace("-", "_")
module_name = f"odev.plugins.{plugin_module_name}"

# Try to import directly from sys.path first
try:
module = importlib.import_module(plugin_module_name)
except ImportError:
# Fallback to explicit file loading if direct import fails
init_path = plugin.path / "__init__.py"
if not init_path.exists():
continue
spec = spec_from_file_location(plugin_module_name, init_path)
if not spec or not spec.loader:
continue
module = module_from_spec(spec)
sys.modules[plugin_module_name] = module
spec.loader.exec_module(module)

# Ensure it's available as odev.plugins.X
sys.modules[module_name] = module
setattr(sys.modules["odev.plugins"], plugin_module_name, module)

except Exception as error: # noqa: BLE001
logger.error(f"Could not load plugin module {plugin.path.name}: {error}")

def register_plugin_commands(self) -> None:
"""Register commands for the plugins directories, pulling changes in plugins if an error arises while loading
the commands.
Expand All @@ -579,29 +648,7 @@ def register_plugin_commands(self) -> None:

def _register_plugin_commands(self) -> None:
"""Register all commands from the plugins directories."""
# Ensure odev.plugins exists as a module so legacy imports work
odev_module = sys.modules.get("odev")
if odev_module:
if not hasattr(odev_module, "plugins"):
odev_module.plugins = ModuleType("odev.plugins")
odev_module.plugins.__path__ = []
odev_module.plugins.__package__ = "odev.plugins"
odev_module.plugins.__spec__ = ModuleSpec("odev.plugins", None, is_package=True)
sys.modules["odev.plugins"] = odev_module.plugins

if str(self.plugins_path) not in odev_module.plugins.__path__:
odev_module.plugins.__path__.append(str(self.plugins_path))

for plugin in self.plugins:
logger.debug(
f"Loading plugin {plugin.name!r} version {string.stylize(plugin.manifest['version'], 'repr.version')}"
)

try:
importlib.import_module(f"odev.plugins.{plugin.path.name}")
except ImportError as error:
logger.debug(f"Could not import plugin module {plugin.path.name}: {error}")

for command_class in self.import_commands(plugin.path.glob("commands/**")):
command_names = [command_class._name] + (list(command_class._aliases) or [])
base_command_class = self.commands.get(command_class._name)
Expand Down Expand Up @@ -1085,7 +1132,7 @@ def __requirements_changed(self, repository: Repo) -> bool:

return bool(diff)

def __should_update_now(self) -> bool:
def _should_update_now(self) -> bool:
"""Check whether the last check date is older than today minus the check interval.

:return: Whether the last check date is older than today minus the check interval
Expand Down
Loading
Loading