11import ast
2+ import operator
23import os
34import re
45import subprocess
89from pathlib import Path
910
1011import typer
11- from packaging .markers import default_environment
12+ from packaging .markers import Marker , default_environment
1213from packaging .requirements import InvalidRequirement , Requirement
1314from packaging .version import parse as parse_version
1415
1516PKG_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
18142def _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 ("\n Installing 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