@@ -858,30 +858,18 @@ def _show_version_details(self, package_name: str, version: str):
858858 print (f" { display_name .ljust (18 )} : { value } " )
859859
860860 print (f"\n 💡 For all raw data, use Redis key: \" { version_key } \" " )
861-
862- def _sort_packages_newest_first (self , packages : List [str ]) -> List [str ]:
863- """Sorts packages by version, newest first, to ensure proper bubble creation."""
864- from packaging .version import parse as parse_version , InvalidVersion
865- import re
866-
867- def get_version_key (pkg_spec ):
868- """Extracts a sortable version key from a package spec."""
869- # Match version strings like ==, >=, <, etc.
870- match = re .search (r'(==|>=|<=|>|<)(.+)' , pkg_spec )
871- if match :
872- version_str = match .group (2 ).strip ()
873- try :
874- # Return a valid version object for sorting
875- return parse_version (version_str )
876- except InvalidVersion :
877- # If version is weird (e.g., a URL), treat it as very old
878- return parse_version ('0.0.0' )
879- # If no version is specified, treat it as the "newest possible"
880- return parse_version ('9999.0.0' )
881861
882- # Sort the list of packages. The key function determines the order.
883- # `reverse=True` puts the newest (highest) versions first.
884- return sorted (packages , key = get_version_key , reverse = True )
862+ def _save_last_known_good_snapshot (self ):
863+ """Saves the current environment state to Redis."""
864+ print ("📸 Saving snapshot of the current environment as 'last known good'..." )
865+ try :
866+ current_state = self .get_installed_packages (live = True )
867+ snapshot_key = f"{ self .config ['redis_key_prefix' ]} snapshot:last_known_good"
868+ # We store the package list as a JSON string
869+ self .redis_client .set (snapshot_key , json .dumps (current_state ))
870+ print (" ✅ Snapshot saved." )
871+ except Exception as e :
872+ print (f" ⚠️ Could not save environment snapshot: { e } " )
885873
886874 def smart_install (self , packages : List [str ], dry_run : bool = False ) -> int :
887875 if not self .connect_redis ():
@@ -890,64 +878,58 @@ def smart_install(self, packages: List[str], dry_run: bool = False) -> int:
890878 if dry_run :
891879 print ("🔬 Running in --dry-run mode. No changes will be made." )
892880 return 0
893-
894- # Sort packages newest to oldest to ensure proper bubble creation
895- sorted_packages = self ._sort_packages_newest_first (packages )
896-
897- if sorted_packages != packages :
898- print (f"🔄 Reordered packages for optimal installation: { ', ' .join (sorted_packages )} " )
899-
900- # Process each package individually to handle version conflicts
901- for package_spec in sorted_packages :
902- print ("\n " + "─" * 60 )
903- print (f"📦 Processing: { package_spec } " )
904- print ("─" * 60 )
905-
906- satisfaction_check = self ._check_package_satisfaction ([package_spec ])
907881
908- if satisfaction_check ['all_satisfied' ]:
909- print (f"✅ Requirement already satisfied: { package_spec } " )
910- continue
882+ print (f"🔍 Checking satisfaction for: { ', ' .join (packages )} " )
911883
912- packages_to_install = satisfaction_check ['needs_install' ]
913-
914- print ("\n 📸 Taking LIVE pre-installation snapshot..." )
915- packages_before = self .get_installed_packages (live = True )
916- print (f" - Found { len (packages_before )} packages" )
884+ satisfaction_check = self ._check_package_satisfaction (packages )
917885
918- print (f"\n ⚙️ Running pip install for: { ', ' .join (packages_to_install )} ..." )
919- return_code = self ._run_pip_install (packages_to_install )
920-
921- if return_code != 0 :
922- print (f"❌ Pip installation for { package_spec } failed. Continuing with next package." )
923- continue
886+ if satisfaction_check ['all_satisfied' ]:
887+ print ("\n ✅ All requirements already satisfied. No work needed. 🛡️" )
888+ return 0
924889
925- print ("\n 🔬 Analyzing post-installation changes..." )
926- packages_after = self .get_installed_packages (live = True )
927- # THIS IS THE NEW LINE: Capture the state containing the new version
928- packages_to_index = packages_after
929- downgrades_to_fix = self ._detect_downgrades (packages_before , packages_after )
930-
931- if downgrades_to_fix :
932- print ("\n 🛡️ DOWNGRADE PROTECTION ACTIVATED!" )
933- for fix in downgrades_to_fix :
934- print (f" -> Fixing downgrade: { fix ['package' ]} from v{ fix ['good_version' ]} to v{ fix ['bad_version' ]} " )
935- self .bubble_manager .create_isolated_bubble (fix ['package' ], fix ['bad_version' ])
936- print (f" 🔄 Restoring '{ fix ['package' ]} ' to safe version v{ fix ['good_version' ]} in main environment..." )
937- subprocess .run ([self .config ["python_executable" ], "-m" , "pip" , "install" , "--quiet" , f"{ fix ['package' ]} =={ fix ['good_version' ]} " ], capture_output = True , text = True )
938- print ("\n ✅ Environment protection complete!" )
939- else :
940- print ("✅ No downgrades detected. Installation completed safely." )
890+ packages_to_install = satisfaction_check ['needs_install' ]
891+
892+ if satisfaction_check ['partial_satisfied' ]:
893+ print ("\n ℹ️ Some packages already satisfied:" )
894+ for pkg in satisfaction_check ['satisfied' ]:
895+ print (f" ✅ { pkg } " )
896+ print ("\n 📦 Proceeding to install remaining packages:" )
897+ for pkg in packages_to_install :
898+ print (f" 📥 { pkg } " )
899+
900+ print ("\n 📸 Taking LIVE pre-installation snapshot..." )
901+ packages_before = self .get_installed_packages (live = True )
902+ print (f" - Found { len (packages_before )} packages" )
903+
904+ print (f"\n ⚙️ Running pip install for: { ', ' .join (packages_to_install )} ..." )
905+ return_code = self ._run_pip_install (packages_to_install )
906+
907+ if return_code != 0 :
908+ print ("❌ Pip installation failed. Aborting cleanup." )
909+ return return_code
910+
911+ print ("\n 🔬 Analyzing post-installation changes..." )
912+ packages_after = self .get_installed_packages (live = True )
913+ downgrades_to_fix = self ._detect_downgrades (packages_before , packages_after )
914+
915+ if downgrades_to_fix :
916+ print ("\n 🛡️ DOWNGRADE PROTECTION ACTIVATED!" )
917+ for fix in downgrades_to_fix :
918+ print (f" -> Fixing downgrade: { fix ['package' ]} from v{ fix ['good_version' ]} to v{ fix ['bad_version' ]} " )
919+ self .bubble_manager .create_isolated_bubble (fix ['package' ], fix ['bad_version' ])
920+ print (f" 🔄 Restoring '{ fix ['package' ]} ' to safe version v{ fix ['good_version' ]} in main environment..." )
921+ subprocess .run ([self .config ["python_executable" ], "-m" , "pip" , "install" , f"{ fix ['package' ]} =={ fix ['good_version' ]} " ], capture_output = True , text = True )
922+ print ("\n ✅ Environment protection complete!" )
923+ else :
924+ print ("✅ No downgrades detected. Installation completed safely." )
941925
942- print ("\n 🧠 Updating knowledge base with final environment state..." )
943- self ._run_metadata_builder_for_delta (packages_before , packages_to_index )
926+ print ("\n 🧠 Updating knowledge base with final environment state..." )
927+ self ._run_metadata_builder_for_delta (packages_before , packages_after )
928+ self ._update_hash_index_for_delta (packages_before , packages_after )
929+ self ._save_last_known_good_snapshot ()
944930
945- final_packages_state = self .get_installed_packages (live = True )
946- self ._update_hash_index_for_delta (packages_before , final_packages_state )
931+ return 0
947932
948- print ("\n " + "=" * 60 )
949- print ("🎉 All package operations complete." )
950-
951933 def _find_package_installations (self , package_name : str ) -> List [Dict ]:
952934 """Find all installations of a package, both active and bubbled."""
953935 found = []
@@ -1042,80 +1024,104 @@ def smart_uninstall(self, packages: List[str], force: bool = False) -> int:
10421024 pipe .execute ()
10431025
10441026 print ("✅ Uninstallation complete." )
1027+
1028+ self ._save_last_known_good_snapshot ()
1029+
10451030 return 0
1031+
1032+ def revert_to_last_known_good (self , force : bool = False ):
1033+ """Compares the current env to the last snapshot and restores it."""
1034+ if not self .connect_redis (): return 1
10461035
1047- def _check_package_satisfaction (self , packages : List [str ]) -> dict :
1048- """Check satisfaction with bubble pre-check optimization"""
1049- satisfied = set ()
1050- remaining_packages = []
1036+ snapshot_key = f"{ self .config ['redis_key_prefix' ]} snapshot:last_known_good"
1037+ snapshot_data = self .redis_client .get (snapshot_key )
1038+
1039+ if not snapshot_data :
1040+ print ("❌ No 'last known good' snapshot found. Cannot revert." )
1041+ print (" Run an `omnipkg install` or `omnipkg uninstall` command to create one." )
1042+ return 1
1043+
1044+ print ("⚖️ Comparing current environment to the last known good snapshot..." )
1045+ snapshot_state = json .loads (snapshot_data )
1046+ current_state = self .get_installed_packages (live = True )
1047+
1048+ # Calculate the "diff"
1049+ snapshot_keys = set (snapshot_state .keys ())
1050+ current_keys = set (current_state .keys ())
1051+
1052+ to_install = [f"{ pkg } =={ ver } " for pkg , ver in snapshot_state .items () if pkg not in current_keys ]
1053+ to_uninstall = [pkg for pkg in current_keys if pkg not in snapshot_keys ]
1054+ to_fix = [f"{ pkg } =={ snapshot_state [pkg ]} " for pkg in (snapshot_keys & current_keys ) if snapshot_state [pkg ] != current_state [pkg ]]
10511055
1052- # FAST PATH: Check for pre-existing bubbles BEFORE calling pip
1053- for pkg_spec in packages :
1054- try :
1055- if '==' in pkg_spec :
1056- pkg_name , version = pkg_spec .split ('==' , 1 ) # Handle edge cases like pkg==1.0.0==extra
1057- bubble_path = self .multiversion_base / f"{ pkg_name } -{ version } "
1058- if bubble_path .exists () and bubble_path .is_dir ():
1059- satisfied .add (pkg_spec )
1060- print (f" ⚡ Found existing bubble: { pkg_spec } " )
1061- continue
1062- # Not version-pinned or no bubble found - needs pip check
1063- remaining_packages .append (pkg_spec )
1064- except ValueError :
1065- # Malformed spec, let pip handle it
1066- remaining_packages .append (pkg_spec )
1056+ if not to_install and not to_uninstall and not to_fix :
1057+ print ("✅ Your environment is already in the last known good state. No action needed." )
1058+ return 0
10671059
1068- # Early return if all packages have bubbles
1069- if not remaining_packages :
1070- return {
1071- 'all_satisfied' : True ,
1072- 'satisfied' : sorted (list (satisfied )),
1073- 'needs_install' : []
1074- }
1060+ print ("\n 📝 The following actions will be taken to restore the environment:" )
1061+ if to_uninstall :
1062+ print (f" - Uninstall: { ', ' .join (to_uninstall )} " )
1063+ if to_install :
1064+ print (f" - Install: { ', ' .join (to_install )} " )
1065+ if to_fix :
1066+ print (f" - Fix Version: { ', ' .join (to_fix )} " )
1067+
1068+ if not force :
1069+ confirm = input ("\n 🤔 Are you sure you want to proceed? (y/N): " ).lower ().strip ()
1070+ if confirm != 'y' :
1071+ print ("🚫 Revert cancelled." )
1072+ return 1
1073+
1074+ print ("\n 🚀 Starting revert operation..." )
1075+ if to_uninstall :
1076+ self .smart_uninstall (to_uninstall , force = True )
10751077
1076- # SLOW PATH: Only call pip for packages without bubbles
1078+ packages_to_install = to_install + to_fix
1079+ if packages_to_install :
1080+ self .smart_install (packages_to_install )
1081+
1082+ print ("\n ✅ Environment successfully reverted to the last known good state." )
1083+ return 0
1084+
1085+ def _check_package_satisfaction (self , packages : List [str ]) -> dict :
10771086 req_file_path = None
10781087 try :
10791088 with tempfile .NamedTemporaryFile (mode = 'w' , suffix = '.txt' , delete = False ) as f :
1080- f .write ("\n " .join (remaining_packages ))
1089+ f .write ("\n " .join (packages ))
10811090 req_file_path = f .name
1082-
1091+
10831092 cmd = [self .config ["python_executable" ], "-m" , "pip" , "install" , "--dry-run" , "-r" , req_file_path ]
10841093 result = subprocess .run (cmd , capture_output = True , text = True , timeout = 30 )
1085-
1086- # Parse pip output for additional satisfied packages
1094+
1095+ satisfied = set ()
10871096 output_lines = result .stdout .splitlines ()
1097+
10881098 for line in output_lines :
10891099 if line .startswith ("Requirement already satisfied:" ):
10901100 try :
10911101 satisfied_spec = line .split ("Requirement already satisfied: " )[1 ].strip ()
10921102 req_name = satisfied_spec .split ('==' )[0 ].lower ()
1093- for user_req in remaining_packages :
1103+ for user_req in packages :
10941104 if user_req .lower ().startswith (req_name ):
10951105 satisfied .add (user_req )
1096- except ( IndexError , AttributeError ) :
1106+ except IndexError :
10971107 continue
1098-
1108+
10991109 needs_install = [pkg for pkg in packages if pkg not in satisfied ]
1110+
11001111 return {
11011112 'all_satisfied' : len (needs_install ) == 0 ,
11021113 'partial_satisfied' : len (satisfied ) > 0 and len (needs_install ) > 0 ,
11031114 'satisfied' : sorted (list (satisfied )),
11041115 'needs_install' : needs_install
11051116 }
1106-
1117+
11071118 except Exception as e :
1108- print (f" ⚠️ Satisfaction check failed ({ e } ). Assuming remaining packages need installation." )
1109- return {
1110- 'all_satisfied' : False ,
1111- 'partial_satisfied' : len (satisfied ) > 0 ,
1112- 'satisfied' : sorted (list (satisfied )),
1113- 'needs_install' : remaining_packages
1114- }
1119+ print (f" ⚠️ Satisfaction check failed ({ e } ). Assuming all packages need installation." )
1120+ return {'all_satisfied' : False , 'partial_satisfied' : False , 'satisfied' : [], 'needs_install' : packages }
11151121 finally :
11161122 if req_file_path and Path (req_file_path ).exists ():
11171123 Path (req_file_path ).unlink ()
1118-
1124+
11191125 def get_package_info (self , package_name : str , version : str ) -> Optional [Dict ]:
11201126 if not self .redis_client : self .connect_redis ()
11211127
0 commit comments