Skip to content

Commit 5432ae3

Browse files
feat: add extra_commands support to presets with odoo_version markers
Add ability to run extra commands at specific stages during venv creation based on odoo_version marker conditions. Features: - New 'extra_commands' field in presets.toml with 'when', 'stage', and 'env' keys - Custom odoo_version marker evaluation (<=13.0, >=14.0, etc.) - Support for environment variables in commands - Verbose output showing condition and environment for each command - Three execution stages: after_venv, after_requirements, after_odoo_install Example usage: [[common.extra_commands]] command = ["uv", "pip", "install", "setuptools<58.0", "wheel"] when = "odoo_version <= '13.0'" stage = "after_requirements" [[common.extra_commands]] command = ["uv", "pip", "install", "vatnumber==1.2"] when = "odoo_version <= '13.0'" stage = "after_requirements" env = { UV_NO_BUILD_ISOLATION = "1" } Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9fd3b30 commit 5432ae3

4 files changed

Lines changed: 258 additions & 25 deletions

File tree

odoo_venv/assets/presets.toml

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,29 @@
3333
# extra_requirement (string):
3434
# Comma-separated list of extra packages to install.
3535
#
36+
# extra_commands (array of tables):
37+
# Additional commands to run at specific points during venv creation.
38+
# Each command can have a 'when' marker to conditionally run based on odoo_version.
39+
# Supported stages: 'after_venv', 'after_requirements', 'after_odoo_install'
40+
#
41+
# Example:
42+
# [[common.extra_commands]]
43+
# command = ["uv", "pip", "install", "setuptools<58.0"]
44+
# when = "odoo_version <= '13.0'"
45+
# stage = "after_venv"
46+
#
47+
# You can also set environment variables for the command:
48+
# [[common.extra_commands]]
49+
# command = ["uv", "pip", "install", "vatnumber==1.2"]
50+
# when = "odoo_version <= '13.0'"
51+
# stage = "after_venv"
52+
# env = { UV_NO_BUILD_ISOLATION = "1" }
53+
#
54+
# Available markers in 'when' expressions:
55+
# - odoo_version: The Odoo version (e.g., '13.0', '14.0')
56+
# - python_version: The Python version (e.g., '3.7', '3.10')
57+
# - All standard packaging markers (sys_platform, etc.)
58+
#
3659

3760
[common]
3861

@@ -46,7 +69,8 @@ common configuration to all presets
4669

4770
ignore_from_odoo_requirements = """
4871
gevent==21.8.0; sys_platform != 'win32' and python_version == '3.10',
49-
greenlet==1.1.2; sys_platform != 'win32' and python_version == '3.10'
72+
greenlet==1.1.2; sys_platform != 'win32' and python_version == '3.10',
73+
vatnumber
5074
"""
5175
extra_requirement = """
5276
gevent==22.10.2; sys_platform != 'win32' and python_version == '3.10',
@@ -56,6 +80,19 @@ greenlet==2.0.2; sys_platform != 'win32' and python_version == '3.10'
5680
# always ignore those exotic requirements unless explicitely added
5781
ignore_from_addons_dirs_requirements = "azure-identity,mysql,mysqlclient,pymssql,cn2an"
5882

83+
# Install setuptools<58.0 for Odoo <= 13.0 (setuptools 58 removed 2to3 support)
84+
[[common.extra_commands]]
85+
command = ["uv", "pip", "install", "setuptools<58.0", "wheel"]
86+
when = "odoo_version <= '13.0'"
87+
stage = "after_requirements"
88+
89+
# Install vatnumber==1.2 for Odoo <= 13.0 with build isolation disabled
90+
[[common.extra_commands]]
91+
command = ["uv", "pip", "install", "vatnumber==1.2"]
92+
when = "odoo_version <= '13.0'"
93+
stage = "after_requirements"
94+
env = { UV_NO_BUILD_ISOLATION = "1" }
95+
5996
[local]
6097

6198
description = """

odoo_venv/cli/main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ def preset_callback(ctx: typer.Context, param: typer.CallbackParam, value: str):
4040

4141
ctx.default_map = ctx.default_map or {}
4242
ctx.default_map.update(preset_options)
43+
# Store extra_commands on ctx.obj (not a CLI option, so default_map won't forward it)
44+
ctx.ensure_object(dict)["extra_commands"] = preset_options.get("extra_commands")
4345
if descr := preset_options["description"]:
4446
typer.secho(f"Applying preset '{value}': {descr}", fg=typer.colors.GREEN)
4547
return value
@@ -167,6 +169,9 @@ def create(
167169
[str(Path(p.strip()).expanduser().resolve()) for p in addons_path.split(",")] if addons_path else None
168170
)
169171

172+
# Get extra_commands from preset if available
173+
extra_commands = ctx.obj.get("extra_commands") if ctx.obj else None
174+
170175
create_odoo_venv(
171176
odoo_version=odoo_version,
172177
odoo_dir=str(odoo_dir_path),
@@ -182,6 +187,7 @@ def create(
182187
ignore_from_addons_manifests_requirements=ignore_from_addons_manifests_requirements,
183188
extra_requirements_file=extra_requirements_file,
184189
extra_requirements=extra_requirements_list,
190+
extra_commands=extra_commands,
185191
verbose=verbose,
186192
dry_run=dry_run,
187193
)

odoo_venv/main.py

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import ast
2+
import operator
23
import os
34
import re
45
import subprocess
@@ -8,12 +9,135 @@
89
from pathlib import Path
910

1011
import typer
11-
from packaging.markers import default_environment
12+
from packaging.markers import Marker, default_environment
1213
from packaging.requirements import InvalidRequirement, Requirement
1314
from packaging.version import parse as parse_version
1415

1516
PKG_NAME_PATTERN = re.compile(r"(?P<lib_name>[a-z0-9A-Z\-\_\.]+)((>|<|=)=)?(.*)")
1617

18+
_COMPARISON_OPS = {
19+
"<": operator.lt,
20+
"<=": operator.le,
21+
">": operator.gt,
22+
">=": operator.ge,
23+
"==": operator.eq,
24+
"!=": operator.ne,
25+
}
26+
27+
28+
def _evaluate_marker(
29+
marker_expr: str,
30+
odoo_version: str,
31+
python_version: str | None,
32+
) -> bool:
33+
"""Evaluate a marker expression, supporting custom variable ``odoo_version``.
34+
35+
For pure PEP 508 expressions, delegates to ``packaging.markers.Marker``.
36+
For expressions containing ``odoo_version``, uses a lightweight custom
37+
evaluator that supports version comparisons and boolean ``and``/``or``.
38+
"""
39+
if not marker_expr:
40+
return True
41+
42+
env: dict[str, str] = {**default_environment()}
43+
if python_version:
44+
env["python_version"] = ".".join(python_version.split(".")[:2])
45+
env["python_full_version"] = python_version
46+
47+
if "odoo_version" not in marker_expr:
48+
try:
49+
return Marker(marker_expr).evaluate(environment=env)
50+
except Exception:
51+
return False
52+
53+
env["odoo_version"] = odoo_version
54+
return _evaluate_version_expr(marker_expr, env)
55+
56+
57+
def _evaluate_version_expr(marker_expr: str, variables: dict[str, str]) -> bool:
58+
"""Evaluate a marker expression with version comparisons.
59+
60+
Handles ``or`` (lower precedence) and ``and`` (higher precedence) boolean
61+
operators, and ``<``, ``<=``, ``>``, ``>=``, ``==``, ``!=`` on version-like
62+
values looked up from *variables*.
63+
"""
64+
expr = marker_expr.strip()
65+
66+
# Split on 'or' first (lower precedence = outermost split)
67+
if " or " in expr:
68+
return any(_evaluate_version_expr(p.strip(), variables) for p in expr.split(" or "))
69+
70+
if " and " in expr:
71+
return all(_evaluate_version_expr(p.strip(), variables) for p in expr.split(" and "))
72+
73+
# Parse: variable OP 'value'
74+
match = re.match(r"(\w+)\s*(<=|>=|<|>|==|!=)\s*['\"]([^'\"]+)['\"]", expr)
75+
if not match:
76+
return False
77+
78+
var_name, op_str, compare_value = match.groups()
79+
actual_value = variables.get(var_name)
80+
if actual_value is None:
81+
return False
82+
83+
try:
84+
return _COMPARISON_OPS[op_str](parse_version(actual_value), parse_version(compare_value))
85+
except Exception:
86+
return _COMPARISON_OPS[op_str](actual_value, compare_value)
87+
88+
89+
def _run_commands_for_stage(
90+
stage: str,
91+
extra_commands: list[dict] | None,
92+
odoo_version: str,
93+
python_version: str | None,
94+
venv_dir: Path,
95+
verbose: bool,
96+
dry_run: bool,
97+
):
98+
"""Run extra commands for a specific stage.
99+
100+
Args:
101+
stage: The stage to run commands for (e.g., 'after_venv', 'after_requirements')
102+
extra_commands: List of command dicts with 'command', 'when', 'stage', and optionally 'env' keys
103+
odoo_version: The Odoo version
104+
python_version: The Python version
105+
venv_dir: The virtual environment directory
106+
verbose: Whether to print verbose output
107+
dry_run: Whether to do a dry run
108+
"""
109+
if not extra_commands:
110+
return
111+
112+
for cmd_spec in extra_commands:
113+
cmd_stage = cmd_spec.get("stage")
114+
if cmd_stage != stage:
115+
continue
116+
117+
# Check if the 'when' marker evaluates to True
118+
when_marker = cmd_spec.get("when", "")
119+
if not _evaluate_marker(when_marker, odoo_version, python_version):
120+
continue
121+
122+
command = cmd_spec.get("command")
123+
if not command or not isinstance(command, list):
124+
continue
125+
126+
extra_env = cmd_spec.get("env")
127+
if extra_env and isinstance(extra_env, dict):
128+
# Convert values to strings
129+
extra_env = {k: str(v) for k, v in extra_env.items()}
130+
131+
if verbose:
132+
typer.secho(f"\n 📋 Running extra command (stage: {stage})", fg=typer.colors.CYAN)
133+
if when_marker:
134+
typer.secho(f" Condition: {when_marker}", fg=typer.colors.CYAN, dim=True)
135+
if extra_env:
136+
env_str = " ".join(f"{k}={v}" for k, v in extra_env.items())
137+
typer.secho(f" Environment: {env_str}", fg=typer.colors.CYAN, dim=True)
138+
139+
_run_command(command, venv_dir=venv_dir, verbose=verbose, dry_run=dry_run, extra_env=extra_env)
140+
17141

18142
def _keep_if_marker_matches(req_line: str, env: dict | None = None) -> str | None:
19143
req_line = req_line.split("#")[0].strip()
@@ -35,6 +159,7 @@ def _run_command(
35159
cwd: Path | None = None,
36160
verbose: bool = False,
37161
dry_run: bool = False,
162+
extra_env: dict[str, str] | None = None,
38163
):
39164
if verbose:
40165
typer.secho(f" → Running: {' '.join(command)}", fg=typer.colors.BLUE)
@@ -46,6 +171,8 @@ def _run_command(
46171
if venv_dir:
47172
env["PATH"] = str(venv_dir / "bin") + os.pathsep + env["PATH"]
48173
env["VIRTUAL_ENV"] = str(venv_dir)
174+
if extra_env:
175+
env.update(extra_env)
49176

50177
# safe to ignore S603 as shell=False
51178
result = subprocess.run( # noqa: S603
@@ -138,6 +265,7 @@ def create_odoo_venv( # noqa: C901
138265
ignore_from_addons_manifests_requirements: str | None = None,
139266
extra_requirements_file: str | None = None,
140267
extra_requirements: list[str] | None = None,
268+
extra_commands: list[dict] | None = None,
141269
verbose: bool = False,
142270
dry_run: bool = False,
143271
):
@@ -182,6 +310,17 @@ def create_odoo_venv( # noqa: C901
182310
f" ✔ Virtual environment created at {typer.style(str(venv_dir), fg=typer.colors.YELLOW)}",
183311
)
184312

313+
# Run extra commands for 'after_venv' stage
314+
_run_commands_for_stage(
315+
"after_venv",
316+
extra_commands,
317+
odoo_version,
318+
python_version,
319+
venv_dir,
320+
verbose,
321+
dry_run,
322+
)
323+
185324
# 3. Install requirements
186325
all_req_files = []
187326
if install_odoo_requirements:
@@ -290,6 +429,17 @@ def create_odoo_venv( # noqa: C901
290429

291430
os.remove(tmp_path)
292431

432+
# Run extra commands for 'after_requirements' stage
433+
_run_commands_for_stage(
434+
"after_requirements",
435+
extra_commands,
436+
odoo_version,
437+
python_version,
438+
venv_dir,
439+
verbose,
440+
dry_run,
441+
)
442+
293443
# 4. Install Odoo in editable mode
294444
if install_odoo:
295445
typer.secho("\nInstalling Odoo in editable mode...")
@@ -303,6 +453,17 @@ def create_odoo_venv( # noqa: C901
303453
" ✔ Installed Odoo in editable mode",
304454
)
305455

456+
# Run extra commands for 'after_odoo_install' stage
457+
_run_commands_for_stage(
458+
"after_odoo_install",
459+
extra_commands,
460+
odoo_version,
461+
python_version,
462+
venv_dir,
463+
verbose,
464+
dry_run,
465+
)
466+
306467
typer.secho("\n✅ Environment setup complete!", fg=typer.colors.GREEN)
307468
typer.secho(
308469
f"Activate it with: source {typer.style(str(venv_dir / 'bin' / 'activate'), fg=typer.colors.YELLOW)}",

0 commit comments

Comments
 (0)