Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/odev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ jobs:
- "3.10"
- "3.11"
- "3.12"
- "3.13"

steps:
- name: setup-python
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ tests/plugins/*
# --- Temporary and testing files
_*/
tmp/

# --- AI files
.cursor/
.agents/
2 changes: 1 addition & 1 deletion odev/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def main():
odev = init_framework()
odev.start(start_time)
logger.debug(f"Framework started in {monotonic() - start_time:.3f} seconds")
odev.dispatch()
sys.exit(0 if odev.dispatch() else 1)

except OdevError as error:
logger.error(error)
Expand Down
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.24.0"
__version__ = "4.27.1"

Check failure on line 25 in odev/_version.py

View workflow job for this annotation

GitHub Actions / version-bump

Version Update Not Incremental

The new version value does not follow the incremental pattern (e.g: 1.2.3 -> 1.2.4 or 1.3.0 or 2.0.0). Please update incrementally the __version__ value on odev/_version.py
2 changes: 1 addition & 1 deletion odev/commands/database/cloc.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def run(self):

process = self.odoobin.run(args=self.args.odoo_args, subcommand=self._name, stream=False)

if process is None:
if process is None or process.returncode:
raise self.error("Failed to fetch cloc result.")

headers = [
Expand Down
3 changes: 2 additions & 1 deletion odev/commands/database/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class CreateCommand(OdoobinTemplateCommand):
)
version_argument = args.String(
name="version",
aliases=["-V", "--version"],
description="""The Odoo version to use for the new database.
If not specified and a template is provided, the version of
the template database will be used. Otherwise, the version will default to "master".
Expand Down Expand Up @@ -184,7 +185,7 @@ def initialize_database(self) -> None:
process.with_worktree(self.worktree)

try:
run_process = process.run(args=args, progress=self.odoobin_progress)
run_process = process.run(args=args, progress=self.odoobin_progress, prepare=True)
self.console.print()
except OdevError as error:
logger.error(str(error))
Expand Down
4 changes: 2 additions & 2 deletions odev/commands/database/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,15 @@ def run(self):
databases_list: str = string.join_and([f"{db!r}" for db in databases])
logger.warning(f"You are about to delete the following databases: {databases_list}")

if not self.console.confirm("Are you sure?", default=False):
if not self.console.confirm("Are you sure?", default=self.args.bypass_prompt):
raise self.error("Command aborted")

tracker = progress.Progress()
task = tracker.add_task(f"Deleting {len(databases)} databases", total=len(databases))
tracker.start()

for database in databases:
with silence_loggers(__name__):
with silence_loggers(__name__), self.console.force_bypass_prompt(force=True):
self.delete_one(LocalDatabase(database))

tracker.update(task, advance=1)
Expand Down
5 changes: 4 additions & 1 deletion odev/commands/database/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,7 @@ def run(self):
if self.odoobin.is_running:
raise self.error(f"Database {self._database.name!r} is already running")

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

if process and process.returncode:
raise self.error("Odoo process failed")
29 changes: 22 additions & 7 deletions odev/commands/database/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ class TestCommand(OdoobinCommand):
description="Comma-separated list of modules to install for testing. If not set, install the base module.",
)

@property
def _database_exists_required(self) -> bool:
"""Return True if a database has to exist for the command to work."""
return not bool(self.args.version)

def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.test_files: list[str] = []
Expand Down Expand Up @@ -84,8 +89,8 @@ def create_test_database(self):
"""Return the arguments to pass to the create command."""
args = ["--bare"]

if self._database.version is not None:
args.extend(["--version", str(self._database.version)])
if self.version is not None:
args.extend(["--version", str(self.version)])

args.append(self.test_database.name)
self.odev.run_command("create", *args)
Expand All @@ -109,8 +114,14 @@ def run_test_database(self):
self.create_test_database()

odoobin = self.test_database.process or OdoobinProcess(self.test_database)
odoobin.with_version(self._database.version)
odoobin.with_edition(self._database.edition)
odoobin.with_version(self.version)

edition = (
"enterprise"
if self.args.enterprise or (self._database.exists and self._database.edition == "enterprise")
else "community"
)
odoobin.with_edition(edition)
odoobin.with_venv(self.venv)
odoobin.with_worktree(self.worktree)

Expand All @@ -126,16 +137,20 @@ def run_test_database(self):

def odoobin_progress(self, line: str):
"""Handle odoo-bin output and fetch information real-time."""
if re.match(r"^(i?pu?db)?>+", line):
if re.match(r"^(?:ipdb|pudb|pdb)>+|^\(Pdb\)|(?:^>\s+.*\.(?:py|js)\(\d+\))", line):
raise self.error("Debugger detected in odoo-bin output, remove breakpoints and try again")

problematic_test_levels = ("warning", "error", "critical")
match = self._parse_progress_log_line(line)

if match is None:
if self.last_level in problematic_test_levels:
if match is None or not self.args.pretty:
if match is None and self.last_level in problematic_test_levels:
self.test_buffer.append(line)

if not self.args.pretty:
self.print(line, highlight=False, soft_wrap=False)
return

color = f"logging.level.{self.last_level}" if self.last_level in problematic_test_levels else "color.black"
self.print(string.stylize(line, color), highlight=False, soft_wrap=False)
return
Expand Down
4 changes: 4 additions & 0 deletions odev/commands/git/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ def grouped_changes(self) -> dict[str, list[tuple[str, int, int]]]:
changes: dict[str, list[tuple[str, int, int]]] = {}

for repository in self.repositories:
try:
repository.prune_worktrees()
except Exception as e: # noqa: BLE001
logger.debug(f"Failed to prune worktrees for {repository.name!r}: {e}")
repository.fetch(detached=False)

for name, worktrees in self.grouped_worktrees.items():
Expand Down
3 changes: 2 additions & 1 deletion odev/commands/git/worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ def create_worktree(self):
self.__check_name()

if self.args.name in self.grouped_worktrees:
raise self.error(f"Worktree with name '{self.args.name}' already exists")
logger.info(f"Worktree with name '{self.args.name}' already exists")
return

with progress.spinner(f"Creating worktree {self.args.name}"):
for repository in self.repositories:
Expand Down
31 changes: 20 additions & 11 deletions odev/common/bash.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
# --- Helpers ------------------------------------------------------------------


def __run_command(command: str, capture: bool = True, sudo_password: str | None = None) -> CompletedProcess[bytes]:
def __run_command(
command: str, capture: bool = True, sudo_password: str | None = None, env: dict[str, str] | None = None
) -> CompletedProcess[bytes]:
"""Execute a command as a subprocess.
If `sudo_password` is provided and not `None`, the command will be executed with
elevated privileges.
Expand All @@ -45,6 +47,7 @@ def __run_command(command: str, capture: bool = True, sudo_password: str | None
:param bool capture: Whether to capture the output of the command.
:param str sudo_password: The password to use when executing the command with
elevated privileges.
:param dict env: The environment variables to use when executing the command.
:return: The result of the command execution.
:rtype: CompletedProcess
"""
Expand All @@ -58,6 +61,7 @@ def __run_command(command: str, capture: bool = True, sudo_password: str | None
check=True,
capture_output=capture,
input=sudo_password.encode() if sudo_password is not None else None,
env=env,
)


Expand All @@ -80,7 +84,9 @@ def __raise_or_log(exception: CalledProcessError, do_raise: bool) -> None:
# --- Public API ---------------------------------------------------------------


def execute(command: str, sudo: bool = False, raise_on_error: bool = True) -> CompletedProcess[bytes] | None:
def execute(
command: str, sudo: bool = False, raise_on_error: bool = True, env: dict[str, str] | None = None
) -> CompletedProcess[bytes] | None:
"""Execute a command in the operating system and wait for it to complete.
Output of the command will be captured and returned after the execution completes.

Expand All @@ -97,7 +103,7 @@ def execute(command: str, sudo: bool = False, raise_on_error: bool = True) -> Co
"""
try:
logger.debug(f"Running process: {shlex.quote(command)}")
process_result = __run_command(command)
process_result = __run_command(command, env=env)
except CalledProcessError as exception:
# If already running as root, sudo will not work
if not sudo or not os.geteuid():
Expand All @@ -112,7 +118,7 @@ def execute(command: str, sudo: bool = False, raise_on_error: bool = True) -> Co
return None

try:
process_result = __run_command(command, sudo_password=sudo_password)
process_result = __run_command(command, sudo_password=sudo_password, env=env)
except CalledProcessError as exception:
sudo_password = None
__raise_or_log(exception, raise_on_error)
Expand All @@ -121,15 +127,16 @@ def execute(command: str, sudo: bool = False, raise_on_error: bool = True) -> Co
return process_result


def run(command: str) -> CompletedProcess:
def run(command: str, env: dict[str, str] | None = None) -> CompletedProcess:
"""Execute a command in the operating system and wait for it to complete.
Output of the command will not be captured and will be printed to the console
in real-time.

:param str command: The command to execute.
:param dict env: The environment variables to use when executing the command.
"""
logger.debug(f"Running process: {shlex.quote(command)}")
return __run_command(command, capture=False)
return __run_command(command, capture=False, env=env)


def detached(command: str) -> Popen[bytes]:
Expand All @@ -141,15 +148,16 @@ def detached(command: str) -> Popen[bytes]:
return Popen(command, shell=True, start_new_session=True, stdout=DEVNULL, stderr=DEVNULL) # noqa: S602 - intentional use of shell=True


def stream(command: str) -> Generator[str, None, None]: # noqa: PLR0912
def stream(command: str, env: dict[str, str] | None = None) -> Generator[str, None, None]: # noqa: PLR0912
"""Execute a command in the operating system and stream its output line by line.
:param str command: The command to execute.
:param dict env: The environment variables to use when executing the command.
"""
logger.debug(f"Streaming process: {shlex.quote(command)}")

if not sys.stdin.isatty():
logger.warning("STDIN is not a TTY, running command in non-interactive mode")
exec_process = execute(command)
exec_process = execute(command, env=env)

if not exec_process:
yield ""
Expand All @@ -164,13 +172,14 @@ def stream(command: str) -> Generator[str, None, None]: # noqa: PLR0912
master, slave = pty.openpty()

try:
process = Popen( # noqa: S603
shlex.split(command),
process = Popen( # noqa: S602
command,
stdout=slave,
stderr=slave,
stdin=slave,
start_new_session=True,
universal_newlines=True,
shell=True,
env=env,
)

received_buffer: bytes = b""
Expand Down
32 changes: 32 additions & 0 deletions odev/common/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Caching implementations for various purposes."""

from datetime import datetime
from typing import Any


class TTLCache:
def __init__(self, ttl: int):
self.ttl = ttl
self.cache = {}

def get(self, key: str) -> Any | None:
if key not in self.cache:
return None

if (datetime.now() - self.cache[key]["timestamp"]).total_seconds() > self.ttl:
del self.cache[key]
return None

return self.cache[key]["value"]

def set(self, key: str, value: Any) -> None:
self.cache[key] = {"value": value, "timestamp": datetime.now()}

def __contains__(self, key: str) -> bool:
return key in self.cache

def __len__(self) -> int:
return len(self.cache)

def __repr__(self) -> str:
return f"TTLCache(ttl={self.ttl}, cached={len(self.cache)})"
15 changes: 10 additions & 5 deletions odev/common/commands/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
from odev.common import args
from odev.common.commands import Command
from odev.common.connectors import GitConnector, GitWorktree
from odev.common.logging import logging
from odev.common.odoobin import odoo_repositories
from odev.common.version import OdooVersion


logger = logging.getLogger(__name__)


class GitCommand(Command, ABC):
Expand All @@ -23,10 +26,12 @@ def worktrees(self) -> Generator[GitWorktree, None, None]:
"""Iterate over worktrees in Odoo repositories."""
for repository in self.repositories:
for worktree in repository.worktrees():
if not worktree.detached and (
not self.args.version or OdooVersion(worktree.branch) == OdooVersion(self.args.version)
):
yield worktree
if not worktree.path.exists():
logger.debug(f"Skipping missing worktree {worktree.name!r} at {worktree.path!s}")
continue
if hasattr(self, "args") and self.args.version and worktree.name != self.args.version:
continue
yield worktree

@property
def grouped_worktrees(self) -> dict[str, list[GitWorktree]]:
Expand Down
6 changes: 6 additions & 0 deletions odev/common/commands/odoobin.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ def version(self) -> OdooVersion:
if self._database.version:
return self._database.version

if self._database.exists and not self._database.is_odoo:
logger.warning(
f"Database {self._database.name!r} is not an Odoo database. Defaulting to 'master'. "
f"Consider using 'odev create -V <version> {self._database.name}' to initialize it properly."
)

return OdooVersion("master")

@property
Expand Down
11 changes: 11 additions & 0 deletions odev/common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,17 @@ def dumps(self) -> Path:
def dumps(self, value: str | Path):
self.set("dumps", value.as_posix() if isinstance(value, Path) else value)

@property
def upgrade(self) -> Path:
"""Path to the directory where Odoo Enterprise migration scripts are stored.
Defaults to ~/odoo/repositories/odoo/upgrade.
"""
return Path(cast(str, self.get("upgrade", "~/odoo/repositories/odoo/upgrade"))).expanduser()

@upgrade.setter
def upgrade(self, value: str | Path):
self.set("upgrade", value.as_posix() if isinstance(value, Path) else value)


class UpdateSection(Section):
"""Configuration for odev auto-updates."""
Expand Down
Loading
Loading