diff --git a/build-aux/AppImageBuilder.yml b/build-aux/AppImageBuilder.yml index a6ad36b..8a01e55 100644 --- a/build-aux/AppImageBuilder.yml +++ b/build-aux/AppImageBuilder.yml @@ -13,9 +13,9 @@ script: # 5) чистим от ненужных модулей и бинарников - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/ - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate} - - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3D*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtHelp*,QtMultimedia*,QtNetwork*,QtOpenGL*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialPort*,QtSql*,QtStateMachine*,QtTest*,QtWeb*,QtXml*} + - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*} - shopt -s extglob - - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6Gui*|libQt6Widgets*|libQt6OpenGL*|libQt6XcbQpa*|libQt6Wayland*|libQt6Egl*|libicudata*|libicuuc*|libicui18n*|libQt6DBus*|libQt6Svg*|libQt6Qml*|libQt6Network*) + - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*) AppDir: path: ./AppDir after_bundle: @@ -82,5 +82,5 @@ AppDir: PERLLIB: '${APPDIR}/usr/share/perl5:${APPDIR}/usr/lib/x86_64-linux-gnu/perl/5.34:${APPDIR}/usr/share/perl/5.34' AppImage: sign-key: None - comp: xz + comp: gzip arch: x86_64 diff --git a/dev-scripts/appimage_clean.py b/dev-scripts/appimage_clean.py new file mode 100755 index 0000000..42813bf --- /dev/null +++ b/dev-scripts/appimage_clean.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +""" +PySide6 Dependencies Analyzer with ldd support +Анализирует зависимости PySide6 модулей используя ldd для определения +реальных зависимостей скомпилированных библиотек. +""" + +import ast +import os +import sys +import subprocess +import re +from pathlib import Path +from typing import Set, Dict, List +import argparse +import json + + +class PySide6DependencyAnalyzer: + def __init__(self): + # Системные библиотеки, которые нужно всегда оставлять + self.system_libs = { + 'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl', + 'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus' + } + + self.real_dependencies = {} + self.used_modules_code = set() + self.used_modules_ldd = set() + self.all_required_modules = set() + + def find_python_files(self, directory: Path) -> List[Path]: + """Находит все Python файлы в директории""" + python_files = [] + for root, dirs, files in os.walk(directory): + dirs[:] = [d for d in dirs if d not in {'.venv', '__pycache__', '.git', 'node_modules'}] + + for file in files: + if file.endswith('.py'): + python_files.append(Path(root) / file) + return python_files + + def find_pyside6_libs(self, base_path: Path) -> Dict[str, Path]: + """Находит все PySide6 библиотеки (.so файлы)""" + libs = {} + + # Поиск в разных возможных локациях + search_paths = [ + base_path / "usr/local/lib/python3.10/dist-packages/PySide6", + base_path / "usr/local/lib/python3.11/dist-packages/PySide6", + base_path / "usr/lib/python3/dist-packages/PySide6", + base_path / ".venv/lib/python3.10/site-packages/PySide6", + base_path / ".venv/lib/python3.11/site-packages/PySide6", + ] + + # Также добавляем путь, если PySide6 установлен системно + try: + import PySide6 + system_path = Path(PySide6.__file__).parent + search_paths.append(system_path) + except ImportError: + pass + + for search_path in search_paths: + if search_path.exists(): + print(f"Поиск PySide6 библиотек в: {search_path}") + + # Ищем .so файлы модулей + for so_file in search_path.glob("Qt*.*.so"): + module_name = so_file.stem.split('.')[0] # QtCore.abi3.so -> QtCore + if module_name.startswith('Qt'): + libs[module_name] = so_file + + # Также ищем в подпапках + for subdir in search_path.iterdir(): + if subdir.is_dir() and subdir.name.startswith('Qt'): + for so_file in subdir.glob("*.so*"): + if 'Qt' in so_file.name: + libs[subdir.name] = so_file + break + + if libs: + break + + return libs + + def analyze_ldd_dependencies(self, lib_path: Path) -> Set[str]: + """Анализирует зависимости библиотеки с помощью ldd""" + qt_deps = set() + + try: + result = subprocess.run(['ldd', str(lib_path)], + capture_output=True, text=True, check=True) + + # Парсим вывод ldd и ищем Qt библиотеки + for line in result.stdout.split('\n'): + # Ищем строки вида: libQt6Core.so.6 => /path/to/lib + match = re.search(r'libQt6(\w+)\.so', line) + if match: + qt_module = f"Qt{match.group(1)}" + qt_deps.add(qt_module) + + except (subprocess.CalledProcessError, FileNotFoundError) as e: + print(f"Предупреждение: не удалось выполнить ldd для {lib_path}: {e}") + + return qt_deps + + def build_real_dependency_graph(self, pyside_libs: Dict[str, Path]) -> Dict[str, Set[str]]: + """Строит граф зависимостей на основе ldd анализа""" + dependencies = {} + + print("Анализ реальных зависимостей с помощью ldd...") + for module, lib_path in pyside_libs.items(): + print(f" Анализируется {module}...") + deps = self.analyze_ldd_dependencies(lib_path) + dependencies[module] = deps + + if deps: + print(f" Зависимости: {', '.join(sorted(deps))}") + + return dependencies + + def analyze_file_imports(self, file_path: Path) -> Set[str]: + """Анализирует один Python файл и возвращает используемые PySide6 модули""" + modules = set() + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + tree = ast.parse(content) + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith('PySide6.'): + module = alias.name.split('.', 2)[1] + if module.startswith('Qt'): + modules.add(module) + + elif isinstance(node, ast.ImportFrom): + if node.module and node.module.startswith('PySide6.'): + module = node.module.split('.', 2)[1] + if module.startswith('Qt'): + modules.add(module) + + except Exception as e: + print(f"Ошибка при анализе {file_path}: {e}") + + return modules + + def get_all_dependencies(self, modules: Set[str], dependency_graph: Dict[str, Set[str]]) -> Set[str]: + """Получает все зависимости для набора модулей, используя граф зависимостей из ldd""" + all_deps = set(modules) + + if not dependency_graph: + return all_deps + + # Повторяем до тех пор, пока не найдем все транзитивные зависимости + changed = True + iteration = 0 + while changed and iteration < 10: # Защита от бесконечного цикла + changed = False + current_deps = set(all_deps) + + for module in current_deps: + if module in dependency_graph: + new_deps = dependency_graph[module] - all_deps + if new_deps: + all_deps.update(new_deps) + changed = True + + iteration += 1 + + return all_deps + + def analyze_project(self, project_path: Path, appdir_path: Path = None) -> Dict: + """Анализирует весь проект""" + python_files = self.find_python_files(project_path) + print(f"Найдено {len(python_files)} Python файлов") + + # Анализ статических импортов + used_modules_code = set() + file_modules = {} + + for file_path in python_files: + modules = self.analyze_file_imports(file_path) + if modules: + file_modules[str(file_path.relative_to(project_path))] = list(modules) + used_modules_code.update(modules) + + print(f"Найдено {len(used_modules_code)} модулей в коде: {', '.join(sorted(used_modules_code))}") + + # Поиск PySide6 библиотек + search_base = appdir_path if appdir_path else project_path + pyside_libs = self.find_pyside6_libs(search_base) + + if not pyside_libs: + print("ОШИБКА: PySide6 библиотеки не найдены! Анализ невозможен.") + return { + 'error': 'PySide6 библиотеки не найдены', + 'analysis_method': 'failed', + 'found_libraries': 0, + 'directly_used_code': sorted(used_modules_code), + 'all_required': [], + 'removable': [], + 'available_modules': [], + 'file_usage': file_modules + } + + print(f"Найдено {len(pyside_libs)} PySide6 библиотек") + + # Анализ реальных зависимостей с ldd + real_dependencies = self.build_real_dependency_graph(pyside_libs) + + # Определяем модули, которые реально используются через ldd + used_modules_ldd = set() + for module in used_modules_code: + if module in real_dependencies: + used_modules_ldd.update(real_dependencies[module]) + used_modules_ldd.add(module) + + print(f"Реальные зависимости через ldd: {', '.join(sorted(used_modules_ldd))}") + + # Объединяем результаты анализа кода и ldd + all_used_modules = used_modules_code | used_modules_ldd + + # Получаем все необходимые модули включая зависимости + all_required = self.get_all_dependencies(all_used_modules, real_dependencies) + + # Все доступные PySide6 модули + available_modules = set(pyside_libs.keys()) + + # Модули, которые можно удалить + removable = available_modules - all_required + + return { + 'analysis_method': 'ldd + static analysis', + 'found_libraries': len(pyside_libs), + 'directly_used_code': sorted(used_modules_code), + 'directly_used_ldd': sorted(used_modules_ldd), + 'all_required': sorted(all_required), + 'removable': sorted(removable), + 'available_modules': sorted(available_modules), + 'file_usage': file_modules, + 'real_dependencies': {k: sorted(v) for k, v in real_dependencies.items()}, + 'library_paths': {k: str(v) for k, v in pyside_libs.items()}, + 'analysis_summary': { + 'total_modules': len(available_modules), + 'required_modules': len(all_required), + 'removable_modules': len(removable), + 'space_saving_potential': f"{len(removable)/len(available_modules)*100:.1f}%" if available_modules else "0%" + } + } + + def generate_appimage_recipe(self, removable_modules: List[str], template_path: Path) -> str: + """Генерирует обновленный AppImage рецепт с командами очистки""" + + # Читаем существующий рецепт + try: + with open(template_path, 'r', encoding='utf-8') as f: + recipe_content = f.read() + except FileNotFoundError: + print(f"Шаблон рецепта не найден: {template_path}") + return "" + + # Генерируем новые команды очистки + cleanup_lines = [] + + # QML удаляем только если не используется + qml_modules = {'QtQml', 'QtQuick', 'QtQuickWidgets'} + if qml_modules.issubset(set(removable_modules)): + cleanup_lines.append(" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/") + + # Инструменты разработки (всегда удаляем) + cleanup_lines.append(" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}") + + # Модули для удаления + if removable_modules: + modules_list = ','.join([f"{mod}*" for mod in sorted(removable_modules)]) + cleanup_lines.append(f" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{{{modules_list}}}") + + # Генерируем команду для удаления нативных библиотек с сохранением нужных + required_libs = set() + for module in sorted(set(self.all_required_modules)): + required_libs.add(f"libQt6{module.replace('Qt', '')}*") + + # Добавляем системные библиотеки + for lib in self.system_libs: + required_libs.add(f"{lib}*") + + keep_pattern = '|'.join(sorted(required_libs)) + + cleanup_lines.extend([ + " - shopt -s extglob", + f" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!({keep_pattern})" + ]) + + # Заменяем блок очистки в рецепте + import re + + # Ищем блок "# 5) чистим от ненужных модулей и бинарников" до следующего комментария или до AppDir: + pattern = r'( # 5\) чистим от ненужных модулей и бинарников\n).*?(?=\nAppDir:|\n # [0-9]+\)|$)' + + new_cleanup_block = " # 5) чистим от ненужных модулей и бинарников\n" + '\n'.join(cleanup_lines) + + updated_recipe = re.sub(pattern, new_cleanup_block, recipe_content, flags=re.DOTALL) + + return updated_recipe + + +def main(): + parser = argparse.ArgumentParser(description='Анализ зависимостей PySide6 модулей с использованием ldd') + parser.add_argument('project_path', help='Путь к проекту для анализа') + parser.add_argument('--appdir', help='Путь к AppDir для поиска PySide6 библиотек') + parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)') + parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод') + + args = parser.parse_args() + + project_path = Path(args.project_path) + if not project_path.exists(): + print(f"Ошибка: путь {project_path} не существует") + sys.exit(1) + + appdir_path = Path(args.appdir) if args.appdir else None + if appdir_path and not appdir_path.exists(): + print(f"Предупреждение: AppDir путь {appdir_path} не существует") + appdir_path = None + + analyzer = PySide6DependencyAnalyzer() + results = analyzer.analyze_project(project_path, appdir_path) + + # Сохраняем в анализатор для генерации команд + analyzer.all_required_modules = set(results.get('all_required', [])) + + # Выводим результаты + print("\n" + "="*60) + print("АНАЛИЗ ЗАВИСИМОСТЕЙ PYSIDE6 (ldd analysis)") + print("="*60) + + if 'error' in results: + print(f"\nОШИБКА: {results['error']}") + sys.exit(1) + + print(f"\nМетод анализа: {results['analysis_method']}") + print(f"Найдено библиотек: {results['found_libraries']}") + + if results['directly_used_code']: + print(f"\nИспользуемые модули в коде ({len(results['directly_used_code'])}):") + for module in results['directly_used_code']: + print(f" • {module}") + + if results['directly_used_ldd']: + print(f"\nРеальные зависимости через ldd ({len(results['directly_used_ldd'])}):") + for module in results['directly_used_ldd']: + print(f" • {module}") + + print(f"\nВсе необходимые модули ({len(results['all_required'])}):") + for module in results['all_required']: + print(f" • {module}") + + print(f"\nМодули, которые можно удалить ({len(results['removable'])}):") + for module in results['removable']: + print(f" • {module}") + + print(f"\nПотенциальная экономия места: {results['analysis_summary']['space_saving_potential']}") + + if args.verbose and results['real_dependencies']: + print(f"\nРеальные зависимости (ldd):") + for module, deps in results['real_dependencies'].items(): + if deps: + print(f" {module} → {', '.join(deps)}") + + # Обновляем AppImage рецепт + recipe_path = project_path / "../build-aux/AppImageBuilder.yml" + if recipe_path.exists(): + updated_recipe = analyzer.generate_appimage_recipe(results['removable'], recipe_path) + if updated_recipe: + with open(recipe_path, 'w', encoding='utf-8') as f: + f.write(updated_recipe) + print(f"\nAppImage рецепт обновлен: {recipe_path}") + else: + print(f"\nОШИБКА: не удалось обновить рецепт") + else: + print(f"\nПредупреждение: рецепт AppImage не найден в {recipe_path}") + + # Сохраняем результаты в JSON + if args.output: + with open(args.output, 'w', encoding='utf-8') as f: + json.dump(results, f, ensure_ascii=False, indent=2) + print(f"Результаты сохранены в: {args.output}") + + print("\n" + "="*60) + + +if __name__ == "__main__": + main()