Skip to content

Commit

Permalink
add button to create a report of installed dependencies
Browse files Browse the repository at this point in the history
  • Loading branch information
zariiii9003 committed Dec 11, 2024
1 parent 9eb9eae commit c361c7f
Show file tree
Hide file tree
Showing 3 changed files with 117 additions and 26 deletions.
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

0 comments on commit c361c7f

Please sign in to comment.