@@ -402,31 +402,6 @@ def _align_config_to_interpreter(self, python_exe_path_str: str):
402402 self .multiversion_base = Path (self .config ['multiversion_base' ])
403403 print (_ (' ✅ Configuration updated and saved successfully.' ))
404404
405- def _get_venv_root (self ) -> Path :
406- """
407- Reliably finds the root of the virtual environment with a strict priority order.
408- """
409- # PRIORITY 1: An override from a relaunch is the absolute source of truth.
410- venv_root_override = os .environ .get ('OMNIPKG_VENV_ROOT' )
411- if venv_root_override :
412- return Path (venv_root_override )
413-
414- # PRIORITY 2: VIRTUAL_ENV is the standard for venv/virtualenv.
415- # This is the critical fix for your Conda vs. venv conflict.
416- virtual_env_path = os .environ .get ('VIRTUAL_ENV' )
417- if virtual_env_path and Path (virtual_env_path ).exists ():
418- return Path (virtual_env_path )
419-
420- # PRIORITY 3: Upward search for pyvenv.cfg is the next best for standard venvs.
421- current_dir = Path (sys .executable ).resolve ().parent
422- while current_dir != current_dir .parent :
423- if (current_dir / 'pyvenv.cfg' ).exists ():
424- return current_dir
425- current_dir = current_dir .parent
426-
427- # PRIORITY 4: Fallback for Conda and other non-standard environments.
428- return Path (sys .prefix )
429-
430405 def _setup_native_311_environment (self ):
431406 """
432407 Performs the one-time setup for an environment that already has Python 3.11.
@@ -3952,272 +3927,6 @@ def switch_active_python(self, version: str) -> int:
39523927 print (_ ('Just kidding, omnipkg handled it for you automatically!' ))
39533928 return 0
39543929
3955- def adopt_interpreter (self , version : str ) -> int :
3956- """
3957- Safely adopts a Python version by checking the registry, then trying to copy
3958- from the local system, and finally falling back to download.
3959- A rescan is forced after any successful filesystem change to ensure registration.
3960- """
3961- print (_ ('🐍 Attempting to adopt Python {} into the environment...' ).format (version ))
3962-
3963- # First, check if it's already perfectly managed.
3964- managed_interpreters = self .interpreter_manager .list_available_interpreters ()
3965- if version in managed_interpreters :
3966- print (_ (' - ✅ Python {} is already adopted and managed.' ).format (version ))
3967- return 0
3968-
3969- # Attempt to find it locally to copy.
3970- discovered_pythons = self .config_manager .list_available_pythons ()
3971- source_path_str = discovered_pythons .get (version )
3972-
3973- if not source_path_str :
3974- print (_ (' - No local Python {} found. Falling back to download strategy.' ).format (version ))
3975- result = self ._fallback_to_download (version )
3976- if result == 0 :
3977- print (_ ('🔧 Forcing rescan to register the new interpreter...' ))
3978- self .rescan_interpreters ()
3979- return result
3980-
3981- source_exe_path = Path (source_path_str )
3982- try :
3983- cmd = [str (source_exe_path ), '-c' , 'import sys; print(sys.prefix)' ]
3984- cmd_result = subprocess .run (cmd , capture_output = True , text = True , check = True , timeout = 10 )
3985- source_root = Path (os .path .realpath (cmd_result .stdout .strip ()))
3986- current_venv_root = self .config_manager .venv_path .resolve ()
3987-
3988- # Perform safety checks before attempting a copy
3989- if self ._is_same_or_child_path (source_root , current_venv_root ) or \
3990- not self ._is_valid_python_installation (source_root , source_exe_path ) or \
3991- self ._estimate_directory_size (source_root ) > 2 * 1024 * 1024 * 1024 or \
3992- self ._is_system_critical_path (source_root ):
3993- print (_ (' - ⚠️ Safety checks failed for local copy. Falling back to download.' ))
3994- result = self ._fallback_to_download (version )
3995- if result == 0 :
3996- print (_ ('🔧 Forcing rescan to register the downloaded interpreter...' ))
3997- self .rescan_interpreters ()
3998- return result
3999-
4000- dest_root = self .config_manager .venv_path / '.omnipkg' / 'interpreters' / f'cpython-{ version } '
4001- if dest_root .exists ():
4002- print (_ (' - ✅ Adopted copy of Python {} already exists. Ensuring it is registered.' ).format (version ))
4003- self .rescan_interpreters ()
4004- return 0
4005-
4006- print (_ (' - Starting safe copy operation...' ))
4007- result = self ._perform_safe_copy (source_root , dest_root , version )
4008-
4009- if result == 0 :
4010- print (_ ('🔧 Forcing rescan to register the copied interpreter...' ))
4011- self .rescan_interpreters ()
4012- return result
4013-
4014- except Exception as e :
4015- print (_ (' - ❌ An error occurred during the copy attempt: {}. Falling back to download.' ).format (e ))
4016- result = self ._fallback_to_download (version )
4017- if result == 0 :
4018- print (_ ('🔧 Forcing rescan to register the downloaded interpreter...' ))
4019- self .rescan_interpreters ()
4020- return result
4021-
4022- def _fallback_to_download (self , version : str ) -> int :
4023- """
4024- Fallback to downloading Python. This function now surgically detects an incomplete
4025- installation by checking for a valid executable, cleans it up if broken,
4026- and includes a safety stop to prevent deleting the active interpreter.
4027- """
4028- print (_ ('\n --- Running robust download strategy ---' ))
4029- try :
4030- full_versions = {'3.12' : '3.12.3' , '3.10' : '3.10.13' , '3.9' : '3.9.18' , '3.11' : '3.11.6' }
4031- full_version = full_versions .get (version )
4032- if not full_version :
4033- print (f'❌ Error: No known standalone build for Python { version } .' )
4034- return 1
4035-
4036- dest_path = self .config_manager .venv_path / '.omnipkg' / 'interpreters' / f'cpython-{ full_version } '
4037-
4038- if dest_path .exists ():
4039- print (_ (' - Found existing directory for Python {}. Verifying integrity...' ).format (full_version ))
4040- if self ._is_interpreter_directory_valid (dest_path ):
4041- print (_ (' - ✅ Integrity check passed. Installation is valid and complete.' ))
4042- # Even if valid, we ensure it's registered by forcing a rescan later.
4043- return 0 # Success, no download needed.
4044- else :
4045- print (_ (' - ⚠️ Integrity check failed: Incomplete installation detected (missing or broken executable).' ))
4046-
4047- # --- CRITICAL SAFETY CHECK ---
4048- # Never delete the interpreter that is currently running this script.
4049- try :
4050- active_interpreter_root = Path (sys .executable ).resolve ().parents [1 ]
4051- if dest_path .resolve () == active_interpreter_root :
4052- print (_ (' - ❌ CRITICAL ERROR: The broken interpreter is the currently active one!' ))
4053- print (_ (' - Aborting to prevent self-destruction. Please fix the environment manually.' ))
4054- return 1
4055- except (IndexError , OSError ):
4056- pass # Path structure is unexpected, proceed with caution but don't block.
4057- # --- END SAFETY CHECK ---
4058-
4059- print (_ (' - Preparing to clean up broken directory...' ))
4060- try :
4061- shutil .rmtree (dest_path )
4062- print (_ (' - ✅ Removed broken directory successfully.' ))
4063- except Exception as e :
4064- print (_ (" - ❌ FATAL: Failed to remove existing broken directory: {}" ).format (e ))
4065- return 1
4066-
4067- # Proceed with a fresh installation into a guaranteed-to-be-clean directory.
4068- print (_ (' - Starting fresh download and installation...' ))
4069-
4070- # Call the correct method name - check your config_manager for the actual method
4071- if hasattr (self .config_manager , '_install_managed_python' ):
4072- self .config_manager ._install_managed_python (self .config_manager .venv_path , full_version )
4073- elif hasattr (self .config_manager , 'install_managed_python' ):
4074- self .config_manager .install_managed_python (self .config_manager .venv_path , full_version )
4075- elif hasattr (self .config_manager , 'download_python' ):
4076- self .config_manager .download_python (full_version )
4077- else :
4078- print (_ ('❌ Error: No download method found on config_manager' ))
4079- return 1
4080-
4081- # Verify the installation worked
4082- if dest_path .exists () and self ._is_interpreter_directory_valid (dest_path ):
4083- print (_ (' - ✅ Download and installation completed successfully.' ))
4084- return 0
4085- else :
4086- print (_ (' - ❌ Installation completed but integrity check still fails.' ))
4087- return 1
4088- except Exception as e :
4089- print (_ ('❌ Download and installation process failed: {}' ).format (e ))
4090- return 1 # Return 1 for failure
4091-
4092- def _is_same_or_child_path (self , source : Path , target : Path ) -> bool :
4093- """Check if source is the same as target or a child of target."""
4094- try :
4095- source = source .resolve ()
4096- target = target .resolve ()
4097- if source == target :
4098- return True
4099- try :
4100- source .relative_to (target )
4101- return True
4102- except ValueError :
4103- return False
4104- except (OSError , RuntimeError ):
4105- return True
4106-
4107- def _is_valid_python_installation (self , root : Path , exe_path : Path ) -> bool :
4108- """Validate that the source looks like a proper Python installation."""
4109- try :
4110- if not exe_path .exists ():
4111- return False
4112- try :
4113- exe_path .resolve ().relative_to (root .resolve ())
4114- except ValueError :
4115- return False
4116- expected_dirs = ['lib' , 'bin' ]
4117- if sys .platform == 'win32' :
4118- expected_dirs = ['Lib' , 'Scripts' ]
4119- has_expected_structure = any (((root / d ).exists () for d in expected_dirs ))
4120- test_cmd = [str (exe_path ), '-c' , 'import sys, os' ]
4121- test_result = subprocess .run (test_cmd , capture_output = True , timeout = 5 )
4122- return has_expected_structure and test_result .returncode == 0
4123- except Exception :
4124- return False
4125-
4126- def _estimate_directory_size (self , path : Path , max_files_to_check : int = 1000 ) -> int :
4127- """Estimate directory size with early termination for safety."""
4128- total_size = 0
4129- file_count = 0
4130- try :
4131- for root , dirs , files in os .walk (path ):
4132- dirs [:] = [d for d in dirs if not d .startswith (('.git' , '__pycache__' , '.mypy_cache' , 'node_modules' ))]
4133- for file in files :
4134- if file_count >= max_files_to_check :
4135- return total_size * 10
4136- try :
4137- file_path = os .path .join (root , file )
4138- total_size += os .path .getsize (file_path )
4139- file_count += 1
4140- except (OSError , IOError ):
4141- continue
4142- except Exception :
4143- return float ('inf' )
4144- return total_size
4145-
4146- def _is_system_critical_path (self , path : Path ) -> bool :
4147- """Check if path is a system-critical directory that shouldn't be copied."""
4148- critical_paths = [Path ('/' ), Path ('/usr' ), Path ('/usr/local' ), Path ('/System' ), Path ('/Library' ), Path ('/opt' ), Path ('/bin' ), Path ('/sbin' ), Path ('/etc' ), Path ('/var' ), Path ('/tmp' ), Path ('/proc' ), Path ('/dev' ), Path ('/sys' )]
4149- if sys .platform == 'win32' :
4150- critical_paths .extend ([Path ('C:\\ Windows' ), Path ('C:\\ Program Files' ), Path ('C:\\ Program Files (x86)' ), Path ('C:\\ System32' )])
4151- try :
4152- resolved_path = path .resolve ()
4153- for critical in critical_paths :
4154- if resolved_path == critical .resolve ():
4155- return True
4156- return False
4157- except Exception :
4158- return True
4159-
4160- def _perform_safe_copy (self , source : Path , dest : Path , version : str ) -> int :
4161- """Perform the actual copy operation with additional safety measures."""
4162- try :
4163- dest .parent .mkdir (parents = True , exist_ok = True )
4164-
4165- def copy_function (src , dst , * , follow_symlinks = True ):
4166- try :
4167- if os .path .getsize (src ) > 100 * 1024 * 1024 :
4168- print (_ (' - ⚠️ Skipping large file: {}' ).format (src ))
4169- return dst
4170- except OSError :
4171- pass
4172- return shutil .copy2 (src , dst , follow_symlinks = follow_symlinks )
4173-
4174- def ignore_patterns (dir , files ):
4175- ignored = []
4176- for file in files :
4177- if file in {'.git' , '__pycache__' , '.mypy_cache' , '.pytest_cache' , '.tox' , '.coverage' , 'node_modules' , '.DS_Store' }:
4178- ignored .append (file )
4179- try :
4180- filepath = os .path .join (dir , file )
4181- if os .path .isfile (filepath ) and os .path .getsize (filepath ) > 50 * 1024 * 1024 :
4182- ignored .append (file )
4183- except OSError :
4184- pass
4185- return ignored
4186- print (_ (' - Copying {} -> {}' ).format (source , dest ))
4187- shutil .copytree (source , dest , symlinks = True , ignore = ignore_patterns , dirs_exist_ok = False )
4188- copied_python = self ._find_python_executable_in_dir (dest )
4189- if not copied_python or not copied_python .exists ():
4190- print (_ (' - ❌ Copy completed but Python executable not found in destination' ))
4191- shutil .rmtree (dest , ignore_errors = True )
4192- return self ._fallback_to_download (version )
4193- test_cmd = [str (copied_python ), '-c' , 'import sys; print(sys.version)' ]
4194- test_result = subprocess .run (test_cmd , capture_output = True , timeout = 10 )
4195- if test_result .returncode != 0 :
4196- print (_ (' - ❌ Copied Python executable failed basic test' ))
4197- shutil .rmtree (dest , ignore_errors = True )
4198- return self ._fallback_to_download (version )
4199- print (_ (' - ✅ Copy successful and verified!' ))
4200- self .config_manager ._register_all_interpreters (self .config_manager .venv_path )
4201- print (f'\n 🎉 Successfully adopted Python { version } from local source!' )
4202- print (_ (" You can now use 'omnipkg swap python {}'" ).format (version ))
4203- return 0
4204- except Exception as e :
4205- print (_ (' - ❌ Copy operation failed: {}' ).format (e ))
4206- if dest .exists ():
4207- shutil .rmtree (dest , ignore_errors = True )
4208- return self ._fallback_to_download (version )
4209-
4210- def _find_python_executable_in_dir (self , directory : Path ) -> Path :
4211- """Find the Python executable in a copied directory."""
4212- possible_names = ['python' , 'python3' , 'python.exe' ]
4213- possible_dirs = ['bin' , 'Scripts' , '.' ]
4214- for subdir in possible_dirs :
4215- for name in possible_names :
4216- candidate = directory / subdir / name
4217- if candidate .exists () and candidate .is_file ():
4218- return candidate
4219- return None
4220-
42213930 def _resolve_package_versions (self , packages : List [str ]) -> List [str ]:
42223931 """
42233932 Takes a list of packages and ensures every entry has an explicit version.
@@ -4242,34 +3951,64 @@ def _resolve_package_versions(self, packages: List[str]) -> List[str]:
42423951
42433952
42443953 def _run_pip_install (self , packages : List [str ]) -> int :
3954+ """Runs `pip install` with LIVE, STREAMING output."""
42453955 if not packages :
42463956 return 0
42473957 try :
4248- cmd = [self .config ['python_executable' ], '-m' , 'pip' , 'install' ] + packages
4249- result = subprocess .run (cmd , capture_output = True , text = True , check = True )
4250- print (result .stdout )
4251- return result .returncode
4252- except subprocess .CalledProcessError as e :
4253- print (_ ('❌ Pip install command failed with exit code {}:' ).format (e .returncode ))
4254- print (e .stderr )
4255- return e .returncode
3958+ # Add '-u' for unbuffered output to force pip to talk in real-time.
3959+ cmd = [self .config ['python_executable' ], '-u' , '-m' , 'pip' , 'install' ] + packages
3960+
3961+ # Use Popen for live, line-by-line streaming.
3962+ process = subprocess .Popen (
3963+ cmd ,
3964+ stdout = subprocess .PIPE ,
3965+ stderr = subprocess .STDOUT , # Redirect stderr to stdout
3966+ text = True ,
3967+ encoding = 'utf-8' ,
3968+ errors = 'replace' ,
3969+ bufsize = 1 , # Line-buffered
3970+ universal_newlines = True
3971+ )
3972+
3973+ # Print each line of pip's output as it happens
3974+ print () # Add a newline for better formatting
3975+ for line in iter (process .stdout .readline , '' ):
3976+ # Print the raw line to preserve pip's own formatting (like progress bars)
3977+ print (line , end = '' )
3978+
3979+ process .stdout .close ()
3980+ return_code = process .wait ()
3981+ return return_code
42563982 except Exception as e :
42573983 print (_ (' ❌ An unexpected error occurred during pip install: {}' ).format (e ))
42583984 return 1
42593985
42603986 def _run_pip_uninstall (self , packages : List [str ]) -> int :
4261- """Runs `pip uninstall` for a list of packages ."""
3987+ """Runs `pip uninstall` with LIVE, STREAMING output ."""
42623988 if not packages :
42633989 return 0
42643990 try :
4265- cmd = [self .config ['python_executable' ], '-m' , 'pip' , 'uninstall' , '-y' ] + packages
4266- result = subprocess .run (cmd , check = True , text = True , capture_output = True )
4267- print (result .stdout )
4268- return result .returncode
4269- except subprocess .CalledProcessError as e :
4270- print (_ ('❌ Pip uninstall command failed with exit code {}:' ).format (e .returncode ))
4271- print (e .stderr )
4272- return e .returncode
3991+ # Add '-u' and streaming here as well for consistency.
3992+ cmd = [self .config ['python_executable' ], '-u' , '-m' , 'pip' , 'uninstall' , '-y' ] + packages
3993+
3994+ process = subprocess .Popen (
3995+ cmd ,
3996+ stdout = subprocess .PIPE ,
3997+ stderr = subprocess .STDOUT ,
3998+ text = True ,
3999+ encoding = 'utf-8' ,
4000+ errors = 'replace' ,
4001+ bufsize = 1 ,
4002+ universal_newlines = True
4003+ )
4004+
4005+ print () # Add a newline for better formatting
4006+ for line in iter (process .stdout .readline , '' ):
4007+ print (line , end = '' )
4008+
4009+ process .stdout .close ()
4010+ return_code = process .wait ()
4011+ return return_code
42734012 except Exception as e :
42744013 print (_ (' ❌ An unexpected error occurred during pip uninstall: {}' ).format (e ))
42754014 return 1
0 commit comments