Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add button to create a report of installed dependencies #1117

Merged
merged 1 commit into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions asammdf.spec
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@ 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"

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")):
Expand Down
134 changes: 109 additions & 25 deletions src/asammdf/gui/dialogs/dependencies_dlg.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
from collections import defaultdict
import contextlib
from importlib.metadata import distribution, PackageNotFoundError
import re
from typing import Optional

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()
Expand Down Expand Up @@ -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)
Expand All @@ -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<extra>\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:
Expand All @@ -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<extra>\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
Loading