Skip to content

Commit 2649740

Browse files
committed
feat: Implement advanced version resolver and testing suite
1 parent 271b538 commit 2649740

7 files changed

Lines changed: 1144 additions & 188 deletions

File tree

omnipkg/common_utils.py

Lines changed: 105 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -75,53 +75,50 @@ def extract_required_dependency(self, stderr_output: str) -> Optional[str]:
7575

7676
def sync_context_to_runtime():
7777
"""
78-
Ensures omnipkg's active context matches the currently running Python interpreter.
79-
80-
This is a critical pre-flight check to prevent state mismatches between the
81-
runtime environment and the omnipkg configuration. It uses the CLI for robust
82-
switching logic.
83-
84-
Returns:
85-
bool: True if the context is synchronized, False otherwise.
78+
Ensures omnipkg's active context matches the currently running Python interpreter
79+
by using the omnipkg API directly. This is the robust method for post-relaunch
80+
synchronization, avoiding the state conflicts of CLI subprocesses.
8681
"""
87-
current_python_version = f'{sys.version_info.major}.{sys.version_info.minor}'
88-
82+
print(_('🔄 Forcing omnipkg context to match script Python version: {}...').format(
83+
f'{sys.version_info.major}.{sys.version_info.minor}'
84+
))
8985
try:
90-
config_manager = ConfigManager()
91-
active_config_version = config_manager.config.get('active_python_version')
92-
93-
# If the config already matches the runtime, we don't need to do anything.
94-
if active_config_version == current_python_version:
95-
# This is a minor optimization to avoid a subprocess call if not needed.
96-
return True
97-
98-
# If a change is needed, use the robust CLI which contains all the necessary logic.
99-
print(_('🔄 Forcing omnipkg context to match script Python version: {}...').format(current_python_version))
100-
omnipkg_cmd_base = [sys.executable, '-m', 'omnipkg.cli']
101-
102-
# The 'swap' command is the source of truth for this operation.
103-
result = subprocess.run(
104-
omnipkg_cmd_base + ['swap', 'python', current_python_version],
105-
capture_output=True, text=True, check=True
106-
)
86+
from omnipkg.core import ConfigManager
10787

108-
print(_('✅ omnipkg context synchronized to Python {}').format(current_python_version))
109-
return True
110-
111-
except subprocess.CalledProcessError as e:
112-
# This block catches errors if the 'swap' command fails.
113-
print(_('⚠️ Could not synchronize omnipkg context via CLI: {}').format(e))
114-
print(_(' CLI output: {}').format(e.stdout))
115-
print(_(' CLI error: {}').format(e.stderr))
116-
return False
88+
# Suppress messages here to avoid the "new environment" prompt if it occurs
89+
config_manager = ConfigManager(suppress_init_messages=True)
11790

91+
current_executable = str(Path(sys.executable).resolve())
92+
93+
# Optimization: If the config is already correct, do nothing.
94+
if config_manager.config.get('python_executable') == current_executable:
95+
print(_('✅ Context is already synchronized.'))
96+
return
97+
98+
# Use the ConfigManager's internal method to get the correct paths for the CURRENT interpreter.
99+
new_paths = config_manager._get_paths_for_interpreter(current_executable)
100+
if not new_paths:
101+
raise RuntimeError(f"Could not determine paths for the current interpreter: {current_executable}")
102+
103+
# Directly update and save the configuration. This is what 'swap' does internally.
104+
print(_(' - Aligning configuration to the new runtime...'))
105+
config_manager.set('python_executable', new_paths['python_executable'])
106+
config_manager.set('site_packages_path', new_paths['site_packages_path'])
107+
config_manager.set('multiversion_base', new_paths['multiversion_base'])
108+
109+
# Also update the default python symlinks to reflect the change.
110+
config_manager._update_default_python_links(config_manager.venv_path, Path(current_executable))
111+
112+
print(_('✅ omnipkg context synchronized successfully via API.'))
113+
return
114+
118115
except Exception as e:
119-
# This is a general catch-all for other unexpected errors.
120-
print(_('⚠️ Unexpected error synchronizing omnipkg context: {}').format(e))
116+
print(_('❌ A critical error occurred during context synchronization: {}').format(e))
121117
import traceback
122118
traceback.print_exc()
123-
return False
124-
119+
# Exit because a failed sync is a fatal error for the script's logic.
120+
sys.exit(1)
121+
125122
def run_script_in_omnipkg_env(command_list, streaming_title):
126123
"""
127124
A centralized utility to run a command in a fully configured omnipkg environment.
@@ -194,64 +191,98 @@ def print_header(title):
194191
def ensure_python_or_relaunch(required_version: str):
195192
"""
196193
A generic utility to ensure the script is running on a specific Python version.
197-
198-
If the current interpreter does not match, it uses omnipkg to switch
199-
to the required version and then re-launches the original script in a new
200-
process. The original process is then terminated.
201-
202-
Args:
203-
required_version (str): The required version string (e.g., "3.11").
194+
This version uses a direct, non-mutating approach to find the target
195+
interpreter and relaunch, avoiding hangs from interactive subprocesses.
204196
"""
205197
major, minor = map(int, required_version.split('.'))
206198
if sys.version_info[:2] == (major, minor):
207199
return # Correct version, do nothing
208200

209-
# If we get here, we need to switch and relaunch.
210201
print('\n' + '=' * 80)
211202
print(_(' 🚀 AUTOMATIC ENVIRONMENT CORRECTION'))
212203
print('=' * 80)
213204
print(_(' This script requires Python {}').format(required_version))
214205
print(_(' Currently running on: Python {}.{}').format(sys.version_info.major, sys.version_info.minor))
215-
print(_(' Attempting to automatically switch using omnipkg...'))
216-
206+
print(_(' Attempting to find and relaunch with the correct interpreter...'))
207+
217208
try:
209+
# Step 1: Capture the parent environment's identity. This is critical.
210+
from omnipkg.core import ConfigManager
211+
print(_(' -> Capturing parent environment identity...'))
212+
parent_cm = ConfigManager(suppress_init_messages=True)
213+
parent_env_id = parent_cm.env_id
214+
parent_venv_root = str(parent_cm.venv_path.resolve())
215+
print(_(' -> Parent env_id: {}').format(parent_env_id))
216+
218217
omnipkg_cmd_base = [sys.executable, '-m', 'omnipkg.cli']
219-
220-
# Step 1: Adopt the required version
221-
subprocess.run(omnipkg_cmd_base + ['python', 'adopt', required_version], check=True, capture_output=True)
222-
223-
# Step 2: Swap to the required version
224-
subprocess.run(omnipkg_cmd_base + ['swap', 'python', required_version], check=True, capture_output=True)
225-
226-
# Step 3: Find the new executable path
227-
info_result = subprocess.run(omnipkg_cmd_base + ['info', 'python'], check=True, capture_output=True, text=True)
228-
new_python_exe = None
229-
for line in info_result.stdout.splitlines():
230-
if '⭐ (currently active)' in line:
231-
match = re.search(r':\s*(/\S+)', line)
232-
if match:
233-
new_python_exe = match.group(1).strip()
234-
break
235-
218+
219+
def find_interpreter_path(version_str):
220+
"""
221+
Helper to find the executable path for a given version using a more
222+
robust parsing method.
223+
"""
224+
try:
225+
# Use 'info python' as it's a reliable source of this data.
226+
info_result = subprocess.run(
227+
omnipkg_cmd_base + ['info', 'python'],
228+
check=True, capture_output=True, text=True, timeout=30
229+
)
230+
# This regex looks for an absolute path starting with '/' or a drive letter 'C:\'
231+
# on a line that contains the required version string.
232+
path_regex = re.compile(r"(/[^\s]+|[\w]:\\[^\s]+)")
233+
234+
for line in info_result.stdout.splitlines():
235+
if f'Python {version_str}' in line:
236+
match = path_regex.search(line)
237+
if match:
238+
# Return the first absolute path found on the correct line.
239+
return match.group(0).strip()
240+
return None
241+
except Exception:
242+
return None
243+
244+
# Step 2: Find the path to the required Python executable.
245+
print(_(' -> Querying omnipkg for Python {} executable path...').format(required_version))
246+
new_python_exe = find_interpreter_path(required_version)
247+
248+
# Step 2b: If not found, adopt it first, then find it again.
236249
if not new_python_exe:
237-
raise RuntimeError(_("Could not find the new Python {} executable after swapping.").format(required_version))
250+
print(_(' -> Python {} is not yet managed. Adopting it now...').format(required_version))
251+
subprocess.run(
252+
omnipkg_cmd_base + ['python', 'adopt', required_version],
253+
check=True, capture_output=True, timeout=300
254+
)
255+
# After adopting, we MUST try to find the path again.
256+
new_python_exe = find_interpreter_path(required_version)
257+
258+
if not new_python_exe or not Path(new_python_exe).exists():
259+
raise RuntimeError(_("Could not find a valid Python {} executable path after checking and adopting.").format(required_version))
238260

239261
print(_(' ✅ Found Python {} at: {}').format(required_version, new_python_exe))
240-
print(_('\n STEP 4: Relaunching script with the correct interpreter...'))
241-
242-
# Relaunch the original script
262+
print(_('\n -> Relaunching script with the correct interpreter...'))
263+
264+
# Step 3: Relaunch the original script, passing the parent's identity.
243265
original_script = os.path.abspath(sys.argv[0])
244266
script_args = sys.argv[1:]
245-
246-
# Use os.execv to replace the current process. This is cleaner than a wrapper.
267+
247268
new_env = os.environ.copy()
248269
new_env['OMNIPKG_RELAUNCHED'] = '1'
270+
new_env['OMNIPKG_ENV_ID_OVERRIDE'] = parent_env_id
271+
new_env['OMNIPKG_VENV_ROOT'] = parent_venv_root
272+
249273
os.execve(new_python_exe, [new_python_exe, original_script] + script_args, new_env)
250274

251275
except Exception as e:
252276
print('\n' + '-' * 80)
253277
print(' ❌ An error occurred while trying to switch Python versions.')
254-
# ... (your existing error handling) ...
278+
if isinstance(e, subprocess.CalledProcessError):
279+
print(' -> Subprocess Error Output:')
280+
print(e.stderr.decode('utf-8') if e.stderr else e.stdout.decode('utf-8'))
281+
else:
282+
print(f' -> Error: {e}')
283+
import traceback
284+
traceback.print_exc()
285+
print('-' * 80)
255286
sys.exit(1)
256287

257288
def run_interactive_command(command_list, input_data, check=True):

0 commit comments

Comments
 (0)