odoorun 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ publish:
13
+ name: Build and publish package
14
+ runs-on: ubuntu-latest
15
+ environment: pypi
16
+ permissions:
17
+ id-token: write
18
+
19
+ steps:
20
+ - name: Check out repository
21
+ uses: actions/checkout@v4
22
+
23
+ - name: Set up Python
24
+ uses: actions/setup-python@v5
25
+ with:
26
+ python-version: "3.x"
27
+
28
+ - name: Build distributions
29
+ run: |
30
+ python -m pip install --upgrade build
31
+ python -m build
32
+
33
+ - name: Publish to PyPI
34
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1 @@
1
+ 3.10
odoorun-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Khalid A. Dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
odoorun-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: odoorun
3
+ Version: 0.1.1
4
+ Summary: A modern CLI for running and managing Odoo projects.
5
+ Project-URL: Homepage, https://github.com/khalid99io/odoorun
6
+ Project-URL: Repository, https://github.com/khalid99io/odoorun
7
+ Project-URL: Issues, https://github.com/khalid99io/odoorun/issues
8
+ Author-email: "Khalid A. Dev" <khalid.a.dev@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,odoo
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: rich>=15.0.0
21
+ Requires-Dist: typer>=0.26.8
22
+ Description-Content-Type: text/markdown
23
+
24
+ # odoorun
25
+
26
+ `odoorun` is a portable command-line launcher for Odoo. It finds an Odoo
27
+ executable from the current project (or a parent directory), prepares the
28
+ appropriate addons path, and forwards the remaining arguments unchanged.
29
+
30
+ It does not require symlinks, shell aliases, a modified `.bashrc`, or a
31
+ machine-specific configuration.
32
+
33
+ ## Requirements
34
+
35
+ - Python 3.10 or newer
36
+ - An Odoo installation or source checkout
37
+ - `psql` is optional and is used only for database completion/diagnostics
38
+
39
+ Odoo and PostgreSQL are external dependencies; `odoorun` does not install or
40
+ configure them.
41
+
42
+ ## Installation
43
+
44
+ ### Recommended: install from PyPI
45
+
46
+ Once published, install it on any supported machine with:
47
+
48
+ ```bash
49
+ uv tool install odoorun
50
+ ```
51
+
52
+ Or use pip:
53
+
54
+ ```bash
55
+ python -m pip install odoorun
56
+ ```
57
+
58
+ ### Install directly from GitHub
59
+
60
+ ```bash
61
+ uv tool install git+https://github.com/khalid99io/odoorun.git
62
+ ```
63
+
64
+ ### Install a local checkout (development)
65
+
66
+ ```bash
67
+ uv tool install .
68
+ ```
69
+
70
+ After changing the checkout, refresh the installed command:
71
+
72
+ ```bash
73
+ uv tool install --force .
74
+ ```
75
+
76
+ ## Usage
77
+
78
+ Run from an Odoo source checkout or any directory below it:
79
+
80
+ ```bash
81
+ odoorun -d my_database --dev=all
82
+ ```
83
+
84
+ The short command `o` is optional and is not installed by this package. If
85
+ desired, add your own alias or shell function:
86
+
87
+ ```bash
88
+ alias o=odoorun
89
+ ```
90
+
91
+ ### Virtual-environment projects
92
+
93
+ For a project directory named `my-project`, `odoorun` looks for:
94
+
95
+ ```text
96
+ ~/venvs/my-project/bin/odoo
97
+ ```
98
+
99
+ When found, it adds `--addons=odoo/addons` automatically. If the virtual
100
+ environment has a different name, configure it without editing the package:
101
+
102
+ ```bash
103
+ export ODOORUN_VENV_NAME=my-project-venv
104
+ export ODOORUN_VENV_ROOT="$HOME/venvs"
105
+ ```
106
+
107
+ The tool invokes the venv executable directly; sourcing `activate` is not
108
+ required for the launched Odoo process. A subprocess cannot change the parent
109
+ shell's prompt, so prompt decoration remains a shell responsibility.
110
+
111
+ ### Odoo source checkouts
112
+
113
+ For a checkout containing an executable `odoo-bin`, the built-in `addons`
114
+ directory is passed automatically. Additional addon directories can be listed
115
+ with `-a` or `--addons-path`; relative paths are resolved from the checkout's
116
+ parent directory:
117
+
118
+ ```bash
119
+ odoorun -a custom-addons,enterprise -d my_database
120
+ ```
121
+
122
+ Each directory must exist. Missing directories are reported before Odoo starts.
123
+
124
+ ## Diagnostics
125
+
126
+ ```bash
127
+ odoorun doctor
128
+ odoorun --help
129
+ odoorun --version
130
+ ```
131
+
132
+ ## Development
133
+
134
+ ```bash
135
+ uv run python -m unittest discover -s tests -v
136
+ uv run python -m compileall -q src tests
137
+ ```
138
+
139
+ ## Publishing
140
+
141
+ The source repository is hosted on GitHub:
142
+
143
+ https://github.com/khalid99io/odoorun
144
+
145
+ To publish releases on PyPI, create a PyPI account, configure a trusted
146
+ publisher for this GitHub repository (recommended), build the package, and
147
+ upload it with `twine` or a GitHub Actions release workflow. A GitHub account
148
+ is required for the repository; a separate PyPI account is required to publish
149
+ the `odoorun` package name.
150
+
151
+ Before the first release, update the version in `pyproject.toml`, add release
152
+ notes, and verify the package in a clean virtual environment.
@@ -0,0 +1,129 @@
1
+ # odoorun
2
+
3
+ `odoorun` is a portable command-line launcher for Odoo. It finds an Odoo
4
+ executable from the current project (or a parent directory), prepares the
5
+ appropriate addons path, and forwards the remaining arguments unchanged.
6
+
7
+ It does not require symlinks, shell aliases, a modified `.bashrc`, or a
8
+ machine-specific configuration.
9
+
10
+ ## Requirements
11
+
12
+ - Python 3.10 or newer
13
+ - An Odoo installation or source checkout
14
+ - `psql` is optional and is used only for database completion/diagnostics
15
+
16
+ Odoo and PostgreSQL are external dependencies; `odoorun` does not install or
17
+ configure them.
18
+
19
+ ## Installation
20
+
21
+ ### Recommended: install from PyPI
22
+
23
+ Once published, install it on any supported machine with:
24
+
25
+ ```bash
26
+ uv tool install odoorun
27
+ ```
28
+
29
+ Or use pip:
30
+
31
+ ```bash
32
+ python -m pip install odoorun
33
+ ```
34
+
35
+ ### Install directly from GitHub
36
+
37
+ ```bash
38
+ uv tool install git+https://github.com/khalid99io/odoorun.git
39
+ ```
40
+
41
+ ### Install a local checkout (development)
42
+
43
+ ```bash
44
+ uv tool install .
45
+ ```
46
+
47
+ After changing the checkout, refresh the installed command:
48
+
49
+ ```bash
50
+ uv tool install --force .
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ Run from an Odoo source checkout or any directory below it:
56
+
57
+ ```bash
58
+ odoorun -d my_database --dev=all
59
+ ```
60
+
61
+ The short command `o` is optional and is not installed by this package. If
62
+ desired, add your own alias or shell function:
63
+
64
+ ```bash
65
+ alias o=odoorun
66
+ ```
67
+
68
+ ### Virtual-environment projects
69
+
70
+ For a project directory named `my-project`, `odoorun` looks for:
71
+
72
+ ```text
73
+ ~/venvs/my-project/bin/odoo
74
+ ```
75
+
76
+ When found, it adds `--addons=odoo/addons` automatically. If the virtual
77
+ environment has a different name, configure it without editing the package:
78
+
79
+ ```bash
80
+ export ODOORUN_VENV_NAME=my-project-venv
81
+ export ODOORUN_VENV_ROOT="$HOME/venvs"
82
+ ```
83
+
84
+ The tool invokes the venv executable directly; sourcing `activate` is not
85
+ required for the launched Odoo process. A subprocess cannot change the parent
86
+ shell's prompt, so prompt decoration remains a shell responsibility.
87
+
88
+ ### Odoo source checkouts
89
+
90
+ For a checkout containing an executable `odoo-bin`, the built-in `addons`
91
+ directory is passed automatically. Additional addon directories can be listed
92
+ with `-a` or `--addons-path`; relative paths are resolved from the checkout's
93
+ parent directory:
94
+
95
+ ```bash
96
+ odoorun -a custom-addons,enterprise -d my_database
97
+ ```
98
+
99
+ Each directory must exist. Missing directories are reported before Odoo starts.
100
+
101
+ ## Diagnostics
102
+
103
+ ```bash
104
+ odoorun doctor
105
+ odoorun --help
106
+ odoorun --version
107
+ ```
108
+
109
+ ## Development
110
+
111
+ ```bash
112
+ uv run python -m unittest discover -s tests -v
113
+ uv run python -m compileall -q src tests
114
+ ```
115
+
116
+ ## Publishing
117
+
118
+ The source repository is hosted on GitHub:
119
+
120
+ https://github.com/khalid99io/odoorun
121
+
122
+ To publish releases on PyPI, create a PyPI account, configure a trusted
123
+ publisher for this GitHub repository (recommended), build the package, and
124
+ upload it with `twine` or a GitHub Actions release workflow. A GitHub account
125
+ is required for the repository; a separate PyPI account is required to publish
126
+ the `odoorun` package name.
127
+
128
+ Before the first release, update the version in `pyproject.toml`, add release
129
+ notes, and verify the package in a clean virtual environment.
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "odoorun"
3
+ version = "0.1.1"
4
+ description = "A modern CLI for running and managing Odoo projects."
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [{ name = "Khalid A. Dev", email = "khalid.a.dev@gmail.com" }]
8
+ requires-python = ">=3.10"
9
+ keywords = ["cli", "odoo"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Environment :: Console",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ ]
19
+ dependencies = [
20
+ "rich>=15.0.0",
21
+ "typer>=0.26.8",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/khalid99io/odoorun"
26
+ Repository = "https://github.com/khalid99io/odoorun"
27
+ Issues = "https://github.com/khalid99io/odoorun/issues"
28
+
29
+ [project.scripts]
30
+ odoorun = "odoorun.__main__:main"
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.ruff]
37
+ line-length = 88
38
+ target-version = "py310"
39
+
40
+ [tool.ruff.lint]
41
+ select = ["B", "E", "F", "I", "UP"]
@@ -0,0 +1,8 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("odoorun")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["__version__"]
@@ -0,0 +1,81 @@
1
+ import sys
2
+
3
+ from rich.console import Console
4
+ from rich.markup import escape
5
+
6
+ from .cli import cli
7
+ from .discovery import OdooExecutableNotFoundError
8
+ from .runner import OdooArgumentError, OdooExecutionError, run_odoo
9
+
10
+ ODOORUN_ARGUMENTS = {
11
+ "--help",
12
+ "--version",
13
+ "-h",
14
+ "doctor",
15
+ }
16
+
17
+ error_console = Console(stderr=True)
18
+
19
+
20
+ def show_odoo_not_found(error: OdooExecutableNotFoundError) -> None:
21
+ error_console.print()
22
+ error_console.print("[bold red]Odoo executable not found[/bold red]")
23
+ error_console.print(
24
+ "odoorun looked for [bold]odoo-bin[/bold] in "
25
+ f"[cyan]{escape(str(error.directory))}[/cyan] or its parents, and "
26
+ "[bold]odoo[/bold] on your PATH."
27
+ )
28
+ error_console.print()
29
+ error_console.print("[bold]Try one of these:[/bold]")
30
+
31
+ if error.non_executable is not None:
32
+ candidate = escape(str(error.non_executable))
33
+ error_console.print(
34
+ f" • Make [cyan]{candidate}[/cyan] executable with "
35
+ f"[bold]chmod +x {candidate}[/bold]."
36
+ )
37
+ else:
38
+ error_console.print(
39
+ " • Run [bold]odoorun[/bold] from inside an Odoo source directory."
40
+ )
41
+
42
+ error_console.print(" • Install Odoo or add its executable to your PATH.")
43
+
44
+
45
+ def show_execution_error(error: OdooExecutionError) -> None:
46
+ error_console.print()
47
+ error_console.print("[bold red]Odoo could not be started[/bold red]")
48
+ error_console.print(
49
+ f"[cyan]{escape(error.executable)}[/cyan]: {escape(error.reason)}"
50
+ )
51
+ error_console.print("Check the file permissions and try again.")
52
+
53
+
54
+ def show_argument_error(error: OdooArgumentError) -> None:
55
+ error_console.print()
56
+ error_console.print("[bold red]Invalid Odoo addons configuration[/bold red]")
57
+ error_console.print(f"[yellow]{escape(str(error))}[/yellow]")
58
+
59
+
60
+ def main() -> None:
61
+ argv = sys.argv[1:]
62
+
63
+ if argv and argv[0] in ODOORUN_ARGUMENTS:
64
+ cli(args=argv, prog_name="odoorun")
65
+ return
66
+
67
+ try:
68
+ run_odoo(argv)
69
+ except OdooExecutableNotFoundError as error:
70
+ show_odoo_not_found(error)
71
+ raise SystemExit(1) from None
72
+ except OdooExecutionError as error:
73
+ show_execution_error(error)
74
+ raise SystemExit(1) from None
75
+ except OdooArgumentError as error:
76
+ show_argument_error(error)
77
+ raise SystemExit(2) from None
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
@@ -0,0 +1,88 @@
1
+ import shutil
2
+ from pathlib import Path
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.markup import escape
8
+ from rich.table import Table
9
+
10
+ from . import __version__
11
+ from .discovery import OdooExecutableNotFoundError, find_odoo_executable
12
+
13
+ cli = typer.Typer(
14
+ add_completion=False,
15
+ help="Run Odoo from any directory inside an Odoo project.",
16
+ no_args_is_help=True,
17
+ rich_markup_mode="rich",
18
+ )
19
+
20
+
21
+ def show_version(value: bool) -> None:
22
+ if value:
23
+ typer.echo(f"odoorun {__version__}")
24
+ raise typer.Exit()
25
+
26
+
27
+ @cli.callback()
28
+ def root(
29
+ version: Annotated[
30
+ bool,
31
+ typer.Option(
32
+ "--version",
33
+ callback=show_version,
34
+ is_eager=True,
35
+ help="Show the installed version and exit.",
36
+ ),
37
+ ] = False,
38
+ ) -> None:
39
+ """Inspect the environment or pass arguments directly to Odoo."""
40
+
41
+
42
+ @cli.command()
43
+ def doctor() -> None:
44
+ """Check whether odoorun can locate its external dependencies."""
45
+ console = Console()
46
+ current = Path.cwd().resolve()
47
+ psql = shutil.which("psql")
48
+
49
+ try:
50
+ executable = find_odoo_executable(current)
51
+ odoo_status = f"[green]found[/green] — {escape(executable)}"
52
+ healthy = True
53
+ except OdooExecutableNotFoundError as error:
54
+ if error.non_executable is not None:
55
+ detail = f"not executable — {escape(str(error.non_executable))}"
56
+ else:
57
+ detail = "not found"
58
+ odoo_status = f"[red]{detail}[/red]"
59
+ healthy = False
60
+
61
+ table = Table(title="odoorun doctor", show_header=False, box=None)
62
+ table.add_column(style="bold")
63
+ table.add_column()
64
+ table.add_row("Working directory", escape(str(current)))
65
+ table.add_row("Odoo", odoo_status)
66
+ table.add_row(
67
+ "PostgreSQL client",
68
+ (
69
+ f"[green]found[/green] — {escape(psql)}"
70
+ if psql
71
+ else "[yellow]not found[/yellow]"
72
+ ),
73
+ )
74
+ console.print(table)
75
+
76
+ if not healthy:
77
+ console.print(
78
+ "\n[red]Odoo is not ready.[/red] Run this command inside an Odoo "
79
+ "checkout or add [bold]odoo[/bold] to your PATH."
80
+ )
81
+ raise typer.Exit(1)
82
+
83
+ if psql is None:
84
+ console.print(
85
+ "\n[yellow]Odoo is ready, but database completion requires psql.[/yellow]"
86
+ )
87
+ else:
88
+ console.print("\n[green]Everything looks ready.[/green]")
File without changes
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+
5
+ QUERY_TIMEOUT_SECONDS = 2
6
+
7
+
8
+ def complete(prefix: str) -> list[str]:
9
+ """Return PostgreSQL databases matching ``prefix``.
10
+
11
+ Completion must stay responsive, so unavailable or slow PostgreSQL
12
+ connections are treated as having no suggestions.
13
+ """
14
+ command = [
15
+ "psql",
16
+ "-Atqc",
17
+ "SELECT datname FROM pg_database WHERE datistemplate = false;",
18
+ ]
19
+
20
+ try:
21
+ result = subprocess.run(
22
+ command,
23
+ capture_output=True,
24
+ text=True,
25
+ check=True,
26
+ timeout=QUERY_TIMEOUT_SECONDS,
27
+ )
28
+ except (subprocess.SubprocessError, OSError, UnicodeError):
29
+ return []
30
+
31
+ return sorted(
32
+ {
33
+ database
34
+ for database in result.stdout.splitlines()
35
+ if database and database.startswith(prefix)
36
+ }
37
+ )
@@ -0,0 +1,15 @@
1
+ from collections.abc import Callable
2
+
3
+ from . import database
4
+
5
+ Completer = Callable[[str], list[str]]
6
+
7
+ COMPLETERS: dict[str, Completer] = {
8
+ "database": database.complete,
9
+ }
10
+
11
+
12
+ def complete(kind: str, prefix: str) -> list[str]:
13
+ """Return completion candidates for a registered completion kind."""
14
+ completer = COMPLETERS.get(kind)
15
+ return completer(prefix) if completer is not None else []
@@ -0,0 +1,89 @@
1
+ import os
2
+ import shutil
3
+ from pathlib import Path
4
+
5
+
6
+ class OdooExecutableNotFoundError(RuntimeError):
7
+ """Raised when no runnable Odoo executable can be discovered."""
8
+
9
+ def __init__(
10
+ self,
11
+ directory: Path,
12
+ non_executable: Path | None = None,
13
+ ) -> None:
14
+ self.directory = directory
15
+ self.non_executable = non_executable
16
+ super().__init__("Could not locate an Odoo executable.")
17
+
18
+
19
+ def find_local_odoo_executable(
20
+ start_directory: Path,
21
+ ) -> tuple[Path | None, Path | None]:
22
+ """Find an executable ``odoo-bin`` in a directory or one of its parents.
23
+
24
+ The second item is the nearest non-executable candidate, when one exists.
25
+ """
26
+ non_executable: Path | None = None
27
+
28
+ for directory in (start_directory, *start_directory.parents):
29
+ candidate = directory / "odoo-bin"
30
+
31
+ if not candidate.is_file():
32
+ continue
33
+
34
+ if os.access(candidate, os.X_OK):
35
+ return candidate, non_executable
36
+
37
+ if non_executable is None:
38
+ non_executable = candidate
39
+
40
+ return None, non_executable
41
+
42
+
43
+ def _venv_names(project_name: str) -> tuple[str, ...]:
44
+ """Return configured and conventional virtual-environment names."""
45
+ configured = os.environ.get("ODOORUN_VENV_NAME", "").strip()
46
+ return tuple(dict.fromkeys(name for name in (configured, project_name) if name))
47
+
48
+
49
+ def find_venv_odoo_executable(start_directory: Path) -> str | None:
50
+ """Find ``odoo`` in the conventional ``~/venvs/<project>/bin`` location.
51
+
52
+ This is the process-local equivalent of sourcing an activation script:
53
+ invoking the executable from the venv gives Odoo the correct interpreter
54
+ and environment without attempting to modify the caller's shell.
55
+ """
56
+ venv_root = Path(
57
+ os.environ.get("ODOORUN_VENV_ROOT", str(Path.home() / "venvs"))
58
+ ).expanduser()
59
+ for directory in (start_directory, *start_directory.parents):
60
+ for name in _venv_names(directory.name):
61
+ candidate = venv_root / name / "bin" / "odoo"
62
+ if candidate.is_file() and os.access(candidate, os.X_OK):
63
+ return str(candidate)
64
+ return None
65
+
66
+
67
+ def find_odoo_executable(start_directory: Path | None = None) -> str:
68
+ """Return the best available Odoo executable.
69
+
70
+ A project-local ``odoo-bin`` takes precedence over an ``odoo`` command on
71
+ ``PATH``. Parent directories are searched so the command also works from
72
+ inside an add-on or another nested project directory.
73
+ """
74
+ current = (start_directory or Path.cwd()).resolve()
75
+ local_executable, non_executable = find_local_odoo_executable(current)
76
+
77
+ if local_executable is not None:
78
+ return str(local_executable)
79
+
80
+ venv_executable = find_venv_odoo_executable(current)
81
+ if venv_executable is not None:
82
+ return venv_executable
83
+
84
+ path_executable = shutil.which("odoo")
85
+
86
+ if path_executable is not None:
87
+ return path_executable
88
+
89
+ raise OdooExecutableNotFoundError(current, non_executable)
@@ -0,0 +1,88 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import NoReturn
4
+
5
+ from .discovery import find_odoo_executable
6
+
7
+
8
+ class OdooExecutionError(RuntimeError):
9
+ """Raised when a discovered Odoo executable cannot be started."""
10
+
11
+ def __init__(self, executable: str, reason: str) -> None:
12
+ self.executable = executable
13
+ self.reason = reason
14
+ super().__init__(f"Could not start {executable}: {reason}")
15
+
16
+
17
+ class OdooArgumentError(RuntimeError):
18
+ """Raised when odoorun cannot construct a valid Odoo command."""
19
+
20
+
21
+ def _is_venv_odoo(executable: str) -> bool:
22
+ """Identify an Odoo executable selected from the configured venv root."""
23
+ if Path(executable).name != "odoo":
24
+ return False
25
+ root = Path(
26
+ os.environ.get("ODOORUN_VENV_ROOT", str(Path.home() / "venvs"))
27
+ ).expanduser().resolve()
28
+ try:
29
+ relative = Path(executable).resolve().relative_to(root)
30
+ except ValueError:
31
+ return False
32
+ return len(relative.parts) == 3 and relative.parts[-2:] == ("bin", "odoo")
33
+
34
+
35
+ def _custom_addons(args: list[str]) -> tuple[list[str], list[str]]:
36
+ """Remove odoorun's ``-a`` option and return its comma-separated values."""
37
+ forwarded: list[str] = []
38
+ custom: list[str] = []
39
+ index = 0
40
+ while index < len(args):
41
+ value = args[index]
42
+ if value in {"-a", "--addons-path"}:
43
+ index += 1
44
+ if index == len(args) or not args[index].strip():
45
+ raise OdooArgumentError(f"{value} requires a comma-separated path list")
46
+ custom.extend(part.strip() for part in args[index].split(",") if part.strip())
47
+ elif value.startswith("-a=") or value.startswith("--addons-path="):
48
+ custom.extend(part.strip() for part in value.split("=", 1)[1].split(",") if part.strip())
49
+ else:
50
+ forwarded.append(value)
51
+ index += 1
52
+ return forwarded, custom
53
+
54
+
55
+ def build_odoo_args(executable: str, args: list[str]) -> list[str]:
56
+ """Add context-specific addon paths and validate custom addon folders."""
57
+ forwarded, custom = _custom_addons(args)
58
+ if Path(executable).name != "odoo-bin":
59
+ if not _is_venv_odoo(executable):
60
+ return forwarded
61
+ if not any(arg == "--addons" or arg.startswith("--addons=") for arg in forwarded):
62
+ forwarded.insert(0, "--addons=odoo/addons")
63
+ return forwarded
64
+
65
+ repo_root = Path(executable).parent
66
+ addons = repo_root / "addons"
67
+ if not addons.is_dir():
68
+ raise OdooArgumentError(f"Odoo addons directory not found: {addons}")
69
+ paths = [str(addons)]
70
+ for item in custom:
71
+ candidate = Path(item).expanduser()
72
+ if not candidate.is_absolute():
73
+ candidate = repo_root.parent / candidate
74
+ if not candidate.is_dir():
75
+ raise OdooArgumentError(f"Custom addons directory not found: {candidate}")
76
+ paths.append(str(candidate))
77
+ return ["--addons-path=" + ",".join(paths), *forwarded]
78
+
79
+
80
+ def run_odoo(args: list[str]) -> NoReturn:
81
+ """Replace the current process with Odoo and forward all arguments."""
82
+ executable = find_odoo_executable()
83
+ command = [executable, *build_odoo_args(executable, args)]
84
+
85
+ try:
86
+ os.execv(executable, command)
87
+ except OSError as error:
88
+ raise OdooExecutionError(executable, error.strerror or str(error)) from error
@@ -0,0 +1,39 @@
1
+ import unittest
2
+ from pathlib import Path
3
+ from unittest.mock import patch
4
+
5
+ from typer.testing import CliRunner
6
+
7
+ from odoorun.cli import cli
8
+ from odoorun.discovery import OdooExecutableNotFoundError
9
+
10
+
11
+ class CliTests(unittest.TestCase):
12
+ def setUp(self) -> None:
13
+ self.runner = CliRunner()
14
+
15
+ def test_version_option(self) -> None:
16
+ result = self.runner.invoke(cli, ["--version"])
17
+
18
+ self.assertEqual(result.exit_code, 0)
19
+ self.assertRegex(result.stdout, r"^odoorun \d+\.\d+\.\d+\s*$")
20
+
21
+ @patch("odoorun.cli.shutil.which", return_value=None)
22
+ @patch("odoorun.cli.find_odoo_executable")
23
+ def test_doctor_fails_cleanly_when_odoo_is_missing(
24
+ self,
25
+ find_executable,
26
+ _which,
27
+ ) -> None:
28
+ find_executable.side_effect = OdooExecutableNotFoundError(
29
+ Path("/tmp/example-project")
30
+ )
31
+
32
+ result = self.runner.invoke(cli, ["doctor"])
33
+
34
+ self.assertEqual(result.exit_code, 1)
35
+ self.assertIn("Odoo is not ready", result.stdout)
36
+
37
+
38
+ if __name__ == "__main__":
39
+ unittest.main()
@@ -0,0 +1,34 @@
1
+ import subprocess
2
+ import unittest
3
+ from unittest.mock import patch
4
+
5
+ from odoorun.completion import database
6
+ from odoorun.completion.engine import complete
7
+
8
+
9
+ class DatabaseCompletionTests(unittest.TestCase):
10
+ @patch("odoorun.completion.database.subprocess.run")
11
+ def test_returns_sorted_unique_prefix_matches(self, run) -> None:
12
+ run.return_value.stdout = "test_z\nproduction\ntest_a\ntest_a\n"
13
+
14
+ result = database.complete("test_")
15
+
16
+ self.assertEqual(result, ["test_a", "test_z"])
17
+ self.assertEqual(
18
+ run.call_args.kwargs["timeout"],
19
+ database.QUERY_TIMEOUT_SECONDS,
20
+ )
21
+
22
+ @patch(
23
+ "odoorun.completion.database.subprocess.run",
24
+ side_effect=subprocess.TimeoutExpired("psql", 2),
25
+ )
26
+ def test_returns_no_results_when_psql_times_out(self, _run) -> None:
27
+ self.assertEqual(database.complete(""), [])
28
+
29
+ def test_unknown_completion_kind_returns_no_results(self) -> None:
30
+ self.assertEqual(complete("unknown", ""), [])
31
+
32
+
33
+ if __name__ == "__main__":
34
+ unittest.main()
@@ -0,0 +1,94 @@
1
+ import os
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+
7
+ from odoorun.discovery import (
8
+ OdooExecutableNotFoundError,
9
+ find_odoo_executable,
10
+ )
11
+
12
+
13
+ class FindOdooExecutableTests(unittest.TestCase):
14
+ def make_executable(self, path: Path) -> None:
15
+ path.write_text("#!/bin/sh\n", encoding="utf-8")
16
+ path.chmod(0o755)
17
+
18
+ def test_finds_odoo_bin_in_parent_directory(self) -> None:
19
+ with tempfile.TemporaryDirectory() as temporary_directory:
20
+ root = Path(temporary_directory)
21
+ nested = root / "addons" / "custom"
22
+ nested.mkdir(parents=True)
23
+ executable = root / "odoo-bin"
24
+ self.make_executable(executable)
25
+
26
+ with patch.dict(os.environ, {"PATH": ""}):
27
+ result = find_odoo_executable(nested)
28
+
29
+ self.assertEqual(result, str(executable))
30
+
31
+ def test_local_odoo_bin_takes_precedence_over_path(self) -> None:
32
+ with tempfile.TemporaryDirectory() as temporary_directory:
33
+ root = Path(temporary_directory)
34
+ local_executable = root / "odoo-bin"
35
+ path_executable = root / "odoo"
36
+ self.make_executable(local_executable)
37
+ self.make_executable(path_executable)
38
+
39
+ with patch.dict(os.environ, {"PATH": str(root)}):
40
+ result = find_odoo_executable(root)
41
+
42
+ self.assertEqual(result, str(local_executable))
43
+
44
+ def test_falls_back_to_odoo_on_path(self) -> None:
45
+ with tempfile.TemporaryDirectory() as temporary_directory:
46
+ root = Path(temporary_directory)
47
+ executable = root / "odoo"
48
+ self.make_executable(executable)
49
+
50
+ with patch.dict(os.environ, {"PATH": str(root)}):
51
+ result = find_odoo_executable(root)
52
+
53
+ self.assertEqual(result, str(executable))
54
+
55
+ def test_finds_odoo_from_project_virtual_environment(self) -> None:
56
+ with tempfile.TemporaryDirectory() as temporary_directory:
57
+ home = Path(temporary_directory)
58
+ project = home / "PycharmProjects" / "obusiness-2"
59
+ project.mkdir(parents=True)
60
+ executable = home / "venvs" / "obusiness-v2" / "bin" / "odoo"
61
+ executable.parent.mkdir(parents=True)
62
+ self.make_executable(executable)
63
+
64
+ with patch.dict(
65
+ os.environ,
66
+ {
67
+ "PATH": "",
68
+ "HOME": str(home),
69
+ "ODOORUN_VENV_NAME": "obusiness-v2",
70
+ },
71
+ ):
72
+ result = find_odoo_executable(project)
73
+
74
+ self.assertEqual(result, str(executable))
75
+
76
+ def test_reports_nearest_non_executable_odoo_bin(self) -> None:
77
+ with tempfile.TemporaryDirectory() as temporary_directory:
78
+ root = Path(temporary_directory)
79
+ nested = root / "addons"
80
+ nested.mkdir()
81
+ candidate = root / "odoo-bin"
82
+ candidate.write_text("#!/bin/sh\n", encoding="utf-8")
83
+ candidate.chmod(0o644)
84
+
85
+ with patch.dict(os.environ, {"PATH": ""}):
86
+ with self.assertRaises(OdooExecutableNotFoundError) as context:
87
+ find_odoo_executable(nested)
88
+
89
+ self.assertEqual(context.exception.directory, nested.resolve())
90
+ self.assertEqual(context.exception.non_executable, candidate)
91
+
92
+
93
+ if __name__ == "__main__":
94
+ unittest.main()
@@ -0,0 +1,60 @@
1
+ import unittest
2
+ from unittest.mock import patch
3
+
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from odoorun.runner import OdooArgumentError, OdooExecutionError, build_odoo_args, run_odoo
8
+
9
+
10
+ class RunOdooTests(unittest.TestCase):
11
+ def test_adds_default_addons_for_venv_odoo(self) -> None:
12
+ self.assertEqual(
13
+ build_odoo_args("/home/khalid/venvs/obusiness-v2/bin/odoo", ["-d", "demo"]),
14
+ ["--addons=odoo/addons", "-d", "demo"],
15
+ )
16
+
17
+ def test_builds_and_validates_source_addons_paths(self) -> None:
18
+ with tempfile.TemporaryDirectory() as temporary_directory:
19
+ repo = Path(temporary_directory) / "odoo"
20
+ (repo / "addons").mkdir(parents=True)
21
+ (repo.parent / "custom-addons").mkdir()
22
+ result = build_odoo_args(
23
+ str(repo / "odoo-bin"), ["-a", "custom-addons", "-d", "demo"]
24
+ )
25
+ self.assertEqual(result[0], f"--addons-path={repo / 'addons'},{repo.parent / 'custom-addons'}")
26
+ self.assertEqual(result[1:], ["-d", "demo"])
27
+
28
+ def test_rejects_missing_custom_addons_path(self) -> None:
29
+ with tempfile.TemporaryDirectory() as temporary_directory:
30
+ repo = Path(temporary_directory) / "odoo"
31
+ (repo / "addons").mkdir(parents=True)
32
+ with self.assertRaises(OdooArgumentError):
33
+ build_odoo_args(str(repo / "odoo-bin"), ["-a", "missing"])
34
+
35
+ @patch("odoorun.runner.os.execv")
36
+ @patch("odoorun.runner.find_odoo_executable", return_value="/home/khalid/venvs/demo/bin/odoo")
37
+ def test_forwards_arguments_unchanged(self, find_executable, execv) -> None:
38
+ run_odoo(["--database", "example", "--dev=all"])
39
+
40
+ find_executable.assert_called_once_with()
41
+ execv.assert_called_once_with(
42
+ "/home/khalid/venvs/demo/bin/odoo",
43
+ ["/home/khalid/venvs/demo/bin/odoo", "--addons=odoo/addons", "--database", "example", "--dev=all"],
44
+ )
45
+
46
+ @patch(
47
+ "odoorun.runner.os.execv",
48
+ side_effect=PermissionError(13, "Permission denied"),
49
+ )
50
+ @patch("odoorun.runner.find_odoo_executable", return_value="/home/khalid/venvs/demo/bin/odoo")
51
+ def test_wraps_operating_system_errors(self, _find_executable, _execv) -> None:
52
+ with self.assertRaises(OdooExecutionError) as context:
53
+ run_odoo([])
54
+
55
+ self.assertEqual(context.exception.executable, "/home/khalid/venvs/demo/bin/odoo")
56
+ self.assertEqual(context.exception.reason, "Permission denied")
57
+
58
+
59
+ if __name__ == "__main__":
60
+ unittest.main()
odoorun-0.1.1/uv.lock ADDED
@@ -0,0 +1,103 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "annotated-doc"
7
+ version = "0.0.4"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "colorama"
16
+ version = "0.4.6"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
19
+ wheels = [
20
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
21
+ ]
22
+
23
+ [[package]]
24
+ name = "markdown-it-py"
25
+ version = "4.2.0"
26
+ source = { registry = "https://pypi.org/simple" }
27
+ dependencies = [
28
+ { name = "mdurl" },
29
+ ]
30
+ sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
31
+ wheels = [
32
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
33
+ ]
34
+
35
+ [[package]]
36
+ name = "mdurl"
37
+ version = "0.1.2"
38
+ source = { registry = "https://pypi.org/simple" }
39
+ sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
40
+ wheels = [
41
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
42
+ ]
43
+
44
+ [[package]]
45
+ name = "odoorun"
46
+ version = "0.1.0"
47
+ source = { editable = "." }
48
+ dependencies = [
49
+ { name = "rich" },
50
+ { name = "typer" },
51
+ ]
52
+
53
+ [package.metadata]
54
+ requires-dist = [
55
+ { name = "rich", specifier = ">=15.0.0" },
56
+ { name = "typer", specifier = ">=0.26.8" },
57
+ ]
58
+
59
+ [[package]]
60
+ name = "pygments"
61
+ version = "2.20.0"
62
+ source = { registry = "https://pypi.org/simple" }
63
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
64
+ wheels = [
65
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
66
+ ]
67
+
68
+ [[package]]
69
+ name = "rich"
70
+ version = "15.0.0"
71
+ source = { registry = "https://pypi.org/simple" }
72
+ dependencies = [
73
+ { name = "markdown-it-py" },
74
+ { name = "pygments" },
75
+ ]
76
+ sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
77
+ wheels = [
78
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
79
+ ]
80
+
81
+ [[package]]
82
+ name = "shellingham"
83
+ version = "1.5.4"
84
+ source = { registry = "https://pypi.org/simple" }
85
+ sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
86
+ wheels = [
87
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
88
+ ]
89
+
90
+ [[package]]
91
+ name = "typer"
92
+ version = "0.26.8"
93
+ source = { registry = "https://pypi.org/simple" }
94
+ dependencies = [
95
+ { name = "annotated-doc" },
96
+ { name = "colorama", marker = "sys_platform == 'win32'" },
97
+ { name = "rich" },
98
+ { name = "shellingham" },
99
+ ]
100
+ sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" }
101
+ wheels = [
102
+ { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" },
103
+ ]