from typing import Dict, List, Tuple, Any, Callable, TypeVar, Generic, NamedTuple from packaging.utils import canonicalize_name, canonicalize_version T = TypeVar("T") # For the package data type K = TypeVar("K") # For the key type V = TypeVar("V") # For the value type class PackageLocation(NamedTuple): """ Composite key representing package name and location. """ name: str location: str class EnvironmentDiffTracker(Generic[T, K, V]): """ Generic utility class to track changes in environment states before and after operations. Can be used with any environment management system (pip, npm, apt, docker, etc.). """ def __init__( self, key_extractor: Callable[[T], K], value_extractor: Callable[[T], V], ) -> None: """ Initialize a new environment diff tracker. Args: key_extractor: Function to extract the item identifier from an entry value_extractor: Function to extract the version or other value to compare normalize_key: Optional function to normalize keys (e.g., make lowercase) """ self._key_extractor = key_extractor self._value_extractor = value_extractor self._before_items: Dict[K, V] = {} self._after_items: Dict[K, V] = {} def set_before_state(self, items_data: List[T]) -> None: """ Set the before-operation environment state. Args: items_data: List of items in the format specific to the environment """ self._before_items = self._normalize_items_data(items_data) def set_after_state(self, items_data: List[T]) -> None: """ Set the after-operation environment state. Args: items_data: List of items in the format specific to the environment """ self._after_items = self._normalize_items_data(items_data) def get_diff(self) -> Tuple[Dict[K, V], Dict[K, V], Dict[K, Tuple[V, V]]]: """ Compute the difference between before and after environment states. Returns: Tuple containing: - Dictionary of added items {key: value} - Dictionary of removed items {key: value} - Dictionary of updated items {key: (old_value, new_value)} """ before_keys = set(self._before_items.keys()) after_keys = set(self._after_items.keys()) # Find added and removed items added_keys = after_keys - before_keys removed_keys = before_keys - after_keys # Find updated items (same key, different value) common_keys = before_keys & after_keys updated_keys = { key: (self._before_items[key], self._after_items[key]) for key in common_keys if self._before_items[key] != self._after_items[key] } # Create result dictionaries added = {key: self._after_items[key] for key in added_keys} removed = {key: self._before_items[key] for key in removed_keys} updated = {key: updated_keys[key] for key in updated_keys} return added, removed, updated def _normalize_items_data(self, items_data: List[T]) -> Dict[K, V]: """ Normalize items data into a standardized dictionary format. Args: items_data: List of item data entries Returns: Dict mapping normalized item keys to their values """ result = {} for item_info in items_data: try: key = self._key_extractor(item_info) value = self._value_extractor(item_info) result[key] = value except (KeyError, TypeError, AttributeError): # Skip entries that don't have the expected structure continue return result class PipEnvironmentDiffTracker( EnvironmentDiffTracker[Dict[str, Any], PackageLocation, str] ): """ Specialized diff tracker for pip package environments. """ def __init__(self): super().__init__( key_extractor=self._pip_key_extractor, value_extractor=self._pip_value_extractor, ) # TODO: handle errors in value extraction def _pip_key_extractor(self, pkg: Dict[str, Any]) -> PackageLocation: return PackageLocation( name=canonicalize_name(pkg.get("name", "")), location=pkg.get("location", ""), ) def _pip_value_extractor(self, pkg: Dict[str, Any]) -> str: return canonicalize_version(pkg.get("version", ""), strip_trailing_zero=False) class NpmEnvironmentDiffTracker( EnvironmentDiffTracker[Dict[str, Any], PackageLocation, str] ): """ Specialized diff tracker for npm package environments. """ def __init__(self): super().__init__( key_extractor=self._npm_key_extractor, value_extractor=self._npm_value_extractor, ) # TODO: handle errors in value extraction def _npm_key_extractor(self, pkg: Dict[str, Any]) -> PackageLocation: return PackageLocation( name=pkg.get("name", ""), location=pkg.get("location", ""), ) def _npm_value_extractor(self, pkg: Dict[str, Any]) -> str: return pkg.get("version", "")