318 lines
14 KiB
Python
318 lines
14 KiB
Python
import subprocess
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from typing import List, Dict, Set, Optional
|
||
from enum import Enum
|
||
import logging
|
||
|
||
|
||
from pythonapp.Decider.Loader import RequirementInfo
|
||
|
||
# Настройка логирования
|
||
logging.basicConfig(level=logging.INFO)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
|
||
|
||
|
||
|
||
@dataclass
|
||
class ConflictGrubber:
|
||
"""Класс для разрешения конфликтов зависимостей."""
|
||
conflict_errors: List[ConflictError] = field(default_factory=list)
|
||
max_attempts: int = 10
|
||
|
||
|
||
|
||
def check_for_conflicts(self,
|
||
env,
|
||
installed_reqs: List[RequirementInfo],
|
||
requested_reqs: List[RequirementInfo],
|
||
repo_url: str) -> List[ConflictError]:
|
||
"""
|
||
Проверяет конфликты зависимостей между установленными и запрашиваемыми пакетами.
|
||
|
||
Args:
|
||
env: Виртуальное окружение
|
||
installed_reqs: Список установленных пакетов
|
||
requested_reqs: Список запрашиваемых пакетов
|
||
repo_url: URL репозитория пакетов
|
||
|
||
Returns:
|
||
Список ошибок конфликтов
|
||
"""
|
||
self.conflict_errors.clear()
|
||
current_requested = requested_reqs.copy()
|
||
attempt = 0
|
||
|
||
# Создаем словари для быстрого доступа
|
||
installed_dict = {req.package_name: req for req in installed_reqs}
|
||
requested_dict = {req.package_name: req for req in current_requested}
|
||
|
||
while attempt < self.max_attempts:
|
||
attempt += 1
|
||
logger.info(f"Попытка разрешения конфликтов #{attempt}")
|
||
|
||
# Формируем команду для dry-run
|
||
all_requirements = [req.requirement_str for req in installed_reqs + current_requested]
|
||
|
||
# Выполняем dry-run установки
|
||
result = self._run_dry_install(env, all_requirements, repo_url)
|
||
|
||
# Анализируем результат
|
||
errors = self._parse_pip_output(result.stderr, result.stdout)
|
||
|
||
if not errors:
|
||
logger.info("Конфликты не обнаружены")
|
||
break
|
||
|
||
# Обрабатываем ошибки
|
||
if not self._process_errors(errors, installed_dict, requested_dict, current_requested):
|
||
logger.warning("Обнаружены неустранимые конфликты")
|
||
break
|
||
|
||
return self.conflict_errors
|
||
|
||
def _run_dry_install(self, env, requirements: List[str], repo_url: str) -> subprocess.CompletedProcess:
|
||
"""Выполняет dry-run установки пакетов."""
|
||
command = [env.pip_path, "install", "--dry-run", "--no-cache-dir"]
|
||
command.extend(requirements)
|
||
if repo_url:
|
||
command.extend(["--extra-index-url", repo_url])
|
||
|
||
logger.debug(f"Выполнение команды: {' '.join(command)}")
|
||
return subprocess.run(command, capture_output=True, text=True, timeout=300)
|
||
|
||
def _parse_pip_output(self, stderr: str, stdout: str) -> List[Dict]:
|
||
"""Анализирует вывод pip и извлекает информацию о конфликтах."""
|
||
errors = []
|
||
output = stderr + stdout
|
||
|
||
# Ищем конфликты версий
|
||
version_conflicts = re.findall(
|
||
r'ERROR: Cannot install ([^\n]+) because these package versions have conflicting dependencies',
|
||
output
|
||
)
|
||
|
||
for conflict in version_conflicts:
|
||
packages = re.findall(r'([a-zA-Z0-9_\-\.]+)==([a-zA-Z0-9_\-\.]+)', conflict)
|
||
if packages:
|
||
error_info = {
|
||
'type': 'version_conflict',
|
||
'packages': {pkg: ver for pkg, ver in packages},
|
||
'message': f"Конфликт версий: {conflict}"
|
||
}
|
||
errors.append(error_info)
|
||
|
||
# Ищем несовместимые требования
|
||
requirement_conflicts = re.findall(
|
||
r'ERROR: ([a-zA-Z0-9_\-\.]+) ([^\n]+) has requirement ([^\n]+), but you\'ll have ([^\n]+) which is incompatible',
|
||
output
|
||
)
|
||
|
||
for pkg, version, required, actual in requirement_conflicts:
|
||
error_info = {
|
||
'type': 'requirement_conflict',
|
||
'package': pkg.strip(),
|
||
'required': required.strip(),
|
||
'actual': actual.strip(),
|
||
'message': f"{pkg} {version} требует {required}, но установлена {actual}"
|
||
}
|
||
errors.append(error_info)
|
||
|
||
return errors
|
||
|
||
def _process_errors(self, errors: List[Dict],
|
||
installed_dict: Dict[str, RequirementInfo],
|
||
requested_dict: Dict[str, RequirementInfo],
|
||
current_requested: List[RequirementInfo]) -> bool:
|
||
"""Обрабатывает ошибки и пытается их разрешить."""
|
||
resolved = True
|
||
|
||
for error in errors:
|
||
if error['type'] == 'version_conflict':
|
||
conflict_resolved = self._handle_version_conflict(
|
||
error, installed_dict, requested_dict, current_requested
|
||
)
|
||
resolved = resolved and conflict_resolved
|
||
|
||
elif error['type'] == 'requirement_conflict':
|
||
conflict_resolved = self._handle_requirement_conflict(
|
||
error, installed_dict, requested_dict, current_requested
|
||
)
|
||
resolved = resolved and conflict_resolved
|
||
|
||
return resolved
|
||
|
||
def _handle_version_conflict(self, error: Dict,
|
||
installed_dict: Dict[str, RequirementInfo],
|
||
requested_dict: Dict[str, RequirementInfo],
|
||
current_requested: List[RequirementInfo]) -> bool:
|
||
"""Обрабатывает конфликт версий."""
|
||
conflicting_packages = set(error['packages'].keys())
|
||
sources = {}
|
||
severity = None
|
||
|
||
# Определяем источники пакетов и их значимость
|
||
for pkg in conflicting_packages:
|
||
if pkg in installed_dict:
|
||
sources[pkg] = 'installed'
|
||
elif pkg in requested_dict:
|
||
sources[pkg] = 'requested'
|
||
else:
|
||
sources[pkg] = 'unknown'
|
||
|
||
# Определяем серьезность конфликта
|
||
installed_conflicts = [pkg for pkg in conflicting_packages if sources[pkg] == 'installed']
|
||
requested_conflicts = [pkg for pkg in conflicting_packages if sources[pkg] == 'requested']
|
||
|
||
if len(installed_conflicts) >= 2:
|
||
severity = ConflictSeverity.FATAL
|
||
elif installed_conflicts and requested_conflicts:
|
||
# Проверяем значимость пакетов
|
||
installed_severity = max(
|
||
installed_dict[pkg].significance_level for pkg in installed_conflicts
|
||
)
|
||
requested_severity = max(
|
||
requested_dict[pkg].significance_level for pkg in requested_conflicts
|
||
)
|
||
|
||
if installed_severity == 'required' and requested_severity == 'required':
|
||
severity = ConflictSeverity.FATAL
|
||
elif installed_severity == 'required' or requested_severity == 'required':
|
||
severity = ConflictSeverity.ERROR
|
||
else:
|
||
severity = ConflictSeverity.WARNING
|
||
else:
|
||
# Все пакеты запрашиваемые
|
||
severities = [requested_dict[pkg].significance_level for pkg in requested_conflicts]
|
||
if 'required' in severities:
|
||
severity = ConflictSeverity.ERROR
|
||
else:
|
||
severity = ConflictSeverity.WARNING
|
||
|
||
# Создаем запись об ошибке
|
||
conflict_error = ConflictError(
|
||
conflicting_packages=conflicting_packages,
|
||
sources=sources,
|
||
severity=severity,
|
||
error_message=error['message']
|
||
)
|
||
|
||
self.conflict_errors.append(conflict_error)
|
||
|
||
# Пытаемся разрешить конфликт
|
||
if severity == ConflictSeverity.ERROR:
|
||
# Удаляем опциональные пакеты из запрашиваемых
|
||
optional_packages = [
|
||
pkg for pkg in requested_conflicts
|
||
if requested_dict[pkg].significance_level == 'optional'
|
||
]
|
||
|
||
if optional_packages:
|
||
for pkg in optional_packages:
|
||
self._remove_package(pkg, requested_dict, current_requested)
|
||
conflict_error.resolution_action = f"Удален опциональный пакет {pkg}"
|
||
return True
|
||
|
||
elif severity == ConflictSeverity.WARNING:
|
||
# Удаляем один из опциональных пакетов
|
||
if requested_conflicts:
|
||
pkg_to_remove = requested_conflicts[0]
|
||
self._remove_package(pkg_to_remove, requested_dict, current_requested)
|
||
conflict_error.resolution_action = f"Удален опциональный пакет {pkg_to_remove}"
|
||
return True
|
||
|
||
# Неустранимый конфликт
|
||
conflict_error.resolution_action = "Требует ручного вмешательства"
|
||
return False
|
||
|
||
def _handle_requirement_conflict(self, error: Dict,
|
||
installed_dict: Dict[str, RequirementInfo],
|
||
requested_dict: Dict[str, RequirementInfo],
|
||
current_requested: List[RequirementInfo]) -> bool:
|
||
"""Обрабатывает конфликт требований."""
|
||
pkg = error['package']
|
||
sources = {pkg: 'requested' if pkg in requested_dict else 'installed'}
|
||
severity = None
|
||
|
||
# Определяем серьезность конфликта
|
||
if pkg in installed_dict:
|
||
pkg_severity = installed_dict[pkg].significance_level
|
||
else:
|
||
pkg_severity = requested_dict[pkg].significance_level
|
||
|
||
if pkg_severity == 'required':
|
||
severity = ConflictSeverity.ERROR
|
||
else:
|
||
severity = ConflictSeverity.WARNING
|
||
|
||
# Создаем запись об ошибке
|
||
conflict_error = ConflictError(
|
||
conflicting_packages={pkg},
|
||
sources=sources,
|
||
severity=severity,
|
||
error_message=error['message']
|
||
)
|
||
|
||
self.conflict_errors.append(conflict_error)
|
||
|
||
# Пытаемся разрешить конфликт
|
||
if severity == ConflictSeverity.ERROR and pkg in requested_dict:
|
||
# Для требуемых пакетов пытаемся найти совместимую версию
|
||
compatible = self._find_compatible_version(pkg, error['required'], requested_dict)
|
||
if compatible:
|
||
self._update_package_version(pkg, compatible, requested_dict, current_requested)
|
||
conflict_error.resolution_action = f"Обновлена версия {pkg} до {compatible}"
|
||
return True
|
||
else:
|
||
# Не удалось найти совместимую версию
|
||
conflict_error.resolution_action = "Не удалось найти совместимую версию"
|
||
return False
|
||
elif severity == ConflictSeverity.WARNING and pkg in requested_dict:
|
||
# Удаляем опциональный пакет
|
||
self._remove_package(pkg, requested_dict, current_requested)
|
||
conflict_error.resolution_action = f"Удален опциональный пакет {pkg}"
|
||
return True
|
||
|
||
return False
|
||
|
||
def _remove_package(self, package_name: str,
|
||
requested_dict: Dict[str, RequirementInfo],
|
||
current_requested: List[RequirementInfo]):
|
||
"""Удаляет пакет из списка запрашиваемых."""
|
||
if package_name in requested_dict:
|
||
package_to_remove = requested_dict[package_name]
|
||
current_requested.remove(package_to_remove)
|
||
del requested_dict[package_name]
|
||
logger.info(f"Удален пакет: {package_name}")
|
||
|
||
def _update_package_version(self, package_name: str, new_version: str,
|
||
requested_dict: Dict[str, RequirementInfo],
|
||
current_requested: List[RequirementInfo]):
|
||
"""Обновляет версию пакета в списке запрашиваемых."""
|
||
if package_name in requested_dict:
|
||
old_req = requested_dict[package_name]
|
||
new_req_str = f"{package_name}{new_version}"
|
||
|
||
# Создаем новый RequirementInfo с обновленной версией
|
||
new_req = RequirementInfo.from_requirement_string(
|
||
new_req_str, old_req.significance_level, old_req.source_file
|
||
)
|
||
|
||
# Заменяем старый запрос на новый
|
||
index = current_requested.index(old_req)
|
||
current_requested[index] = new_req
|
||
requested_dict[package_name] = new_req
|
||
|
||
logger.info(f"Обновлена версия {package_name} до {new_version}")
|
||
|
||
def _find_compatible_version(self, package_name: str, requirement: str,
|
||
requested_dict: Dict[str, RequirementInfo]) -> Optional[str]:
|
||
"""Пытается найти совместимую версию пакета."""
|
||
# В реальной реализации здесь должен быть вызов к API PyPI или использование
|
||
# библиотеки для разрешения зависимостей, например, pip-tools или poetry
|
||
# Это упрощенная реализация
|
||
logger.warning(f"Поиск совместимой версии для {package_name} не реализован")
|
||
return None |