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/__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.26.0"
__version__ = "4.27.0"

Check notice on line 25 in odev/_version.py

View workflow job for this annotation

GitHub Actions / version-bump

Minor Update
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
1 change: 1 addition & 0 deletions 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
2 changes: 1 addition & 1 deletion odev/commands/database/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ 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()
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
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
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
17 changes: 15 additions & 2 deletions odev/common/connectors/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,10 +252,13 @@ def __init__(self, repo: str, path: Path | None = None):
"""
super().__init__()

# If repo looks like an absolute path, use it as the path
if not path and repo and Path(repo).is_absolute():
path = Path(repo)

self._path: Path | None = path
"""Forced path to the git repository on the local system."""

if path:
if path and path.joinpath(".git").exists():
repo_url = Repo(path).remote().url
self._organization, self._repository = repo_url.removesuffix(".git").split("/")[-2:]

Expand Down Expand Up @@ -371,6 +374,16 @@ def branch(self) -> str | None:

return self.repository.active_branch.name.split("/")[-1]

@property
def is_dirty(self) -> bool:
"""Whether the repository has uncommitted changes."""
return self.repository.is_dirty(untracked_files=True) if self.exists and self.repository else False

@property
def is_protected_branch(self) -> bool:
"""Whether the current branch is a protected branch (main or master)."""
return self.branch in ("main", "master")

@property
def requirements_path(self) -> Path:
"""Path to the requirements.txt path of the repo, if present."""
Expand Down
20 changes: 17 additions & 3 deletions odev/common/connectors/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,19 @@ def _load_cookies(self):
if cookie:
self._connection.cookies.set(key, cookie, domain=domain)

def _get_cookie_header(self) -> str:
"""Return the cookies as a string suitable for the 'Cookie' header."""
if self._connection is None:
return ""

cookies = []
for domain in self.session_domains:
for key in self.session_cookies:
value = self._connection.cookies.get(key, domain=domain)
if value:
cookies.append(f"{key}={value}")
return "; ".join(cookies)

def _save_cookies(self):
"""Save session cookies to the secrets vault."""
if self._connection is None:
Expand Down Expand Up @@ -269,7 +282,7 @@ def _request(
logger_message
+ f" -> [{response.status_code}] {response.reason} ({response.elapsed.total_seconds():.3f} seconds)"
)
except RequestsConnectionError as error:
except (RequestsConnectionError, ConnectionResetError) as error:
if retry_on_error:
logger.debug(error)
return self._request(
Expand All @@ -283,11 +296,12 @@ def _request(

raise ConnectorError(f"Could not connect to {self.name}", self) from error

self.cache(cache_key, response)
self._save_cookies()

if raise_for_status:
response.raise_for_status()

self.cache(cache_key, response)
self._save_cookies()
return response

@abstractmethod
Expand Down
5 changes: 3 additions & 2 deletions odev/common/odev.py
Original file line number Diff line number Diff line change
Expand Up @@ -875,9 +875,10 @@ def run_command(

return not command_errored

def dispatch(self, argv: list[str] | None = None) -> None:
def dispatch(self, argv: list[str] | None = None) -> bool:
"""Handle commands and arguments as received from the terminal.
:param argv: Optional list of command-line arguments used to override arguments received from the CLI.
:return: True if the command were executed successfully, False otherwise.
"""
argv = (argv or sys.argv)[1:]

Expand All @@ -895,7 +896,7 @@ def dispatch(self, argv: list[str] | None = None) -> None:
logger.debug("Help argument or no command provided, falling back to help command")
argv.insert(0, "help")

self.run_command(argv[0], *argv[1:], history=True)
return self.run_command(argv[0], *argv[1:], history=True)

def check_release(self) -> None:
"""Check if a new release is available."""
Expand Down
7 changes: 5 additions & 2 deletions odev/common/odoobin.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@

ODOO_ENTERPRISE_REPOSITORIES: list[str] = ["odoo/enterprise"]

ODOO_UPGRADE_REPOSITORY: str = "odoo/upgrade"


ODOO_PYTHON_VERSIONS: Mapping[int, str] = {
19: "3.12",
16: "3.10",
Expand Down Expand Up @@ -531,7 +534,7 @@ def deploy(
except CalledProcessError as error:
error_message: str = error.stderr.strip().decode().rstrip(".").replace("ERROR: ", "")
logger.error(f"Odoo exited with an error: {error_message}")
return None
return CompletedProcess(error.cmd, error.returncode, error.stdout, error.stderr)
else:
return process

Expand Down Expand Up @@ -600,7 +603,7 @@ def run( # noqa: PLR0913
logger.error(f"STDERR: {error.stderr.decode()}")

logger.error("Odoo exited with an error, check the output above for more information")
return None
return CompletedProcess(error.cmd, error.returncode, error.stdout, error.stderr)
else:
return process

Expand Down
Loading
Loading