Skip to content

Commit a26146c

Browse files
authored
[REF] core: support streaming process output and AI CLI integration (#134)
* [REF] core: support AI CLI sandboxed execution * fix(rest): add ConnectionResetError to retryable exceptions The REST connector now catches ConnectionResetError in addition to RequestsConnectionError during requests. This ensures that the built-in retry logic is triggered when a connection is reset by the peer, improving resilience against transient network issues. * fix(test): improve debugger detection regex * [IMP] rest: improve cookie handling and fix caching order
1 parent 884028f commit a26146c

File tree

22 files changed

+237
-47
lines changed

22 files changed

+237
-47
lines changed

odev/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def main():
3535
odev = init_framework()
3636
odev.start(start_time)
3737
logger.debug(f"Framework started in {monotonic() - start_time:.3f} seconds")
38-
odev.dispatch()
38+
sys.exit(0 if odev.dispatch() else 1)
3939

4040
except OdevError as error:
4141
logger.error(error)

odev/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@
2222
# or merged change.
2323
# ------------------------------------------------------------------------------
2424

25-
__version__ = "4.26.0"
25+
__version__ = "4.27.0"

odev/commands/database/cloc.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def run(self):
4040

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

43-
if process is None:
43+
if process is None or process.returncode:
4444
raise self.error("Failed to fetch cloc result.")
4545

4646
headers = [

odev/commands/database/create.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class CreateCommand(OdoobinTemplateCommand):
3939
)
4040
version_argument = args.String(
4141
name="version",
42+
aliases=["-V", "--version"],
4243
description="""The Odoo version to use for the new database.
4344
If not specified and a template is provided, the version of
4445
the template database will be used. Otherwise, the version will default to "master".

odev/commands/database/delete.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def run(self):
7676
databases_list: str = string.join_and([f"{db!r}" for db in databases])
7777
logger.warning(f"You are about to delete the following databases: {databases_list}")
7878

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

8282
tracker = progress.Progress()

odev/commands/database/run.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,7 @@ def run(self):
7676
if self.odoobin.is_running:
7777
raise self.error(f"Database {self._database.name!r} is already running")
7878

79-
self.odoobin.run(args=self.args.odoo_args, progress=self.odoobin_progress)
79+
process = self.odoobin.run(args=self.args.odoo_args, progress=self.odoobin_progress)
80+
81+
if process and process.returncode:
82+
raise self.error("Odoo process failed")

odev/commands/database/test.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ class TestCommand(OdoobinCommand):
3838
description="Comma-separated list of modules to install for testing. If not set, install the base module.",
3939
)
4040

41+
@property
42+
def _database_exists_required(self) -> bool:
43+
"""Return True if a database has to exist for the command to work."""
44+
return not bool(self.args.version)
45+
4146
def __init__(self, *args, **kwargs) -> None:
4247
super().__init__(*args, **kwargs)
4348
self.test_files: list[str] = []
@@ -84,8 +89,8 @@ def create_test_database(self):
8489
"""Return the arguments to pass to the create command."""
8590
args = ["--bare"]
8691

87-
if self._database.version is not None:
88-
args.extend(["--version", str(self._database.version)])
92+
if self.version is not None:
93+
args.extend(["--version", str(self.version)])
8994

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

111116
odoobin = self.test_database.process or OdoobinProcess(self.test_database)
112-
odoobin.with_version(self._database.version)
113-
odoobin.with_edition(self._database.edition)
117+
odoobin.with_version(self.version)
118+
119+
edition = (
120+
"enterprise"
121+
if self.args.enterprise or (self._database.exists and self._database.edition == "enterprise")
122+
else "community"
123+
)
124+
odoobin.with_edition(edition)
114125
odoobin.with_venv(self.venv)
115126
odoobin.with_worktree(self.worktree)
116127

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

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

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

135-
if match is None:
136-
if self.last_level in problematic_test_levels:
146+
if match is None or not self.args.pretty:
147+
if match is None and self.last_level in problematic_test_levels:
137148
self.test_buffer.append(line)
138149

150+
if not self.args.pretty:
151+
self.print(line, highlight=False, soft_wrap=False)
152+
return
153+
139154
color = f"logging.level.{self.last_level}" if self.last_level in problematic_test_levels else "color.black"
140155
self.print(string.stylize(line, color), highlight=False, soft_wrap=False)
141156
return

odev/commands/git/worktree.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ def create_worktree(self):
9090
self.__check_name()
9191

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

9596
with progress.spinner(f"Creating worktree {self.args.name}"):
9697
for repository in self.repositories:

odev/common/bash.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@
3636
# --- Helpers ------------------------------------------------------------------
3737

3838

39-
def __run_command(command: str, capture: bool = True, sudo_password: str | None = None) -> CompletedProcess[bytes]:
39+
def __run_command(
40+
command: str, capture: bool = True, sudo_password: str | None = None, env: dict[str, str] | None = None
41+
) -> CompletedProcess[bytes]:
4042
"""Execute a command as a subprocess.
4143
If `sudo_password` is provided and not `None`, the command will be executed with
4244
elevated privileges.
@@ -45,6 +47,7 @@ def __run_command(command: str, capture: bool = True, sudo_password: str | None
4547
:param bool capture: Whether to capture the output of the command.
4648
:param str sudo_password: The password to use when executing the command with
4749
elevated privileges.
50+
:param dict env: The environment variables to use when executing the command.
4851
:return: The result of the command execution.
4952
:rtype: CompletedProcess
5053
"""
@@ -58,6 +61,7 @@ def __run_command(command: str, capture: bool = True, sudo_password: str | None
5861
check=True,
5962
capture_output=capture,
6063
input=sudo_password.encode() if sudo_password is not None else None,
64+
env=env,
6165
)
6266

6367

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

8286

83-
def execute(command: str, sudo: bool = False, raise_on_error: bool = True) -> CompletedProcess[bytes] | None:
87+
def execute(
88+
command: str, sudo: bool = False, raise_on_error: bool = True, env: dict[str, str] | None = None
89+
) -> CompletedProcess[bytes] | None:
8490
"""Execute a command in the operating system and wait for it to complete.
8591
Output of the command will be captured and returned after the execution completes.
8692
@@ -97,7 +103,7 @@ def execute(command: str, sudo: bool = False, raise_on_error: bool = True) -> Co
97103
"""
98104
try:
99105
logger.debug(f"Running process: {shlex.quote(command)}")
100-
process_result = __run_command(command)
106+
process_result = __run_command(command, env=env)
101107
except CalledProcessError as exception:
102108
# If already running as root, sudo will not work
103109
if not sudo or not os.geteuid():
@@ -112,7 +118,7 @@ def execute(command: str, sudo: bool = False, raise_on_error: bool = True) -> Co
112118
return None
113119

114120
try:
115-
process_result = __run_command(command, sudo_password=sudo_password)
121+
process_result = __run_command(command, sudo_password=sudo_password, env=env)
116122
except CalledProcessError as exception:
117123
sudo_password = None
118124
__raise_or_log(exception, raise_on_error)
@@ -121,15 +127,16 @@ def execute(command: str, sudo: bool = False, raise_on_error: bool = True) -> Co
121127
return process_result
122128

123129

124-
def run(command: str) -> CompletedProcess:
130+
def run(command: str, env: dict[str, str] | None = None) -> CompletedProcess:
125131
"""Execute a command in the operating system and wait for it to complete.
126132
Output of the command will not be captured and will be printed to the console
127133
in real-time.
128134
129135
:param str command: The command to execute.
136+
:param dict env: The environment variables to use when executing the command.
130137
"""
131138
logger.debug(f"Running process: {shlex.quote(command)}")
132-
return __run_command(command, capture=False)
139+
return __run_command(command, capture=False, env=env)
133140

134141

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

143150

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

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

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

166174
try:
167-
process = Popen( # noqa: S603
168-
shlex.split(command),
175+
process = Popen( # noqa: S602
176+
command,
169177
stdout=slave,
170178
stderr=slave,
171179
stdin=slave,
172180
start_new_session=True,
173-
universal_newlines=True,
181+
shell=True,
182+
env=env,
174183
)
175184

176185
received_buffer: bytes = b""

odev/common/commands/odoobin.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ def version(self) -> OdooVersion:
142142
if self._database.version:
143143
return self._database.version
144144

145+
if self._database.exists and not self._database.is_odoo:
146+
logger.warning(
147+
f"Database {self._database.name!r} is not an Odoo database. Defaulting to 'master'. "
148+
f"Consider using 'odev create -V <version> {self._database.name}' to initialize it properly."
149+
)
150+
145151
return OdooVersion("master")
146152

147153
@property

0 commit comments

Comments
 (0)