meerschaum.utils.venv
Manage virtual environments.
1#! /usr/bin/env python3 2# -*- coding: utf-8 -*- 3# vim:fenc=utf-8 4 5""" 6Manage virtual environments. 7""" 8 9from __future__ import annotations 10 11import sys 12import pathlib 13 14from meerschaum.utils.typing import Optional, Union, Dict, List, Tuple 15from meerschaum.utils.threading import RLock, get_ident 16 17__all__ = sorted([ 18 'activate_venv', 'deactivate_venv', 'init_venv', 19 'inside_venv', 'is_venv_active', 'venv_exec', 20 'venv_executable', 'venv_exists', 'venv_target_path', 21 'Venv', 'get_venvs', 'verify_venv', 'get_module_venv', 'ensure_default_venvs', 22]) 23__pdoc__ = {'Venv': True} 24 25LOCKS = { 26 'sys.path': RLock(), 27 'active_venvs': RLock(), 28 'venvs_active_counts': RLock(), 29} 30 31active_venvs = set() 32active_venvs_counts: Dict[str, int] = {} 33active_venvs_order: List[Optional[str]] = [] 34threads_active_venvs: Dict[int, 'set[str]'] = {} 35CREATE_NEW_PROCESS_GROUP = 0x00000200 36 37 38def activate_venv( 39 venv: Optional[str] = 'mrsm', 40 color: bool = True, 41 force: bool = False, 42 init_if_not_exists: bool = True, 43 debug: bool = False, 44 **kw 45) -> bool: 46 """ 47 Create a virtual environment (if it doesn't exist) and add it to `sys.path` if necessary. 48 49 Parameters 50 ---------- 51 venv: Optional[str], default 'mrsm' 52 The virtual environment to activate. 53 54 color: bool, default True 55 If `True`, include color in debug text. 56 57 init_if_not_exists: bool, default True 58 If `True`, create the virtual environment if it does not exist. 59 60 force: bool, default False 61 If `True`, do not exit early even if the venv is currently active. 62 63 debug: bool, default False 64 Verbosity toggle. 65 66 Returns 67 ------- 68 A bool indicating whether the virtual environment was successfully activated. 69 70 """ 71 thread_id = get_ident() 72 import sys 73 import os 74 if venv is not None and init_if_not_exists: 75 init_venv(venv=venv, debug=debug) 76 77 with LOCKS['active_venvs']: 78 base_fallback = False 79 try: 80 target_path = venv_target_path(venv, debug=debug, allow_nonexistent=True) 81 except OSError as error: 82 import errno 83 import site 84 from meerschaum._internal.static import STATIC_CONFIG 85 if ( 86 venv is not None 87 or inside_venv() 88 or os.environ.get(STATIC_CONFIG['environment']['runtime']) == 'portable' 89 or error.errno not in (errno.EACCES, errno.EROFS) 90 ): 91 raise 92 ### Activating the base interpreter does not install anything. If its user site 93 ### cannot be created, reuse an existing global site already visible to it. 94 ### Keep venv_target_path(None) strict for callers that need an install target. 95 base_sites = set(site.getsitepackages()) 96 target_path = next(( 97 pathlib.Path(path) 98 for path in sys.path 99 if path in base_sites and pathlib.Path(path).is_dir() 100 ), None) 101 if target_path is None: 102 raise 103 base_fallback = True 104 105 if thread_id not in threads_active_venvs: 106 threads_active_venvs[thread_id] = {} 107 active_venvs.add(venv) 108 if venv not in threads_active_venvs[thread_id]: 109 threads_active_venvs[thread_id][venv] = 1 110 else: 111 threads_active_venvs[thread_id][venv] += 1 112 113 if not target_path.exists() and venv is not None and init_if_not_exists: 114 init_venv(venv=venv, force=True, debug=debug) 115 116 if not target_path.exists(): 117 if init_if_not_exists: 118 return False 119 raise EnvironmentError(f"Could not activate virtual environment '{venv}'.") 120 121 target = target_path.as_posix() 122 if ( 123 active_venvs_order 124 and active_venvs_order[0] == venv 125 and target in sys.path 126 and not force 127 ): 128 return True 129 130 if venv in active_venvs_order or base_fallback: 131 try: 132 sys.path.remove(target) 133 except Exception: 134 pass 135 try: 136 active_venvs_order.remove(venv) 137 except Exception: 138 pass 139 140 if venv is not None: 141 sys.path.insert(0, target) 142 else: 143 if sys.path and sys.path[0] in (os.getcwd(), ''): 144 sys.path.insert(1, target) 145 else: 146 sys.path.insert(0, target) 147 try: 148 active_venvs_order.insert(0, venv) 149 except Exception: 150 pass 151 152 return True 153 154 155def ensure_default_venvs(debug: bool = False) -> bool: 156 """ 157 Put the shared `'mrsm'` virtual environment on `sys.path` (when it exists and is not already 158 there), the way v4 left it for plugin code. 159 160 In v4 every process that ran plugin code had imported `meerschaum` and called 161 `attempt_import(..., venv='mrsm')` long before the first plugin was imported, so the 162 shared `mrsm` venv (where a dependency-free `pip install meerschaum` puts pandas and 163 friends on first use) was always visible: a plugin with its own `required` venv could still 164 `import pandas`. The order v4 produced is: the plugin's venv(s) first, then the base 165 interpreter's site-packages, then `<root>/venvs/mrsm`, then the rest of `sys.path`. The 166 plugin venv is activated later (on top), so this activates `'mrsm'` and then the base 167 interpreter (`venv=None`), exactly as v4's `attempt_import` calls did. 168 169 This never creates a virtual environment and never installs anything 170 (`MRSM_NO_AUTO_INSTALL` is unaffected): without a `mrsm` venv there is nothing to expose. 171 172 Returns 173 ------- 174 A bool indicating whether the `mrsm` venv is on `sys.path` afterwards. 175 """ 176 import sys 177 try: 178 target_path = venv_target_path('mrsm', allow_nonexistent=True, debug=debug) 179 target = target_path.as_posix() 180 if not target_path.exists(): 181 return False 182 with LOCKS['sys.path']: 183 if target not in sys.path: 184 activate_venv('mrsm', init_if_not_exists=False, debug=debug) 185 if target not in sys.path: 186 return False 187 try: 188 base = venv_target_path(None, debug=debug).as_posix() 189 except (Exception, SystemExit): 190 return True 191 ### The base interpreter's site-packages come before the `mrsm` venv (v4: 192 ### `activate_venv(None)` ran after `activate_venv('mrsm')`). 193 index = sys.path.index(target) 194 if base != target and base not in sys.path[:index]: 195 sys.path.insert(index, base) 196 active_venvs.add(None) 197 return True 198 except (Exception, SystemExit): 199 return False 200 201 202def deactivate_venv( 203 venv: str = 'mrsm', 204 color: bool = True, 205 debug: bool = False, 206 previously_active_venvs: Union['set[str]', List[str], None] = None, 207 force: bool = False, 208 **kw 209) -> bool: 210 """ 211 Remove a virtual environment from `sys.path` (if it's been activated). 212 213 Parameters 214 ---------- 215 venv: str, default 'mrsm' 216 The virtual environment to deactivate. 217 218 color: bool, default True 219 If `True`, include color in debug text. 220 221 debug: bool, default False 222 Verbosity toggle. 223 224 previously_active_venvs: Union[Set[str], List[str], None] 225 If provided, skip deactivating if a virtual environment is in this iterable. 226 227 force: bool, default False 228 If `True`, forcibly deactivate the virtual environment. 229 This may cause issues with other threads, so be careful! 230 231 Returns 232 ------- 233 Return a bool indicating whether the virtual environment was successfully deactivated. 234 235 """ 236 import sys 237 thread_id = get_ident() 238 if venv is None: 239 if venv in active_venvs: 240 active_venvs.remove(venv) 241 return True 242 243 if previously_active_venvs and venv in previously_active_venvs and not force: 244 return True 245 246 with LOCKS['active_venvs']: 247 if venv in threads_active_venvs.get(thread_id, {}): 248 new_count = threads_active_venvs[thread_id][venv] - 1 249 if new_count > 0 and not force: 250 threads_active_venvs[thread_id][venv] = new_count 251 return True 252 else: 253 del threads_active_venvs[thread_id][venv] 254 255 if not force: 256 for other_thread_id, other_venvs in threads_active_venvs.items(): 257 if other_thread_id == thread_id: 258 continue 259 if venv in other_venvs: 260 return True 261 else: 262 to_delete = [other_thread_id for other_thread_id in threads_active_venvs] 263 for other_thread_id in to_delete: 264 del threads_active_venvs[other_thread_id] 265 266 if venv in active_venvs: 267 active_venvs.remove(venv) 268 269 if sys.path is None: 270 return False 271 272 target = venv_target_path(venv, allow_nonexistent=True, debug=debug).as_posix() 273 with LOCKS['sys.path']: 274 if target in sys.path: 275 try: 276 sys.path.remove(target) 277 except Exception: 278 pass 279 try: 280 active_venvs_order.remove(venv) 281 except Exception: 282 pass 283 284 return True 285 286 287def is_venv_active( 288 venv: str = 'mrsm', 289 color : bool = True, 290 debug: bool = False 291) -> bool: 292 """ 293 Check if a virtual environment is active. 294 295 Parameters 296 ---------- 297 venv: str, default 'mrsm' 298 The virtual environment to check. 299 300 color: bool, default True 301 If `True`, include color in debug text. 302 303 debug: bool, default False 304 Verbosity toggle. 305 306 Returns 307 ------- 308 A bool indicating whether the virtual environment `venv` is active. 309 310 """ 311 return venv in active_venvs 312 313 314verified_venvs = set() 315_venv_mutation_locks = {} 316_venv_mutation_locks_guard = RLock() 317 318 319def _locked_venv_mutation(func): 320 """Serialize creation and verification, including recursive repair, for each environment.""" 321 from functools import wraps 322 from inspect import signature 323 call_signature = signature(func) 324 325 @wraps(func) 326 def locked(*args, **kwargs): 327 import meerschaum.config.paths as paths 328 from meerschaum.utils.locks import InterProcessLock 329 330 arguments = call_signature.bind(*args, **kwargs) 331 arguments.apply_defaults() 332 venv = arguments.arguments['venv'] 333 # Already-verified activation performs no mutations and must keep working even 334 # when its cache directory has become read-only. 335 if ( 336 func.__name__ == 'init_venv' 337 and not arguments.arguments['force'] 338 and venv in verified_venvs 339 ): 340 return func(*args, **kwargs) 341 342 # Keep this distinct from the legacy existence-marker `<venv>.lock`: file locks 343 # persist after release. Share the same object for recursive init / verify calls. 344 lock_path = paths.VENVS_CACHE_RESOURCES_PATH / (venv + '.mutation.lock') 345 lock_key = str(lock_path.absolute()) 346 with _venv_mutation_locks_guard: 347 thread_lock, process_lock = _venv_mutation_locks.setdefault( 348 lock_key, (RLock(), InterProcessLock(lock_path)), 349 ) 350 with thread_lock: 351 with process_lock: 352 return func(*args, **kwargs) 353 354 return locked 355 356 357@_locked_venv_mutation 358def verify_venv( 359 venv: str, 360 debug: bool = False, 361) -> None: 362 """ 363 Verify that the virtual environment matches the expected state. 364 """ 365 import platform 366 import os 367 import sys 368 369 import meerschaum.config.paths as paths 370 from meerschaum.utils.process import run_process 371 from meerschaum.utils.misc import make_symlink, is_symlink 372 from meerschaum.utils.warnings import warn 373 374 venv_path = paths.VIRTENV_RESOURCES_PATH / venv 375 bin_path = venv_path / ( 376 'bin' if platform.system() != 'Windows' else "Scripts" 377 ) 378 current_python_versioned_name = ( 379 'python' + str(sys.version_info.major) + '.' + str(sys.version_info.minor) 380 + ('' if platform.system() != 'Windows' else '.exe') 381 ) 382 383 if not (bin_path / current_python_versioned_name).exists(): 384 init_venv(venv, verify=False, force=True, debug=debug) 385 current_python_in_venv_path = pathlib.Path(venv_executable(venv=venv)) 386 current_python_in_sys_path = pathlib.Path(venv_executable(venv=None)) 387 if not current_python_in_venv_path.exists(): 388 if is_symlink(current_python_in_venv_path): 389 try: 390 current_python_in_venv_path.unlink() 391 except Exception as e: 392 print(f"Unable to remove symlink {current_python_in_venv_path}:\n{e}") 393 try: 394 make_symlink(current_python_in_sys_path, current_python_in_venv_path) 395 except Exception: 396 print( 397 f"Unable to create symlink {current_python_in_venv_path} " 398 + f"to {current_python_in_sys_path}." 399 ) 400 files_to_inspect = sorted(os.listdir(bin_path), reverse=True) 401 else: 402 files_to_inspect = [current_python_versioned_name] 403 404 def get_python_version(python_path: pathlib.Path) -> Union[str, None]: 405 """ 406 Return the version for the python binary at the given path. 407 """ 408 proc = None 409 try: 410 ### It might be a broken symlink, so skip on errors. 411 if debug: 412 print(f"Getting python version for {python_path}") 413 proc = run_process( 414 [str(python_path), '-V'], 415 as_proc=True, 416 capture_output=True, 417 ) 418 stdout, stderr = proc.communicate(timeout=1.0) 419 except Exception as e: 420 # A loaded host may exceed the probe timeout with a valid interpreter. Reap 421 # that child, and only remove aliases whose targets are actually missing. 422 if proc is not None and proc.poll() is None: 423 proc.kill() 424 proc.communicate() 425 if is_symlink(python_path) and not python_path.exists(): 426 try: 427 python_path.unlink() 428 except Exception as _e: 429 print(f"Unable to remove broken symlink {python_path}:\n{e}\n{_e}") 430 return None 431 return stdout.decode('utf-8').strip().replace('Python ', '') 432 433 ### Ensure the versions are symlinked correctly. 434 for filename in files_to_inspect: 435 if not filename.startswith('python'): 436 continue 437 python_path = bin_path / filename 438 version = get_python_version(python_path) 439 if version is None: 440 continue 441 try: 442 major_version = version.split('.', maxsplit=1)[0] 443 minor_version = version.split('.', maxsplit=2)[1] 444 except IndexError: 445 return 446 python_versioned_name = ( 447 'python' + major_version + '.' + minor_version 448 + ('' if platform.system() != 'Windows' else '.exe') 449 ) 450 451 ### E.g. python3.10 actually links to Python 3.10. 452 if filename == python_versioned_name: 453 try: 454 real_path = pathlib.Path(os.path.realpath(python_path)) 455 real_path_exists = real_path.exists() 456 except Exception: 457 real_path_exists = False 458 459 if not real_path_exists: 460 try: 461 python_path.unlink() 462 except Exception: 463 pass 464 init_venv(venv, verify=False, force=True, debug=debug) 465 if not python_path.exists(): 466 raise FileNotFoundError(f"Unable to verify Python symlink:\n{python_path}") 467 468 if python_path == real_path: 469 continue 470 471 # Already-normalized aliases need no mutation. For indirect aliases, replace 472 # atomically so another process may keep executing this interpreter throughout. 473 if python_path.is_symlink() and python_path.readlink() == real_path: 474 continue 475 import uuid 476 temporary_path = python_path.with_name('.' + filename + '.' + uuid.uuid4().hex) 477 try: 478 success, msg = make_symlink(real_path, temporary_path) 479 if success: 480 os.replace(temporary_path, python_path) 481 else: 482 warn(msg, color=False) 483 except OSError as e: 484 warn(str(e), color=False) 485 finally: 486 temporary_path.unlink(missing_ok=True) 487 continue 488 489 python_versioned_path = bin_path / python_versioned_name 490 if python_versioned_path.exists(): 491 ### Avoid circular symlinks. 492 if get_python_version(python_versioned_path) == version: 493 continue 494 os.replace(python_path, python_versioned_path) 495 496 497tried_virtualenv = False 498@_locked_venv_mutation 499def init_venv( 500 venv: str = 'mrsm', 501 verify: bool = True, 502 force: bool = False, 503 debug: bool = False, 504) -> bool: 505 """ 506 Initialize the virtual environment. 507 508 Parameters 509 ---------- 510 venv: str, default 'mrsm' 511 The name of the virtual environment to create. 512 513 verify: bool, default True 514 If `True`, verify that the virtual environment is in the expected state. 515 516 force: bool, default False 517 If `True`, recreate the virtual environment, even if already initalized. 518 519 Returns 520 ------- 521 A `bool` indicating success. 522 """ 523 if not force and venv in verified_venvs: 524 return True 525 if not force and venv_exists(venv, debug=debug): 526 if verify: 527 verify_venv(venv, debug=debug) 528 verified_venvs.add(venv) 529 return True 530 531 import io 532 from contextlib import redirect_stdout 533 import sys 534 import platform 535 import os 536 import shutil 537 import time 538 539 import meerschaum.config.paths as paths 540 from meerschaum._internal.static import STATIC_CONFIG 541 from meerschaum.utils.packages import is_uv_enabled 542 543 venv_path = paths.VIRTENV_RESOURCES_PATH / venv 544 vtp = venv_target_path(venv=venv, allow_nonexistent=True, debug=debug) 545 docker_home_venv_path = pathlib.Path('/home/meerschaum/venvs/mrsm') 546 lock_path = paths.VENVS_CACHE_RESOURCES_PATH / (venv + '.lock') 547 work_dir_env_var = STATIC_CONFIG['environment']['work_dir'] 548 549 def update_lock(active: bool): 550 try: 551 if not active: 552 if debug: 553 print(f"Releasing lock: '{lock_path}'") 554 lock_path.unlink() 555 else: 556 if debug: 557 print(f"Acquiring lock: '{lock_path}'") 558 lock_path.touch() 559 except Exception: 560 pass 561 562 def wait_for_lock(): 563 if platform.system() == 'Windows': 564 return 565 max_lock_seconds = 30.0 566 sleep_message_seconds = 5.0 567 step_sleep_seconds = 0.1 568 init_venv_check_start = time.perf_counter() 569 last_print = init_venv_check_start 570 while ((time.perf_counter() - init_venv_check_start) < max_lock_seconds): 571 if not lock_path.exists(): 572 break 573 574 now = time.perf_counter() 575 if debug or (now - last_print) > sleep_message_seconds: 576 print(f"Lock exists for venv '{venv}', sleeping...") 577 last_print = now 578 time.sleep(step_sleep_seconds) 579 update_lock(False) 580 581 if ( 582 not force 583 and venv == 'mrsm' 584 and os.environ.get(work_dir_env_var, None) is not None 585 and docker_home_venv_path.exists() 586 ): 587 wait_for_lock() 588 shutil.move(docker_home_venv_path, venv_path) 589 if verify: 590 verify_venv(venv, debug=debug) 591 verified_venvs.add(venv) 592 return True 593 594 from meerschaum.utils.packages import ( 595 run_python_package, attempt_import, _get_pip_os_env, _get_uv_bin, _auto_install_allowed, 596 ) 597 global tried_virtualenv 598 try: 599 import venv as _venv 600 uv = _get_uv_bin() if is_uv_enabled() else None 601 virtualenv = None 602 except ImportError: 603 _venv = None 604 uv = None 605 virtualenv = None 606 607 _venv_success = False 608 temp_vtp = paths.VENVS_CACHE_RESOURCES_PATH / str(venv) 609 ### NOTE: Disable site-packages movement for now. 610 rename_vtp = False and vtp.exists() and not temp_vtp.exists() 611 612 wait_for_lock() 613 update_lock(True) 614 try: 615 if rename_vtp: 616 if debug: 617 print(f"Moving '{vtp}' to '{temp_vtp}'...") 618 shutil.move(vtp, temp_vtp) 619 620 if uv is not None: 621 # Plugin imports run in this interpreter. Letting uv choose another version 622 # makes verification recreate the venv while its creation lock is still held. 623 _venv_success = run_python_package( 624 'uv', 625 [ 626 'venv', venv_path.as_posix(), '--python', sys.executable, 627 '-q', '--no-project', '--allow-existing', 628 ] + ( 629 ### `--seed` downloads `pip`, `setuptools` and `wheel` into the new venv. 630 ['--seed'] if _auto_install_allowed() else [] 631 ), 632 venv=None, 633 env=_get_pip_os_env(), 634 debug=debug, 635 ) == 0 636 637 if _venv is not None and not _venv_success: 638 f = io.StringIO() 639 with redirect_stdout(f): 640 _venv_success = run_python_package( 641 'venv', 642 [venv_path.as_posix()] + ( 643 ['--symlinks'] 644 if platform.system() != 'Windows' 645 else [] 646 ), 647 venv=None, debug=debug 648 ) == 0 649 if not _venv_success: 650 print(f"Please install python3-venv.\n{f.getvalue()}\nFalling back to virtualenv...") 651 if not venv_exists(venv, debug=debug): 652 _venv = None 653 if not _venv_success: 654 virtualenv = attempt_import( 655 'virtualenv', 656 venv=None, 657 lazy=False, 658 install=(not tried_virtualenv), 659 warn=False, 660 check_update=False, 661 color=False, 662 debug=debug, 663 ) 664 if virtualenv is None: 665 print( 666 "Failed to import `venv` or `virtualenv`! " 667 + "Please install `virtualenv` via pip then restart Meerschaum." 668 ) 669 if rename_vtp and temp_vtp.exists(): 670 if debug: 671 print(f"Moving '{temp_vtp}' back to '{vtp}'...") 672 shutil.move(temp_vtp, vtp) 673 return False 674 675 tried_virtualenv = True 676 try: 677 python_folder = ( 678 'python' + str(sys.version_info.major) + '.' + str(sys.version_info.minor) 679 ) 680 dist_packages_path = ( 681 paths.VIRTENV_RESOURCES_PATH / 682 venv / 'local' / 'lib' / python_folder / 'dist-packages' 683 ) 684 local_bin_path = paths.VIRTENV_RESOURCES_PATH / venv / 'local' / 'bin' 685 bin_path = paths.VIRTENV_RESOURCES_PATH / venv / 'bin' 686 vtp = venv_target_path(venv=venv, allow_nonexistent=True, debug=debug) 687 if bin_path.exists(): 688 try: 689 shutil.rmtree(bin_path) 690 except Exception: 691 import traceback 692 traceback.print_exc() 693 virtualenv.cli_run([venv_path.as_posix()]) 694 if dist_packages_path.exists(): 695 vtp.mkdir(exist_ok=True, parents=True) 696 for file_path in dist_packages_path.glob('*'): 697 shutil.move(file_path, vtp) 698 shutil.rmtree(dist_packages_path) 699 # shutil.move(dist_packages_path, vtp) 700 bin_path.mkdir(exist_ok=True, parents=True) 701 for file_path in local_bin_path.glob('*'): 702 shutil.move(file_path, bin_path) 703 # shutil.move(local_bin_path, bin_path) 704 shutil.rmtree(local_bin_path) 705 706 except Exception: 707 import traceback 708 traceback.print_exc() 709 if rename_vtp and temp_vtp.exists(): 710 shutil.move(temp_vtp, vtp) 711 return False 712 if rename_vtp and temp_vtp.exists(): 713 if debug: 714 print(f"Cleanup: move '{temp_vtp}' back to '{vtp}'.") 715 shutil.move(temp_vtp, vtp) 716 finally: 717 update_lock(False) 718 719 # The mutation lock is reentrant: verification may recreate an incompatible environment. 720 if verify: 721 verify_venv(venv, debug=debug) 722 verified_venvs.add(venv) 723 return True 724 725 726def venv_executable(venv: Optional[str] = 'mrsm') -> str: 727 """ 728 The Python interpreter executable for a given virtual environment. 729 """ 730 import sys 731 import platform 732 import meerschaum.config.paths as paths 733 return ( 734 sys.executable if venv is None 735 else str( 736 paths.VIRTENV_RESOURCES_PATH 737 / venv 738 / ( 739 'bin' if platform.system() != 'Windows' 740 else 'Scripts' 741 ) / ( 742 'python' 743 + str(sys.version_info.major) 744 + '.' 745 + str(sys.version_info.minor) 746 ) 747 ) 748 ) 749 750 751def venv_exec( 752 code: str, 753 venv: Optional[str] = 'mrsm', 754 env: Optional[Dict[str, str]] = None, 755 with_extras: bool = False, 756 as_proc: bool = False, 757 capture_output: bool = True, 758 debug: bool = False, 759) -> Union[bool, Tuple[int, bytes, bytes], 'subprocess.Popen']: 760 """ 761 Execute Python code in a subprocess via a virtual environment's interpeter. 762 Return `True` if the code successfully executes, `False` on failure. 763 764 Parameters 765 ---------- 766 code: str 767 The Python code to excecute. 768 769 venv: str, default 'mrsm' 770 The virtual environment to use to get the path for the Python executable. 771 If `venv` is `None`, use the default `sys.executable` path. 772 773 env: Optional[Dict[str, str]], default None 774 Optionally specify the environment variables for the subprocess. 775 Defaults to `os.environ`. 776 777 with_extras: bool, default False 778 If `True`, return a tuple of the exit code, stdout bytes, and stderr bytes. 779 780 as_proc: bool, default False 781 If `True`, return the `subprocess.Popen` object instead of executing. 782 783 Returns 784 ------- 785 By default, return a bool indicating success. 786 If `as_proc` is `True`, return a `subprocess.Popen` object. 787 If `with_extras` is `True`, return a tuple of the exit code, stdout bytes, and stderr bytes. 788 789 """ 790 import os 791 import subprocess 792 import platform 793 from meerschaum.utils.debug import dprint 794 from meerschaum.utils.process import _child_processes 795 796 executable = venv_executable(venv=venv) 797 cmd_list = [executable, '-c', code] 798 if env is None: 799 env = os.environ 800 if debug: 801 dprint(str(cmd_list)) 802 if not with_extras and not as_proc: 803 return subprocess.call(cmd_list, env=env) == 0 804 805 stdout, stderr = (None, None) if not capture_output else (subprocess.PIPE, subprocess.PIPE) 806 group_kwargs = ( 807 { 808 'preexec_fn': os.setsid, 809 } if platform.system() != 'Windows' 810 else { 811 'creationflags': CREATE_NEW_PROCESS_GROUP, 812 } 813 ) 814 process = subprocess.Popen( 815 cmd_list, 816 stdout=stdout, 817 stderr=stderr, 818 stdin=sys.stdin, 819 env=env, 820 **group_kwargs 821 ) 822 if as_proc: 823 _child_processes.append(process) 824 return process 825 stdout, stderr = process.communicate() 826 exit_code = process.returncode 827 return exit_code, stdout, stderr 828 829 830def venv_exists(venv: Union[str, None], debug: bool = False) -> bool: 831 """ 832 Determine whether a virtual environment has been created. 833 """ 834 target_path = venv_target_path(venv, allow_nonexistent=True, debug=debug) 835 return target_path.exists() 836 837 838def venv_target_path( 839 venv: Union[str, None], 840 allow_nonexistent: bool = False, 841 debug: bool = False, 842) -> pathlib.Path: 843 """ 844 Return a virtual environment's site-package path. 845 846 Parameters 847 ---------- 848 venv: Union[str, None] 849 The virtual environment for which a path should be returned. 850 851 allow_nonexistent: bool, default False 852 If `True`, return a path even if it does not exist. 853 854 Returns 855 ------- 856 The `pathlib.Path` object for the virtual environment's path. 857 858 """ 859 import os 860 import sys 861 import platform 862 import site 863 864 import meerschaum.config.paths as paths 865 from meerschaum._internal.static import STATIC_CONFIG 866 867 ### Check sys.path for a user-writable site-packages directory. 868 if venv is None: 869 870 ### Return the known value for the portable environment. 871 environment_runtime = STATIC_CONFIG['environment']['runtime'] 872 if os.environ.get(environment_runtime, None) == 'portable': 873 python_version_folder = ( 874 'python' + str(sys.version_info.major) + '.' + str(sys.version_info.minor) 875 ) 876 executable_path = pathlib.Path(sys.executable) 877 site_packages_path = ( 878 ( 879 executable_path.parent.parent / 'lib' / python_version_folder / 'site-packages' 880 ) if platform.system() != 'Windows' else ( 881 executable_path.parent / 'Lib' / 'site-packages' 882 ) 883 ) 884 if not site_packages_path.exists(): 885 raise EnvironmentError(f"Could not find '{site_packages_path}'. Does it exist?") 886 return site_packages_path 887 888 if not inside_venv(): 889 user_site_packages = site.getusersitepackages() 890 if user_site_packages is None: 891 raise EnvironmentError("Could not determine user site packages.") 892 893 site_path = pathlib.Path(user_site_packages) 894 if not site_path.exists(): 895 896 ### Windows does not have `os.geteuid()`. 897 if platform.system() == 'Windows' or os.geteuid() != 0: 898 site_path.mkdir(parents=True, exist_ok=True) 899 return site_path 900 901 ### Allow for dist-level paths (running as root). 902 for possible_dist in site.getsitepackages(): 903 dist_path = pathlib.Path(possible_dist) 904 if not dist_path.exists(): 905 continue 906 return dist_path 907 908 raise EnvironmentError("Could not determine the dist-packages directory.") 909 910 return site_path 911 912 venv_root_path = ( 913 (paths.VIRTENV_RESOURCES_PATH / venv) 914 if venv is not None 915 else pathlib.Path(sys.prefix) 916 ) 917 target_path = venv_root_path 918 919 ### Ensure 'lib' or 'Lib' exists. 920 lib = 'lib' if platform.system() != 'Windows' else 'Lib' 921 if not allow_nonexistent: 922 if not venv_root_path.exists() or lib not in os.listdir(venv_root_path): 923 print(f"Failed to find lib directory for virtual environment '{venv}'.") 924 import traceback 925 traceback.print_stack() 926 sys.exit(1) 927 target_path = target_path / lib 928 929 ### Check if a 'python3.x' folder exists. 930 python_folder = 'python' + str(sys.version_info.major) + '.' + str(sys.version_info.minor) 931 if target_path.exists(): 932 target_path = ( 933 (target_path / python_folder) 934 if python_folder in os.listdir(target_path) 935 else target_path 936 ) 937 else: 938 target_path = ( 939 (target_path / python_folder) 940 if platform.system() != 'Windows' 941 else target_path 942 ) 943 944 ### Ensure 'site-packages' exists. 945 if allow_nonexistent or 'site-packages' in os.listdir(target_path): ### Windows 946 target_path = target_path / 'site-packages' 947 else: 948 import traceback 949 traceback.print_stack() 950 print(f"Failed to find site-packages directory for virtual environment '{venv}'.") 951 print("This may be because you are using a different Python version.") 952 print("Try deleting the following directory and restarting Meerschaum:") 953 print(paths.VIRTENV_RESOURCES_PATH) 954 sys.exit(1) 955 956 return target_path 957 958 959def inside_venv() -> bool: 960 """ 961 Determine whether current Python interpreter is running inside a virtual environment. 962 """ 963 import sys 964 return ( 965 hasattr(sys, 'real_prefix') or ( 966 hasattr(sys, 'base_prefix') 967 and sys.base_prefix != sys.prefix 968 ) 969 ) 970 971 972def get_venvs() -> List[str]: 973 """ 974 Return a list of all the virtual environments. 975 """ 976 import os 977 import meerschaum.config.paths as paths 978 venvs = [] 979 for filename in os.listdir(paths.VIRTENV_RESOURCES_PATH): 980 path = paths.VIRTENV_RESOURCES_PATH / filename 981 if not path.is_dir(): 982 continue 983 if not venv_exists(filename): 984 continue 985 venvs.append(filename) 986 return venvs 987 988 989def get_module_venv(module) -> Union[str, None]: 990 """ 991 Return the virtual environment where an imported module is installed. 992 993 Parameters 994 ---------- 995 module: ModuleType 996 The imported module to inspect. 997 998 Returns 999 ------- 1000 The name of a venv or `None`. 1001 """ 1002 import meerschaum.config.paths as paths 1003 module_path = pathlib.Path(module.__file__).resolve() 1004 try: 1005 rel_path = module_path.relative_to(paths.VIRTENV_RESOURCES_PATH) 1006 except ValueError: 1007 return None 1008 1009 return rel_path.as_posix().split('/', maxsplit=1)[0] 1010 1011 1012from meerschaum.utils.venv._Venv import Venv
19class Venv: 20 """ 21 Manage a virtual enviroment's activation status. 22 23 Examples 24 -------- 25 >>> from meerschaum.plugins import Plugin 26 >>> with Venv('mrsm') as venv: 27 ... import pandas 28 >>> with Venv(Plugin('noaa')) as venv: 29 ... import requests 30 >>> venv = Venv('mrsm') 31 >>> venv.activate() 32 True 33 >>> venv.deactivate() 34 True 35 >>> 36 """ 37 38 def __init__( 39 self, 40 venv: Union[str, 'mrsm.core.Plugin', None] = 'mrsm', 41 init_if_not_exists: bool = True, 42 debug: bool = False, 43 ) -> None: 44 from meerschaum.utils.venv import activate_venv, deactivate_venv, active_venvs 45 ### For some weird threading issue, 46 ### we can't use `isinstance` here. 47 if '_Plugin' in str(type(venv)): 48 self._venv = venv.name 49 self._activate = venv.activate_venv 50 self._deactivate = venv.deactivate_venv 51 self._kwargs = {} 52 else: 53 self._venv = venv 54 self._activate = activate_venv 55 self._deactivate = deactivate_venv 56 self._kwargs = {'venv': venv} 57 self._debug = debug 58 self._init_if_not_exists = init_if_not_exists 59 ### In case someone calls `deactivate()` before `activate()`. 60 self._kwargs['previously_active_venvs'] = copy.deepcopy(active_venvs) 61 62 63 def activate(self, debug: bool = False) -> bool: 64 """ 65 Activate this virtual environment. 66 If a `meerschaum.plugins.Plugin` was provided, its dependent virtual environments 67 will also be activated. 68 """ 69 from meerschaum.utils.venv import active_venvs, init_venv 70 self._kwargs['previously_active_venvs'] = copy.deepcopy(active_venvs) 71 try: 72 return self._activate( 73 debug=(debug or self._debug), 74 init_if_not_exists=self._init_if_not_exists, 75 **self._kwargs 76 ) 77 except OSError as e: 78 ### None is the current interpreter, not a named environment to recreate. 79 if self._venv is None: 80 raise 81 if self._init_if_not_exists: 82 if not init_venv(self._venv, force=True): 83 raise e 84 return self._activate( 85 debug=(debug or self._debug), 86 init_if_not_exists=self._init_if_not_exists, 87 **self._kwargs 88 ) 89 90 91 def deactivate(self, debug: bool = False) -> bool: 92 """ 93 Deactivate this virtual environment. 94 If a `meerschaum.plugins.Plugin` was provided, its dependent virtual environments 95 will also be deactivated. 96 """ 97 return self._deactivate(debug=(debug or self._debug), **self._kwargs) 98 99 100 @property 101 def target_path(self) -> pathlib.Path: 102 """ 103 Return the target site-packages path for this virtual environment. 104 A `meerschaum.utils.venv.Venv` may have one virtual environment per minor Python version 105 (e.g. Python 3.10 and Python 3.7). 106 """ 107 from meerschaum.utils.venv import venv_target_path 108 return venv_target_path(venv=self._venv, allow_nonexistent=True, debug=self._debug) 109 110 111 @property 112 def root_path(self) -> pathlib.Path: 113 """ 114 Return the top-level path for this virtual environment. 115 """ 116 import meerschaum.config.paths as paths 117 if self._venv is None: 118 return self.target_path.parent 119 return paths.VIRTENV_RESOURCES_PATH / self._venv 120 121 122 def __enter__(self) -> None: 123 self.activate(debug=self._debug) 124 125 126 def __exit__(self, exc_type, exc_value, exc_traceback) -> None: 127 self.deactivate(debug=self._debug) 128 129 130 def __str__(self) -> str: 131 quote = "'" if self._venv is not None else "" 132 return "Venv(" + quote + str(self._venv) + quote + ")" 133 134 135 def __repr__(self) -> str: 136 return self.__str__()
Manage a virtual enviroment's activation status.
Examples
>>> from meerschaum.plugins import Plugin
>>> with Venv('mrsm') as venv:
... import pandas
>>> with Venv(Plugin('noaa')) as venv:
... import requests
>>> venv = Venv('mrsm')
>>> venv.activate()
True
>>> venv.deactivate()
True
>>>
38 def __init__( 39 self, 40 venv: Union[str, 'mrsm.core.Plugin', None] = 'mrsm', 41 init_if_not_exists: bool = True, 42 debug: bool = False, 43 ) -> None: 44 from meerschaum.utils.venv import activate_venv, deactivate_venv, active_venvs 45 ### For some weird threading issue, 46 ### we can't use `isinstance` here. 47 if '_Plugin' in str(type(venv)): 48 self._venv = venv.name 49 self._activate = venv.activate_venv 50 self._deactivate = venv.deactivate_venv 51 self._kwargs = {} 52 else: 53 self._venv = venv 54 self._activate = activate_venv 55 self._deactivate = deactivate_venv 56 self._kwargs = {'venv': venv} 57 self._debug = debug 58 self._init_if_not_exists = init_if_not_exists 59 ### In case someone calls `deactivate()` before `activate()`. 60 self._kwargs['previously_active_venvs'] = copy.deepcopy(active_venvs)
63 def activate(self, debug: bool = False) -> bool: 64 """ 65 Activate this virtual environment. 66 If a `meerschaum.plugins.Plugin` was provided, its dependent virtual environments 67 will also be activated. 68 """ 69 from meerschaum.utils.venv import active_venvs, init_venv 70 self._kwargs['previously_active_venvs'] = copy.deepcopy(active_venvs) 71 try: 72 return self._activate( 73 debug=(debug or self._debug), 74 init_if_not_exists=self._init_if_not_exists, 75 **self._kwargs 76 ) 77 except OSError as e: 78 ### None is the current interpreter, not a named environment to recreate. 79 if self._venv is None: 80 raise 81 if self._init_if_not_exists: 82 if not init_venv(self._venv, force=True): 83 raise e 84 return self._activate( 85 debug=(debug or self._debug), 86 init_if_not_exists=self._init_if_not_exists, 87 **self._kwargs 88 )
Activate this virtual environment.
If a meerschaum.plugins.Plugin was provided, its dependent virtual environments
will also be activated.
91 def deactivate(self, debug: bool = False) -> bool: 92 """ 93 Deactivate this virtual environment. 94 If a `meerschaum.plugins.Plugin` was provided, its dependent virtual environments 95 will also be deactivated. 96 """ 97 return self._deactivate(debug=(debug or self._debug), **self._kwargs)
Deactivate this virtual environment.
If a meerschaum.plugins.Plugin was provided, its dependent virtual environments
will also be deactivated.
100 @property 101 def target_path(self) -> pathlib.Path: 102 """ 103 Return the target site-packages path for this virtual environment. 104 A `meerschaum.utils.venv.Venv` may have one virtual environment per minor Python version 105 (e.g. Python 3.10 and Python 3.7). 106 """ 107 from meerschaum.utils.venv import venv_target_path 108 return venv_target_path(venv=self._venv, allow_nonexistent=True, debug=self._debug)
Return the target site-packages path for this virtual environment.
A meerschaum.utils.venv.Venv may have one virtual environment per minor Python version
(e.g. Python 3.10 and Python 3.7).
111 @property 112 def root_path(self) -> pathlib.Path: 113 """ 114 Return the top-level path for this virtual environment. 115 """ 116 import meerschaum.config.paths as paths 117 if self._venv is None: 118 return self.target_path.parent 119 return paths.VIRTENV_RESOURCES_PATH / self._venv
Return the top-level path for this virtual environment.
39def activate_venv( 40 venv: Optional[str] = 'mrsm', 41 color: bool = True, 42 force: bool = False, 43 init_if_not_exists: bool = True, 44 debug: bool = False, 45 **kw 46) -> bool: 47 """ 48 Create a virtual environment (if it doesn't exist) and add it to `sys.path` if necessary. 49 50 Parameters 51 ---------- 52 venv: Optional[str], default 'mrsm' 53 The virtual environment to activate. 54 55 color: bool, default True 56 If `True`, include color in debug text. 57 58 init_if_not_exists: bool, default True 59 If `True`, create the virtual environment if it does not exist. 60 61 force: bool, default False 62 If `True`, do not exit early even if the venv is currently active. 63 64 debug: bool, default False 65 Verbosity toggle. 66 67 Returns 68 ------- 69 A bool indicating whether the virtual environment was successfully activated. 70 71 """ 72 thread_id = get_ident() 73 import sys 74 import os 75 if venv is not None and init_if_not_exists: 76 init_venv(venv=venv, debug=debug) 77 78 with LOCKS['active_venvs']: 79 base_fallback = False 80 try: 81 target_path = venv_target_path(venv, debug=debug, allow_nonexistent=True) 82 except OSError as error: 83 import errno 84 import site 85 from meerschaum._internal.static import STATIC_CONFIG 86 if ( 87 venv is not None 88 or inside_venv() 89 or os.environ.get(STATIC_CONFIG['environment']['runtime']) == 'portable' 90 or error.errno not in (errno.EACCES, errno.EROFS) 91 ): 92 raise 93 ### Activating the base interpreter does not install anything. If its user site 94 ### cannot be created, reuse an existing global site already visible to it. 95 ### Keep venv_target_path(None) strict for callers that need an install target. 96 base_sites = set(site.getsitepackages()) 97 target_path = next(( 98 pathlib.Path(path) 99 for path in sys.path 100 if path in base_sites and pathlib.Path(path).is_dir() 101 ), None) 102 if target_path is None: 103 raise 104 base_fallback = True 105 106 if thread_id not in threads_active_venvs: 107 threads_active_venvs[thread_id] = {} 108 active_venvs.add(venv) 109 if venv not in threads_active_venvs[thread_id]: 110 threads_active_venvs[thread_id][venv] = 1 111 else: 112 threads_active_venvs[thread_id][venv] += 1 113 114 if not target_path.exists() and venv is not None and init_if_not_exists: 115 init_venv(venv=venv, force=True, debug=debug) 116 117 if not target_path.exists(): 118 if init_if_not_exists: 119 return False 120 raise EnvironmentError(f"Could not activate virtual environment '{venv}'.") 121 122 target = target_path.as_posix() 123 if ( 124 active_venvs_order 125 and active_venvs_order[0] == venv 126 and target in sys.path 127 and not force 128 ): 129 return True 130 131 if venv in active_venvs_order or base_fallback: 132 try: 133 sys.path.remove(target) 134 except Exception: 135 pass 136 try: 137 active_venvs_order.remove(venv) 138 except Exception: 139 pass 140 141 if venv is not None: 142 sys.path.insert(0, target) 143 else: 144 if sys.path and sys.path[0] in (os.getcwd(), ''): 145 sys.path.insert(1, target) 146 else: 147 sys.path.insert(0, target) 148 try: 149 active_venvs_order.insert(0, venv) 150 except Exception: 151 pass 152 153 return True
Create a virtual environment (if it doesn't exist) and add it to sys.path if necessary.
Parameters
- venv (Optional[str], default 'mrsm'): The virtual environment to activate.
- color (bool, default True):
If
True, include color in debug text. - init_if_not_exists (bool, default True):
If
True, create the virtual environment if it does not exist. - force (bool, default False):
If
True, do not exit early even if the venv is currently active. - debug (bool, default False): Verbosity toggle.
Returns
- A bool indicating whether the virtual environment was successfully activated.
203def deactivate_venv( 204 venv: str = 'mrsm', 205 color: bool = True, 206 debug: bool = False, 207 previously_active_venvs: Union['set[str]', List[str], None] = None, 208 force: bool = False, 209 **kw 210) -> bool: 211 """ 212 Remove a virtual environment from `sys.path` (if it's been activated). 213 214 Parameters 215 ---------- 216 venv: str, default 'mrsm' 217 The virtual environment to deactivate. 218 219 color: bool, default True 220 If `True`, include color in debug text. 221 222 debug: bool, default False 223 Verbosity toggle. 224 225 previously_active_venvs: Union[Set[str], List[str], None] 226 If provided, skip deactivating if a virtual environment is in this iterable. 227 228 force: bool, default False 229 If `True`, forcibly deactivate the virtual environment. 230 This may cause issues with other threads, so be careful! 231 232 Returns 233 ------- 234 Return a bool indicating whether the virtual environment was successfully deactivated. 235 236 """ 237 import sys 238 thread_id = get_ident() 239 if venv is None: 240 if venv in active_venvs: 241 active_venvs.remove(venv) 242 return True 243 244 if previously_active_venvs and venv in previously_active_venvs and not force: 245 return True 246 247 with LOCKS['active_venvs']: 248 if venv in threads_active_venvs.get(thread_id, {}): 249 new_count = threads_active_venvs[thread_id][venv] - 1 250 if new_count > 0 and not force: 251 threads_active_venvs[thread_id][venv] = new_count 252 return True 253 else: 254 del threads_active_venvs[thread_id][venv] 255 256 if not force: 257 for other_thread_id, other_venvs in threads_active_venvs.items(): 258 if other_thread_id == thread_id: 259 continue 260 if venv in other_venvs: 261 return True 262 else: 263 to_delete = [other_thread_id for other_thread_id in threads_active_venvs] 264 for other_thread_id in to_delete: 265 del threads_active_venvs[other_thread_id] 266 267 if venv in active_venvs: 268 active_venvs.remove(venv) 269 270 if sys.path is None: 271 return False 272 273 target = venv_target_path(venv, allow_nonexistent=True, debug=debug).as_posix() 274 with LOCKS['sys.path']: 275 if target in sys.path: 276 try: 277 sys.path.remove(target) 278 except Exception: 279 pass 280 try: 281 active_venvs_order.remove(venv) 282 except Exception: 283 pass 284 285 return True
Remove a virtual environment from sys.path (if it's been activated).
Parameters
- venv (str, default 'mrsm'): The virtual environment to deactivate.
- color (bool, default True):
If
True, include color in debug text. - debug (bool, default False): Verbosity toggle.
- previously_active_venvs (Union[Set[str], List[str], None]): If provided, skip deactivating if a virtual environment is in this iterable.
- force (bool, default False):
If
True, forcibly deactivate the virtual environment. This may cause issues with other threads, so be careful!
Returns
- Return a bool indicating whether the virtual environment was successfully deactivated.
156def ensure_default_venvs(debug: bool = False) -> bool: 157 """ 158 Put the shared `'mrsm'` virtual environment on `sys.path` (when it exists and is not already 159 there), the way v4 left it for plugin code. 160 161 In v4 every process that ran plugin code had imported `meerschaum` and called 162 `attempt_import(..., venv='mrsm')` long before the first plugin was imported, so the 163 shared `mrsm` venv (where a dependency-free `pip install meerschaum` puts pandas and 164 friends on first use) was always visible: a plugin with its own `required` venv could still 165 `import pandas`. The order v4 produced is: the plugin's venv(s) first, then the base 166 interpreter's site-packages, then `<root>/venvs/mrsm`, then the rest of `sys.path`. The 167 plugin venv is activated later (on top), so this activates `'mrsm'` and then the base 168 interpreter (`venv=None`), exactly as v4's `attempt_import` calls did. 169 170 This never creates a virtual environment and never installs anything 171 (`MRSM_NO_AUTO_INSTALL` is unaffected): without a `mrsm` venv there is nothing to expose. 172 173 Returns 174 ------- 175 A bool indicating whether the `mrsm` venv is on `sys.path` afterwards. 176 """ 177 import sys 178 try: 179 target_path = venv_target_path('mrsm', allow_nonexistent=True, debug=debug) 180 target = target_path.as_posix() 181 if not target_path.exists(): 182 return False 183 with LOCKS['sys.path']: 184 if target not in sys.path: 185 activate_venv('mrsm', init_if_not_exists=False, debug=debug) 186 if target not in sys.path: 187 return False 188 try: 189 base = venv_target_path(None, debug=debug).as_posix() 190 except (Exception, SystemExit): 191 return True 192 ### The base interpreter's site-packages come before the `mrsm` venv (v4: 193 ### `activate_venv(None)` ran after `activate_venv('mrsm')`). 194 index = sys.path.index(target) 195 if base != target and base not in sys.path[:index]: 196 sys.path.insert(index, base) 197 active_venvs.add(None) 198 return True 199 except (Exception, SystemExit): 200 return False
Put the shared 'mrsm' virtual environment on sys.path (when it exists and is not already
there), the way v4 left it for plugin code.
In v4 every process that ran plugin code had imported meerschaum and called
attempt_import(..., venv='mrsm') long before the first plugin was imported, so the
shared mrsm venv (where a dependency-free pip install meerschaum puts pandas and
friends on first use) was always visible: a plugin with its own required venv could still
import pandas. The order v4 produced is: the plugin's venv(s) first, then the base
interpreter's site-packages, then <root>/venvs/mrsm, then the rest of sys.path. The
plugin venv is activated later (on top), so this activates 'mrsm' and then the base
interpreter (venv=None), exactly as v4's attempt_import calls did.
This never creates a virtual environment and never installs anything
(MRSM_NO_AUTO_INSTALL is unaffected): without a mrsm venv there is nothing to expose.
Returns
- A bool indicating whether the
mrsmvenv is onsys.pathafterwards.
990def get_module_venv(module) -> Union[str, None]: 991 """ 992 Return the virtual environment where an imported module is installed. 993 994 Parameters 995 ---------- 996 module: ModuleType 997 The imported module to inspect. 998 999 Returns 1000 ------- 1001 The name of a venv or `None`. 1002 """ 1003 import meerschaum.config.paths as paths 1004 module_path = pathlib.Path(module.__file__).resolve() 1005 try: 1006 rel_path = module_path.relative_to(paths.VIRTENV_RESOURCES_PATH) 1007 except ValueError: 1008 return None 1009 1010 return rel_path.as_posix().split('/', maxsplit=1)[0]
Return the virtual environment where an imported module is installed.
Parameters
- module (ModuleType): The imported module to inspect.
Returns
- The name of a venv or
None.
973def get_venvs() -> List[str]: 974 """ 975 Return a list of all the virtual environments. 976 """ 977 import os 978 import meerschaum.config.paths as paths 979 venvs = [] 980 for filename in os.listdir(paths.VIRTENV_RESOURCES_PATH): 981 path = paths.VIRTENV_RESOURCES_PATH / filename 982 if not path.is_dir(): 983 continue 984 if not venv_exists(filename): 985 continue 986 venvs.append(filename) 987 return venvs
Return a list of all the virtual environments.
499@_locked_venv_mutation 500def init_venv( 501 venv: str = 'mrsm', 502 verify: bool = True, 503 force: bool = False, 504 debug: bool = False, 505) -> bool: 506 """ 507 Initialize the virtual environment. 508 509 Parameters 510 ---------- 511 venv: str, default 'mrsm' 512 The name of the virtual environment to create. 513 514 verify: bool, default True 515 If `True`, verify that the virtual environment is in the expected state. 516 517 force: bool, default False 518 If `True`, recreate the virtual environment, even if already initalized. 519 520 Returns 521 ------- 522 A `bool` indicating success. 523 """ 524 if not force and venv in verified_venvs: 525 return True 526 if not force and venv_exists(venv, debug=debug): 527 if verify: 528 verify_venv(venv, debug=debug) 529 verified_venvs.add(venv) 530 return True 531 532 import io 533 from contextlib import redirect_stdout 534 import sys 535 import platform 536 import os 537 import shutil 538 import time 539 540 import meerschaum.config.paths as paths 541 from meerschaum._internal.static import STATIC_CONFIG 542 from meerschaum.utils.packages import is_uv_enabled 543 544 venv_path = paths.VIRTENV_RESOURCES_PATH / venv 545 vtp = venv_target_path(venv=venv, allow_nonexistent=True, debug=debug) 546 docker_home_venv_path = pathlib.Path('/home/meerschaum/venvs/mrsm') 547 lock_path = paths.VENVS_CACHE_RESOURCES_PATH / (venv + '.lock') 548 work_dir_env_var = STATIC_CONFIG['environment']['work_dir'] 549 550 def update_lock(active: bool): 551 try: 552 if not active: 553 if debug: 554 print(f"Releasing lock: '{lock_path}'") 555 lock_path.unlink() 556 else: 557 if debug: 558 print(f"Acquiring lock: '{lock_path}'") 559 lock_path.touch() 560 except Exception: 561 pass 562 563 def wait_for_lock(): 564 if platform.system() == 'Windows': 565 return 566 max_lock_seconds = 30.0 567 sleep_message_seconds = 5.0 568 step_sleep_seconds = 0.1 569 init_venv_check_start = time.perf_counter() 570 last_print = init_venv_check_start 571 while ((time.perf_counter() - init_venv_check_start) < max_lock_seconds): 572 if not lock_path.exists(): 573 break 574 575 now = time.perf_counter() 576 if debug or (now - last_print) > sleep_message_seconds: 577 print(f"Lock exists for venv '{venv}', sleeping...") 578 last_print = now 579 time.sleep(step_sleep_seconds) 580 update_lock(False) 581 582 if ( 583 not force 584 and venv == 'mrsm' 585 and os.environ.get(work_dir_env_var, None) is not None 586 and docker_home_venv_path.exists() 587 ): 588 wait_for_lock() 589 shutil.move(docker_home_venv_path, venv_path) 590 if verify: 591 verify_venv(venv, debug=debug) 592 verified_venvs.add(venv) 593 return True 594 595 from meerschaum.utils.packages import ( 596 run_python_package, attempt_import, _get_pip_os_env, _get_uv_bin, _auto_install_allowed, 597 ) 598 global tried_virtualenv 599 try: 600 import venv as _venv 601 uv = _get_uv_bin() if is_uv_enabled() else None 602 virtualenv = None 603 except ImportError: 604 _venv = None 605 uv = None 606 virtualenv = None 607 608 _venv_success = False 609 temp_vtp = paths.VENVS_CACHE_RESOURCES_PATH / str(venv) 610 ### NOTE: Disable site-packages movement for now. 611 rename_vtp = False and vtp.exists() and not temp_vtp.exists() 612 613 wait_for_lock() 614 update_lock(True) 615 try: 616 if rename_vtp: 617 if debug: 618 print(f"Moving '{vtp}' to '{temp_vtp}'...") 619 shutil.move(vtp, temp_vtp) 620 621 if uv is not None: 622 # Plugin imports run in this interpreter. Letting uv choose another version 623 # makes verification recreate the venv while its creation lock is still held. 624 _venv_success = run_python_package( 625 'uv', 626 [ 627 'venv', venv_path.as_posix(), '--python', sys.executable, 628 '-q', '--no-project', '--allow-existing', 629 ] + ( 630 ### `--seed` downloads `pip`, `setuptools` and `wheel` into the new venv. 631 ['--seed'] if _auto_install_allowed() else [] 632 ), 633 venv=None, 634 env=_get_pip_os_env(), 635 debug=debug, 636 ) == 0 637 638 if _venv is not None and not _venv_success: 639 f = io.StringIO() 640 with redirect_stdout(f): 641 _venv_success = run_python_package( 642 'venv', 643 [venv_path.as_posix()] + ( 644 ['--symlinks'] 645 if platform.system() != 'Windows' 646 else [] 647 ), 648 venv=None, debug=debug 649 ) == 0 650 if not _venv_success: 651 print(f"Please install python3-venv.\n{f.getvalue()}\nFalling back to virtualenv...") 652 if not venv_exists(venv, debug=debug): 653 _venv = None 654 if not _venv_success: 655 virtualenv = attempt_import( 656 'virtualenv', 657 venv=None, 658 lazy=False, 659 install=(not tried_virtualenv), 660 warn=False, 661 check_update=False, 662 color=False, 663 debug=debug, 664 ) 665 if virtualenv is None: 666 print( 667 "Failed to import `venv` or `virtualenv`! " 668 + "Please install `virtualenv` via pip then restart Meerschaum." 669 ) 670 if rename_vtp and temp_vtp.exists(): 671 if debug: 672 print(f"Moving '{temp_vtp}' back to '{vtp}'...") 673 shutil.move(temp_vtp, vtp) 674 return False 675 676 tried_virtualenv = True 677 try: 678 python_folder = ( 679 'python' + str(sys.version_info.major) + '.' + str(sys.version_info.minor) 680 ) 681 dist_packages_path = ( 682 paths.VIRTENV_RESOURCES_PATH / 683 venv / 'local' / 'lib' / python_folder / 'dist-packages' 684 ) 685 local_bin_path = paths.VIRTENV_RESOURCES_PATH / venv / 'local' / 'bin' 686 bin_path = paths.VIRTENV_RESOURCES_PATH / venv / 'bin' 687 vtp = venv_target_path(venv=venv, allow_nonexistent=True, debug=debug) 688 if bin_path.exists(): 689 try: 690 shutil.rmtree(bin_path) 691 except Exception: 692 import traceback 693 traceback.print_exc() 694 virtualenv.cli_run([venv_path.as_posix()]) 695 if dist_packages_path.exists(): 696 vtp.mkdir(exist_ok=True, parents=True) 697 for file_path in dist_packages_path.glob('*'): 698 shutil.move(file_path, vtp) 699 shutil.rmtree(dist_packages_path) 700 # shutil.move(dist_packages_path, vtp) 701 bin_path.mkdir(exist_ok=True, parents=True) 702 for file_path in local_bin_path.glob('*'): 703 shutil.move(file_path, bin_path) 704 # shutil.move(local_bin_path, bin_path) 705 shutil.rmtree(local_bin_path) 706 707 except Exception: 708 import traceback 709 traceback.print_exc() 710 if rename_vtp and temp_vtp.exists(): 711 shutil.move(temp_vtp, vtp) 712 return False 713 if rename_vtp and temp_vtp.exists(): 714 if debug: 715 print(f"Cleanup: move '{temp_vtp}' back to '{vtp}'.") 716 shutil.move(temp_vtp, vtp) 717 finally: 718 update_lock(False) 719 720 # The mutation lock is reentrant: verification may recreate an incompatible environment. 721 if verify: 722 verify_venv(venv, debug=debug) 723 verified_venvs.add(venv) 724 return True
Initialize the virtual environment.
Parameters
- venv (str, default 'mrsm'): The name of the virtual environment to create.
- verify (bool, default True):
If
True, verify that the virtual environment is in the expected state. - force (bool, default False):
If
True, recreate the virtual environment, even if already initalized.
Returns
- A
boolindicating success.
960def inside_venv() -> bool: 961 """ 962 Determine whether current Python interpreter is running inside a virtual environment. 963 """ 964 import sys 965 return ( 966 hasattr(sys, 'real_prefix') or ( 967 hasattr(sys, 'base_prefix') 968 and sys.base_prefix != sys.prefix 969 ) 970 )
Determine whether current Python interpreter is running inside a virtual environment.
288def is_venv_active( 289 venv: str = 'mrsm', 290 color : bool = True, 291 debug: bool = False 292) -> bool: 293 """ 294 Check if a virtual environment is active. 295 296 Parameters 297 ---------- 298 venv: str, default 'mrsm' 299 The virtual environment to check. 300 301 color: bool, default True 302 If `True`, include color in debug text. 303 304 debug: bool, default False 305 Verbosity toggle. 306 307 Returns 308 ------- 309 A bool indicating whether the virtual environment `venv` is active. 310 311 """ 312 return venv in active_venvs
Check if a virtual environment is active.
Parameters
- venv (str, default 'mrsm'): The virtual environment to check.
- color (bool, default True):
If
True, include color in debug text. - debug (bool, default False): Verbosity toggle.
Returns
- A bool indicating whether the virtual environment
venvis active.
752def venv_exec( 753 code: str, 754 venv: Optional[str] = 'mrsm', 755 env: Optional[Dict[str, str]] = None, 756 with_extras: bool = False, 757 as_proc: bool = False, 758 capture_output: bool = True, 759 debug: bool = False, 760) -> Union[bool, Tuple[int, bytes, bytes], 'subprocess.Popen']: 761 """ 762 Execute Python code in a subprocess via a virtual environment's interpeter. 763 Return `True` if the code successfully executes, `False` on failure. 764 765 Parameters 766 ---------- 767 code: str 768 The Python code to excecute. 769 770 venv: str, default 'mrsm' 771 The virtual environment to use to get the path for the Python executable. 772 If `venv` is `None`, use the default `sys.executable` path. 773 774 env: Optional[Dict[str, str]], default None 775 Optionally specify the environment variables for the subprocess. 776 Defaults to `os.environ`. 777 778 with_extras: bool, default False 779 If `True`, return a tuple of the exit code, stdout bytes, and stderr bytes. 780 781 as_proc: bool, default False 782 If `True`, return the `subprocess.Popen` object instead of executing. 783 784 Returns 785 ------- 786 By default, return a bool indicating success. 787 If `as_proc` is `True`, return a `subprocess.Popen` object. 788 If `with_extras` is `True`, return a tuple of the exit code, stdout bytes, and stderr bytes. 789 790 """ 791 import os 792 import subprocess 793 import platform 794 from meerschaum.utils.debug import dprint 795 from meerschaum.utils.process import _child_processes 796 797 executable = venv_executable(venv=venv) 798 cmd_list = [executable, '-c', code] 799 if env is None: 800 env = os.environ 801 if debug: 802 dprint(str(cmd_list)) 803 if not with_extras and not as_proc: 804 return subprocess.call(cmd_list, env=env) == 0 805 806 stdout, stderr = (None, None) if not capture_output else (subprocess.PIPE, subprocess.PIPE) 807 group_kwargs = ( 808 { 809 'preexec_fn': os.setsid, 810 } if platform.system() != 'Windows' 811 else { 812 'creationflags': CREATE_NEW_PROCESS_GROUP, 813 } 814 ) 815 process = subprocess.Popen( 816 cmd_list, 817 stdout=stdout, 818 stderr=stderr, 819 stdin=sys.stdin, 820 env=env, 821 **group_kwargs 822 ) 823 if as_proc: 824 _child_processes.append(process) 825 return process 826 stdout, stderr = process.communicate() 827 exit_code = process.returncode 828 return exit_code, stdout, stderr
Execute Python code in a subprocess via a virtual environment's interpeter.
Return True if the code successfully executes, False on failure.
Parameters
- code (str): The Python code to excecute.
- venv (str, default 'mrsm'):
The virtual environment to use to get the path for the Python executable.
If
venvisNone, use the defaultsys.executablepath. - env (Optional[Dict[str, str]], default None):
Optionally specify the environment variables for the subprocess.
Defaults to
os.environ. - with_extras (bool, default False):
If
True, return a tuple of the exit code, stdout bytes, and stderr bytes. - as_proc (bool, default False):
If
True, return thesubprocess.Popenobject instead of executing.
Returns
- By default, return a bool indicating success.
- If
as_procisTrue, return asubprocess.Popenobject. - If
with_extrasisTrue, return a tuple of the exit code, stdout bytes, and stderr bytes.
727def venv_executable(venv: Optional[str] = 'mrsm') -> str: 728 """ 729 The Python interpreter executable for a given virtual environment. 730 """ 731 import sys 732 import platform 733 import meerschaum.config.paths as paths 734 return ( 735 sys.executable if venv is None 736 else str( 737 paths.VIRTENV_RESOURCES_PATH 738 / venv 739 / ( 740 'bin' if platform.system() != 'Windows' 741 else 'Scripts' 742 ) / ( 743 'python' 744 + str(sys.version_info.major) 745 + '.' 746 + str(sys.version_info.minor) 747 ) 748 ) 749 )
The Python interpreter executable for a given virtual environment.
831def venv_exists(venv: Union[str, None], debug: bool = False) -> bool: 832 """ 833 Determine whether a virtual environment has been created. 834 """ 835 target_path = venv_target_path(venv, allow_nonexistent=True, debug=debug) 836 return target_path.exists()
Determine whether a virtual environment has been created.
839def venv_target_path( 840 venv: Union[str, None], 841 allow_nonexistent: bool = False, 842 debug: bool = False, 843) -> pathlib.Path: 844 """ 845 Return a virtual environment's site-package path. 846 847 Parameters 848 ---------- 849 venv: Union[str, None] 850 The virtual environment for which a path should be returned. 851 852 allow_nonexistent: bool, default False 853 If `True`, return a path even if it does not exist. 854 855 Returns 856 ------- 857 The `pathlib.Path` object for the virtual environment's path. 858 859 """ 860 import os 861 import sys 862 import platform 863 import site 864 865 import meerschaum.config.paths as paths 866 from meerschaum._internal.static import STATIC_CONFIG 867 868 ### Check sys.path for a user-writable site-packages directory. 869 if venv is None: 870 871 ### Return the known value for the portable environment. 872 environment_runtime = STATIC_CONFIG['environment']['runtime'] 873 if os.environ.get(environment_runtime, None) == 'portable': 874 python_version_folder = ( 875 'python' + str(sys.version_info.major) + '.' + str(sys.version_info.minor) 876 ) 877 executable_path = pathlib.Path(sys.executable) 878 site_packages_path = ( 879 ( 880 executable_path.parent.parent / 'lib' / python_version_folder / 'site-packages' 881 ) if platform.system() != 'Windows' else ( 882 executable_path.parent / 'Lib' / 'site-packages' 883 ) 884 ) 885 if not site_packages_path.exists(): 886 raise EnvironmentError(f"Could not find '{site_packages_path}'. Does it exist?") 887 return site_packages_path 888 889 if not inside_venv(): 890 user_site_packages = site.getusersitepackages() 891 if user_site_packages is None: 892 raise EnvironmentError("Could not determine user site packages.") 893 894 site_path = pathlib.Path(user_site_packages) 895 if not site_path.exists(): 896 897 ### Windows does not have `os.geteuid()`. 898 if platform.system() == 'Windows' or os.geteuid() != 0: 899 site_path.mkdir(parents=True, exist_ok=True) 900 return site_path 901 902 ### Allow for dist-level paths (running as root). 903 for possible_dist in site.getsitepackages(): 904 dist_path = pathlib.Path(possible_dist) 905 if not dist_path.exists(): 906 continue 907 return dist_path 908 909 raise EnvironmentError("Could not determine the dist-packages directory.") 910 911 return site_path 912 913 venv_root_path = ( 914 (paths.VIRTENV_RESOURCES_PATH / venv) 915 if venv is not None 916 else pathlib.Path(sys.prefix) 917 ) 918 target_path = venv_root_path 919 920 ### Ensure 'lib' or 'Lib' exists. 921 lib = 'lib' if platform.system() != 'Windows' else 'Lib' 922 if not allow_nonexistent: 923 if not venv_root_path.exists() or lib not in os.listdir(venv_root_path): 924 print(f"Failed to find lib directory for virtual environment '{venv}'.") 925 import traceback 926 traceback.print_stack() 927 sys.exit(1) 928 target_path = target_path / lib 929 930 ### Check if a 'python3.x' folder exists. 931 python_folder = 'python' + str(sys.version_info.major) + '.' + str(sys.version_info.minor) 932 if target_path.exists(): 933 target_path = ( 934 (target_path / python_folder) 935 if python_folder in os.listdir(target_path) 936 else target_path 937 ) 938 else: 939 target_path = ( 940 (target_path / python_folder) 941 if platform.system() != 'Windows' 942 else target_path 943 ) 944 945 ### Ensure 'site-packages' exists. 946 if allow_nonexistent or 'site-packages' in os.listdir(target_path): ### Windows 947 target_path = target_path / 'site-packages' 948 else: 949 import traceback 950 traceback.print_stack() 951 print(f"Failed to find site-packages directory for virtual environment '{venv}'.") 952 print("This may be because you are using a different Python version.") 953 print("Try deleting the following directory and restarting Meerschaum:") 954 print(paths.VIRTENV_RESOURCES_PATH) 955 sys.exit(1) 956 957 return target_path
Return a virtual environment's site-package path.
Parameters
- venv (Union[str, None]): The virtual environment for which a path should be returned.
- allow_nonexistent (bool, default False):
If
True, return a path even if it does not exist.
Returns
- The
pathlib.Pathobject for the virtual environment's path.
358@_locked_venv_mutation 359def verify_venv( 360 venv: str, 361 debug: bool = False, 362) -> None: 363 """ 364 Verify that the virtual environment matches the expected state. 365 """ 366 import platform 367 import os 368 import sys 369 370 import meerschaum.config.paths as paths 371 from meerschaum.utils.process import run_process 372 from meerschaum.utils.misc import make_symlink, is_symlink 373 from meerschaum.utils.warnings import warn 374 375 venv_path = paths.VIRTENV_RESOURCES_PATH / venv 376 bin_path = venv_path / ( 377 'bin' if platform.system() != 'Windows' else "Scripts" 378 ) 379 current_python_versioned_name = ( 380 'python' + str(sys.version_info.major) + '.' + str(sys.version_info.minor) 381 + ('' if platform.system() != 'Windows' else '.exe') 382 ) 383 384 if not (bin_path / current_python_versioned_name).exists(): 385 init_venv(venv, verify=False, force=True, debug=debug) 386 current_python_in_venv_path = pathlib.Path(venv_executable(venv=venv)) 387 current_python_in_sys_path = pathlib.Path(venv_executable(venv=None)) 388 if not current_python_in_venv_path.exists(): 389 if is_symlink(current_python_in_venv_path): 390 try: 391 current_python_in_venv_path.unlink() 392 except Exception as e: 393 print(f"Unable to remove symlink {current_python_in_venv_path}:\n{e}") 394 try: 395 make_symlink(current_python_in_sys_path, current_python_in_venv_path) 396 except Exception: 397 print( 398 f"Unable to create symlink {current_python_in_venv_path} " 399 + f"to {current_python_in_sys_path}." 400 ) 401 files_to_inspect = sorted(os.listdir(bin_path), reverse=True) 402 else: 403 files_to_inspect = [current_python_versioned_name] 404 405 def get_python_version(python_path: pathlib.Path) -> Union[str, None]: 406 """ 407 Return the version for the python binary at the given path. 408 """ 409 proc = None 410 try: 411 ### It might be a broken symlink, so skip on errors. 412 if debug: 413 print(f"Getting python version for {python_path}") 414 proc = run_process( 415 [str(python_path), '-V'], 416 as_proc=True, 417 capture_output=True, 418 ) 419 stdout, stderr = proc.communicate(timeout=1.0) 420 except Exception as e: 421 # A loaded host may exceed the probe timeout with a valid interpreter. Reap 422 # that child, and only remove aliases whose targets are actually missing. 423 if proc is not None and proc.poll() is None: 424 proc.kill() 425 proc.communicate() 426 if is_symlink(python_path) and not python_path.exists(): 427 try: 428 python_path.unlink() 429 except Exception as _e: 430 print(f"Unable to remove broken symlink {python_path}:\n{e}\n{_e}") 431 return None 432 return stdout.decode('utf-8').strip().replace('Python ', '') 433 434 ### Ensure the versions are symlinked correctly. 435 for filename in files_to_inspect: 436 if not filename.startswith('python'): 437 continue 438 python_path = bin_path / filename 439 version = get_python_version(python_path) 440 if version is None: 441 continue 442 try: 443 major_version = version.split('.', maxsplit=1)[0] 444 minor_version = version.split('.', maxsplit=2)[1] 445 except IndexError: 446 return 447 python_versioned_name = ( 448 'python' + major_version + '.' + minor_version 449 + ('' if platform.system() != 'Windows' else '.exe') 450 ) 451 452 ### E.g. python3.10 actually links to Python 3.10. 453 if filename == python_versioned_name: 454 try: 455 real_path = pathlib.Path(os.path.realpath(python_path)) 456 real_path_exists = real_path.exists() 457 except Exception: 458 real_path_exists = False 459 460 if not real_path_exists: 461 try: 462 python_path.unlink() 463 except Exception: 464 pass 465 init_venv(venv, verify=False, force=True, debug=debug) 466 if not python_path.exists(): 467 raise FileNotFoundError(f"Unable to verify Python symlink:\n{python_path}") 468 469 if python_path == real_path: 470 continue 471 472 # Already-normalized aliases need no mutation. For indirect aliases, replace 473 # atomically so another process may keep executing this interpreter throughout. 474 if python_path.is_symlink() and python_path.readlink() == real_path: 475 continue 476 import uuid 477 temporary_path = python_path.with_name('.' + filename + '.' + uuid.uuid4().hex) 478 try: 479 success, msg = make_symlink(real_path, temporary_path) 480 if success: 481 os.replace(temporary_path, python_path) 482 else: 483 warn(msg, color=False) 484 except OSError as e: 485 warn(str(e), color=False) 486 finally: 487 temporary_path.unlink(missing_ok=True) 488 continue 489 490 python_versioned_path = bin_path / python_versioned_name 491 if python_versioned_path.exists(): 492 ### Avoid circular symlinks. 493 if get_python_version(python_versioned_path) == version: 494 continue 495 os.replace(python_path, python_versioned_path)
Verify that the virtual environment matches the expected state.