From c361c7f82ff2d7c19f0fb21588aa996f7512f2d6 Mon Sep 17 00:00:00 2001 From: zariiii9003 Date: Wed, 11 Dec 2024 01:54:31 +0100 Subject: [PATCH] add button to create a report of installed dependencies --- .gitignore | 1 - asammdf.spec | 8 ++ src/asammdf/gui/dialogs/dependencies_dlg.py | 134 ++++++++++++++++---- 3 files changed, 117 insertions(+), 26 deletions(-) diff --git a/.gitignore b/.gitignore index a4db89822..598f821d1 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,6 @@ MANIFEST # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest -*.spec # Installer logs pip-log.txt diff --git a/asammdf.spec b/asammdf.spec index b88d4af82..b1cf3042e 100644 --- a/asammdf.spec +++ b/asammdf.spec @@ -3,6 +3,10 @@ import os from pathlib import Path import sys +from PyInstaller.utils.hooks import copy_metadata + +from asammdf.gui.dialogs.dependencies_dlg import find_all_dependencies + sys.setrecursionlimit(sys.getrecursionlimit() * 5) asammdf_path = Path.cwd() / "src" / "asammdf" / "app" / "asammdfgui.py" @@ -10,6 +14,10 @@ asammdf_path = Path.cwd() / "src" / "asammdf" / "app" / "asammdfgui.py" block_cipher = None added_files = [] +# get metadata for importlib.metadata (used by DependenciesDlg) +for dep in find_all_dependencies("asammdf"): + added_files += copy_metadata(dep) + for root, dirs, files in os.walk(asammdf_path.parent / "ui"): for file in files: if file.lower().endswith(("ui", "png", "qrc")): diff --git a/src/asammdf/gui/dialogs/dependencies_dlg.py b/src/asammdf/gui/dialogs/dependencies_dlg.py index 9e5b56a49..f1f1247d2 100644 --- a/src/asammdf/gui/dialogs/dependencies_dlg.py +++ b/src/asammdf/gui/dialogs/dependencies_dlg.py @@ -1,3 +1,4 @@ +from collections import defaultdict import contextlib from importlib.metadata import distribution, PackageNotFoundError import re @@ -5,21 +6,29 @@ from packaging.requirements import Requirement from PySide6.QtCore import QSize -from PySide6.QtGui import QIcon -from PySide6.QtWidgets import QDialog, QTreeWidget, QTreeWidgetItem, QVBoxLayout +from PySide6.QtGui import QGuiApplication, QIcon +from PySide6.QtWidgets import ( + QDialog, + QPushButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, +) class DependenciesDlg(QDialog): - def __init__(self, package_name: str) -> None: + def __init__(self, package_name: str, is_root_package: bool = True) -> None: """Create a dialog to list all dependencies for `package_name`.""" super().__init__() # Variables self._package_name = package_name + self._is_root_package = is_root_package # Widgets self._tree = QTreeWidget() + self._copy_btn = QPushButton("Copy installed dependencies to clipboard") # Setup widgets self._setup_widgets() @@ -48,14 +57,19 @@ def _setup_widgets(self) -> None: self._tree.resizeColumnToContents(1) self._tree.resizeColumnToContents(2) + # enable copy button for root package only + self._copy_btn.setVisible(self._is_root_package) + def _setup_layout(self) -> None: vbox = QVBoxLayout() self.setLayout(vbox) vbox.addWidget(self._tree) + vbox.addWidget(self._copy_btn) def _connect_signals(self) -> None: self._tree.itemDoubleClicked.connect(self._on_item_double_clicked) + self._copy_btn.clicked.connect(self._on_copy_button_clicked) def _populate_tree(self, package_name: str) -> None: package_dist = distribution(package_name) @@ -82,27 +96,22 @@ def get_root_node(name: Optional[str] = None) -> QTreeWidgetItem: self._tree.invisibleRootItem().addChild(new_root_node) return new_root_node - for req_string in requires: - req = Requirement(req_string) - - parent = get_root_node() + for group, requirements in grouped_dependencies(package_name).items(): + for req_string in requirements: + req = Requirement(req_string) + parent_node = get_root_node(group) - if req.marker is not None: - match = re.search(r"extra\s*==\s*['\"](?P\S+)['\"]", str(req.marker)) - if match: - parent = get_root_node(match["extra"]) + item = QTreeWidgetItem() + item.setText(0, req.name) + item.setText(1, str(req.specifier)) - item = QTreeWidgetItem() - item.setText(0, req.name) - item.setText(1, str(req.specifier)) + with contextlib.suppress(PackageNotFoundError): + dist = distribution(req.name) + item.setText(2, str(dist.version)) + item.setText(3, dist.metadata["Summary"]) + item.setText(4, dist.metadata["Home-Page"]) - with contextlib.suppress(PackageNotFoundError): - dist = distribution(req.name) - item.setText(2, str(dist.version)) - item.setText(3, dist.metadata["Summary"]) - item.setText(4, dist.metadata["Home-Page"]) - - parent.addChild(item) + parent_node.addChild(item) def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None: if column != 0: @@ -111,9 +120,84 @@ def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None: return package_name = item.text(0) - DependenciesDlg.show_dependencies(package_name) + DependenciesDlg.show_dependencies(package_name, is_root_package=False) + + def _on_copy_button_clicked(self) -> None: + """Create a list of all dependencies and their versions and write it to clipboard.""" + lines: list[str] = [] + dependencies = find_all_dependencies(self._package_name) + max_name_length = max(len(name) for name in dependencies) + + header = f"{'Package':<{max_name_length}} Version" + lines.append(header) + lines.append("-" * len(header)) + for name in sorted(dependencies): + version = distribution(name).version + lines.append(f"{name:<{max_name_length}} {version}") + + # write to clipboard + QGuiApplication.clipboard().setText("\n".join(lines)) @staticmethod - def show_dependencies(package_name: str) -> None: - dlg = DependenciesDlg(package_name) - dlg.exec_() + def show_dependencies(package_name: str, is_root_package: bool = True) -> None: + dlg = DependenciesDlg(package_name, is_root_package) + dlg.exec() + + +def grouped_dependencies(package_name: str) -> dict[str, list[str]]: + """Retrieve a dictionary grouping the dependencies of a given package into mandatory and optional categories. + + This function fetches the dependencies of the specified package and categorizes them into groups, such as + 'mandatory' or any optional feature groups specified by `extra` markers. + + :param package_name: + The name of the package to analyze. + :return: + A dictionary where keys are group names (e.g., 'mandatory', 'extra_feature') + and values are lists of package names corresponding to those groups. + """ + dependencies: defaultdict[str, list[str]] = defaultdict(list) + package_dist = distribution(package_name) + + if requires := package_dist.requires: + for req_string in requires: + req = Requirement(req_string) + + group = "mandatory" + if match := re.search(r"extra\s*==\s*['\"](?P\S+)['\"]", str(req.marker)): + group = match["extra"] + + dependencies[group].append(req_string) + return dependencies + + +def find_all_dependencies(package_name: str) -> set[str]: + """Recursively find all dependencies of a given package, including transitive dependencies. + + This function determines all dependencies of the specified package, following any transitive dependencies + (i.e., dependencies of dependencies) and returning a complete set of package names. + + :param package_name: + The name of the package to analyze. + :return: + A set of all dependencies for the package, including transitive dependencies. + """ + + def _flatten_groups(grouped_deps: dict[str, list[str]]) -> set[str]: + _dep_set = set() + for group, requirements in grouped_deps.items(): + _dep_set |= {Requirement(req_string).name for req_string in requirements} + return _dep_set + + dep_set: set[str] = {package_name} + todo = _flatten_groups(grouped_dependencies(package_name)) + while todo: + req_name = todo.pop() + if req_name in dep_set: + continue + try: + todo |= _flatten_groups(grouped_dependencies(req_name)) + except PackageNotFoundError: + continue + dep_set.add(req_name) + return dep_set