vibefpmgui.packages

About → Packages…: the packages installed in the environment the app runs in.

In a pixi environment (pixi run, pixi shell) they come from pixi list --json; in an activated conda environment from conda list --json (or mamba / micromamba); otherwise they're the Python packages the app can see. Shown in a sortable, filterable table.

  1"""*About → Packages…*: the packages installed in the environment the app runs in.
  2
  3In a pixi environment (``pixi run``, ``pixi shell``) they come from ``pixi list
  4--json``; in an activated conda environment from ``conda list --json`` (or
  5mamba / micromamba); otherwise they're the Python packages the app can see.
  6Shown in a sortable, filterable table.
  7"""
  8
  9import json
 10import os
 11import shutil
 12import subprocess
 13import sys
 14from pathlib import Path
 15from typing import NamedTuple
 16
 17from qtpy.QtCore import Qt, QUrl
 18from qtpy.QtGui import QDesktopServices
 19from qtpy.QtWidgets import (
 20    QAbstractItemView,
 21    QApplication,
 22    QDialog,
 23    QHBoxLayout,
 24    QHeaderView,
 25    QLabel,
 26    QLineEdit,
 27    QMenu,
 28    QPushButton,
 29    QTableWidget,
 30    QTableWidgetItem,
 31    QVBoxLayout,
 32)
 33
 34from .background import call_in_background
 35from .console import NO_WINDOW
 36
 37LIST_TIMEOUT = 60  # seconds (a first `pixi list` may have to solve the environment)
 38
 39
 40class Package(NamedTuple):
 41    name: str
 42    version: str
 43    build: str = ""
 44    kind: str = ""        # "conda" or "pypi"
 45    channel: str = ""     # conda channel or package index
 46    license: str = ""
 47    size: int = 0         # bytes (0: not known)
 48    requested: bool = False  # asked for in the manifest (not only needed by another package)
 49    url: str = ""         # where it was downloaded from ("" if not known: see page)
 50
 51    @property
 52    def channel_name(self) -> str:
 53        """The channel or index, short: "conda-forge", "pypi.org"."""
 54        text = self.channel.rstrip("/")
 55        if "pypi.org" in text:
 56            return "pypi.org"
 57        return text.rsplit("/", 1)[-1] if "://" in text else text
 58
 59    @property
 60    def page(self) -> str:
 61        """The package's web page (anaconda.org or PyPI)."""
 62        if self.kind == "pypi":
 63            return f"https://pypi.org/project/{self.name}/"
 64        channel = self.channel.rstrip("/").rsplit("/", 1)[-1] or "conda-forge"
 65        return f"https://anaconda.org/{channel}/{self.name}"
 66
 67
 68class Listing(NamedTuple):
 69    source: str               # where the list is from, e.g. "pixi environment default of …/pixi.toml"
 70    packages: list[Package]
 71    error: str = ""
 72
 73
 74# ------------------------------------------------------------------ parsing
 75def parse_pixi(text: str) -> list[Package]:
 76    """``pixi list --json`` -> packages."""
 77    out = []
 78    for item in json.loads(text):
 79        if not isinstance(item, dict) or not item.get("name"):
 80            continue
 81        out.append(Package(
 82            str(item["name"]), str(item.get("version") or ""), str(item.get("build") or ""),
 83            str(item.get("kind") or ""), str(item.get("source") or item.get("index_url") or ""),
 84            str(item.get("license") or ""), int(item.get("size_bytes") or 0),
 85            bool(item.get("is_explicit")), str(item.get("url") or "")))
 86    return out
 87
 88
 89def parse_conda(text: str) -> list[Package]:
 90    """``conda list --json`` (also mamba and micromamba) -> packages."""
 91    out = []
 92    for item in json.loads(text):
 93        if not isinstance(item, dict) or not item.get("name"):
 94            continue
 95        channel = str(item.get("channel") or "")
 96        pypi = channel == "pypi" or item.get("platform") == "pypi"
 97        base = str(item.get("base_url") or "")
 98        # (No download URL: conda list doesn't say whether it was a .conda or a .tar.bz2.)
 99        out.append(Package(
100            str(item["name"]), str(item.get("version") or ""), str(item.get("build_string") or ""),
101            "pypi" if pypi else "conda", base or channel))
102    return out
103
104
105def python_packages() -> list[Package]:
106    """The Python packages the app can import (no pixi or conda environment)."""
107    from importlib import metadata
108
109    out = {}
110    for dist in metadata.distributions():
111        meta = dist.metadata
112        name = meta.get("Name") or ""  # (.get: a missing field is None, not deprecated)
113        if name and name.lower() not in out:
114            out[name.lower()] = Package(name, dist.version or "", kind="pypi", license=meta.get("License") or "",
115                                        url=meta.get("Home-page") or "")
116    return sorted(out.values(), key=lambda p: p.name.lower())
117
118
119# ---------------------------------------------------------------- listing
120def _run(args: list[str]) -> str:
121    out = subprocess.run(args, capture_output=True, text=True, timeout=LIST_TIMEOUT, check=False, **NO_WINDOW)
122    if out.returncode != 0:
123        lines = [line for line in (out.stderr or out.stdout).splitlines() if line.strip()]
124        raise RuntimeError(lines[-1] if lines else f"{Path(args[0]).name} exited with code {out.returncode}")
125    return out.stdout
126
127
128def list_packages(environ=os.environ) -> Listing:
129    """The environment the app runs in, and its packages (blocking: run it
130    in the background)."""
131    manifest = environ.get("PIXI_PROJECT_MANIFEST")
132    if manifest:
133        env_name = environ.get("PIXI_ENVIRONMENT_NAME") or "default"
134        source = f"pixi environment “{env_name}” of {manifest}"
135        pixi = environ.get("PIXI_EXE") or shutil.which("pixi")
136        if not pixi:
137            return Listing(source, [], "pixi wasn't found to list the packages.")
138        try:
139            return Listing(source, parse_pixi(_run([pixi, "list", "--json", "--manifest-path", manifest,
140                                                    "--environment", env_name])))
141        except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as exc:
142            return Listing(source, [], f"pixi list failed: {exc}")
143    prefix = environ.get("CONDA_PREFIX")
144    if prefix:
145        name = environ.get("CONDA_DEFAULT_ENV") or Path(prefix).name
146        source = f"conda environment “{name}” ({prefix})"
147        for exe in (environ.get("CONDA_EXE"), environ.get("MAMBA_EXE"),
148                    shutil.which("conda"), shutil.which("mamba"), shutil.which("micromamba")):
149            if exe and Path(exe).exists():
150                try:
151                    return Listing(source, parse_conda(_run([exe, "list", "--json", "--prefix", prefix])))
152                except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as exc:
153                    return Listing(source, [], f"{Path(exe).name} list failed: {exc}")
154        return Listing(source, [], "conda (or mamba, micromamba) wasn't found to list the packages.")
155    return Listing(f"Python packages of {sys.prefix} (not a pixi or conda environment)", python_packages())
156
157
158def human_size(n: int) -> str:
159    if not n:
160        return ""
161    for unit in ("B", "KB", "MB", "GB"):
162        if n < 1024 or unit == "GB":
163            return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
164        n /= 1024
165    return ""
166
167
168# ------------------------------------------------------------------- dialog
169MAX_COLUMN_WIDTH = 220  # pixels (but the name's; the full text is in the tooltip)
170COLUMNS = ["Name", "Version", "Build", "Type", "Channel / Index", "License", "Size", "Requested", "URL"]
171
172
173class _SizeItem(QTableWidgetItem):
174    """Sorts by the number of bytes, not the text."""
175
176    def __lt__(self, other):
177        return (self.data(Qt.UserRole) or 0) < (other.data(Qt.UserRole) or 0)
178
179
180class PackagesDialog(QDialog):
181    """The packages of the app's environment, in a table (filled in when
182    the listing, run in the background, is done)."""
183
184    def __init__(self, parent=None, lister=None):
185        """``lister``: what makes the list (list_packages, looked up now: tests replace it)."""
186        super().__init__(parent)
187        self.setWindowTitle("Installed Packages")
188        self.packages: list[Package] = []
189        self.source = QLabel("Listing the packages…")
190        self.source.setWordWrap(True)
191        self.source.setTextInteractionFlags(Qt.TextSelectableByMouse)
192        self.filter = QLineEdit()
193        self.filter.setPlaceholderText("Filter by name…")
194        self.filter.setClearButtonEnabled(True)
195        self.filter.textChanged.connect(self._apply_filter)
196        self.count = QLabel()
197        top = QHBoxLayout()
198        top.addWidget(self.filter, 1)
199        top.addWidget(self.count)
200        self.table = QTableWidget(0, len(COLUMNS))
201        self.table.setHorizontalHeaderLabels(COLUMNS)
202        self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
203        self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
204        self.table.setAlternatingRowColors(True)
205        self.table.verticalHeader().hide()
206        self.table.setSortingEnabled(True)
207        self.table.horizontalHeader().setStretchLastSection(True)
208        self.table.cellDoubleClicked.connect(lambda row, _c: self._open_page(row))
209        self.table.setContextMenuPolicy(Qt.CustomContextMenu)
210        self.table.customContextMenuRequested.connect(self._context_menu)
211        self.table.setToolTip("Double-click a package for its page (anaconda.org or PyPI)")
212        copy = QPushButton("Copy List")
213        copy.setToolTip("Copy the packages shown, one per line (name, version, build, type): for bug reports")
214        copy.clicked.connect(self.copy_list)
215        close = QPushButton("Close")
216        close.setDefault(True)
217        close.clicked.connect(self.accept)
218        buttons = QHBoxLayout()
219        buttons.addWidget(copy)
220        buttons.addStretch(1)
221        buttons.addWidget(close)
222        layout = QVBoxLayout(self)
223        layout.addWidget(self.source)
224        layout.addLayout(top)
225        layout.addWidget(self.table, 1)
226        layout.addLayout(buttons)
227        self.resize(980, 560)
228        call_in_background(lister or list_packages, self.show_listing, self)
229
230    def show_listing(self, listing: Listing):
231        self.packages = listing.packages
232        text = f"<b>{len(listing.packages)} packages</b> in the {listing.source}"
233        if listing.error:
234            text = f"{listing.source}<br><span style='color:#e03131'>{listing.error}</span>"
235        self.source.setText(text)
236        self.table.setSortingEnabled(False)  # (while filling: rows would move under us)
237        self.table.setRowCount(len(listing.packages))
238        for row, p in enumerate(listing.packages):
239            values = [p.name, p.version, p.build, p.kind, p.channel_name, p.license, human_size(p.size),
240                      "✓" if p.requested else "", p.url or p.page]
241            for column, value in enumerate(values):
242                item = _SizeItem(value) if column == 6 else QTableWidgetItem(value)
243                if column == 6:
244                    item.setData(Qt.UserRole, p.size)
245                    item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
246                if column == 7:
247                    item.setTextAlignment(Qt.AlignCenter)
248                    item.setToolTip("Asked for in the manifest" if p.requested else "Needed by another package")
249                if column in (4, 5, 8) and value:
250                    item.setToolTip(p.channel if column == 4 else value)  # (the full text: the column may be narrow)
251                item.setData(Qt.UserRole + 1, row)  # (which package: rows move when sorted)
252                self.table.setItem(row, column, item)
253        self.table.setSortingEnabled(True)
254        self.table.sortByColumn(0, Qt.AscendingOrder)
255        header = self.table.horizontalHeader()
256        header.resizeSections(QHeaderView.ResizeToContents)
257        for column in range(1, len(COLUMNS) - 1):  # (a long license mustn't push the rest out of view)
258            header.resizeSection(column, min(header.sectionSize(column), MAX_COLUMN_WIDTH))
259        self._apply_filter()
260
261    def _package(self, row: int) -> Package | None:
262        item = self.table.item(row, 0)
263        index = item.data(Qt.UserRole + 1) if item is not None else None
264        return self.packages[index] if index is not None and 0 <= index < len(self.packages) else None
265
266    def _apply_filter(self):
267        needle = self.filter.text().strip().lower()
268        shown = 0
269        for row in range(self.table.rowCount()):
270            p = self._package(row)
271            hide = bool(needle) and (p is None or needle not in p.name.lower())
272            self.table.setRowHidden(row, hide)
273            shown += not hide
274        self.count.setText(f"{shown} of {len(self.packages)}" if needle else "")
275
276    def visible_packages(self) -> list[Package]:
277        return [p for row in range(self.table.rowCount())
278                if not self.table.isRowHidden(row) and (p := self._package(row)) is not None]
279
280    def copy_list(self):
281        lines = [f"{p.name} {p.version} {p.build} {p.kind}".rstrip() for p in self.visible_packages()]
282        QApplication.clipboard().setText("\n".join(lines))
283
284    def _open_page(self, row: int):
285        p = self._package(row)
286        if p is not None:
287            QDesktopServices.openUrl(QUrl(p.page))
288
289    def _context_menu(self, pos):
290        row = self.table.rowAt(pos.y())
291        p = self._package(row)
292        if p is None:
293            return
294        menu = QMenu(self)
295        menu.addAction("Open Package Page", lambda: self._open_page(row))
296        menu.addAction("Copy Name and Version", lambda: QApplication.clipboard().setText(f"{p.name} {p.version}"))
297        if p.url:
298            menu.addAction("Copy URL", lambda: QApplication.clipboard().setText(p.url))
299        menu.exec(self.table.viewport().mapToGlobal(pos))
300        menu.deleteLater()
LIST_TIMEOUT = 60
class Package(typing.NamedTuple):
41class Package(NamedTuple):
42    name: str
43    version: str
44    build: str = ""
45    kind: str = ""        # "conda" or "pypi"
46    channel: str = ""     # conda channel or package index
47    license: str = ""
48    size: int = 0         # bytes (0: not known)
49    requested: bool = False  # asked for in the manifest (not only needed by another package)
50    url: str = ""         # where it was downloaded from ("" if not known: see page)
51
52    @property
53    def channel_name(self) -> str:
54        """The channel or index, short: "conda-forge", "pypi.org"."""
55        text = self.channel.rstrip("/")
56        if "pypi.org" in text:
57            return "pypi.org"
58        return text.rsplit("/", 1)[-1] if "://" in text else text
59
60    @property
61    def page(self) -> str:
62        """The package's web page (anaconda.org or PyPI)."""
63        if self.kind == "pypi":
64            return f"https://pypi.org/project/{self.name}/"
65        channel = self.channel.rstrip("/").rsplit("/", 1)[-1] or "conda-forge"
66        return f"https://anaconda.org/{channel}/{self.name}"

Package(name, version, build, kind, channel, license, size, requested, url)

Package( name: str, version: str, build: str = '', kind: str = '', channel: str = '', license: str = '', size: int = 0, requested: bool = False, url: str = '')

Create new instance of Package(name, version, build, kind, channel, license, size, requested, url)

name: str

Alias for field number 0

version: str

Alias for field number 1

build: str

Alias for field number 2

kind: str

Alias for field number 3

channel: str

Alias for field number 4

license: str

Alias for field number 5

size: int

Alias for field number 6

requested: bool

Alias for field number 7

url: str

Alias for field number 8

channel_name: str
52    @property
53    def channel_name(self) -> str:
54        """The channel or index, short: "conda-forge", "pypi.org"."""
55        text = self.channel.rstrip("/")
56        if "pypi.org" in text:
57            return "pypi.org"
58        return text.rsplit("/", 1)[-1] if "://" in text else text

The channel or index, short: "conda-forge", "pypi.org".

page: str
60    @property
61    def page(self) -> str:
62        """The package's web page (anaconda.org or PyPI)."""
63        if self.kind == "pypi":
64            return f"https://pypi.org/project/{self.name}/"
65        channel = self.channel.rstrip("/").rsplit("/", 1)[-1] or "conda-forge"
66        return f"https://anaconda.org/{channel}/{self.name}"

The package's web page (anaconda.org or PyPI).

class Listing(typing.NamedTuple):
69class Listing(NamedTuple):
70    source: str               # where the list is from, e.g. "pixi environment default of …/pixi.toml"
71    packages: list[Package]
72    error: str = ""

Listing(source, packages, error)

Listing( source: str, packages: list[Package], error: str = '')

Create new instance of Listing(source, packages, error)

source: str

Alias for field number 0

packages: list[Package]

Alias for field number 1

error: str

Alias for field number 2

def parse_pixi(text: str) -> list[Package]:
76def parse_pixi(text: str) -> list[Package]:
77    """``pixi list --json`` -> packages."""
78    out = []
79    for item in json.loads(text):
80        if not isinstance(item, dict) or not item.get("name"):
81            continue
82        out.append(Package(
83            str(item["name"]), str(item.get("version") or ""), str(item.get("build") or ""),
84            str(item.get("kind") or ""), str(item.get("source") or item.get("index_url") or ""),
85            str(item.get("license") or ""), int(item.get("size_bytes") or 0),
86            bool(item.get("is_explicit")), str(item.get("url") or "")))
87    return out

pixi list --json -> packages.

def parse_conda(text: str) -> list[Package]:
 90def parse_conda(text: str) -> list[Package]:
 91    """``conda list --json`` (also mamba and micromamba) -> packages."""
 92    out = []
 93    for item in json.loads(text):
 94        if not isinstance(item, dict) or not item.get("name"):
 95            continue
 96        channel = str(item.get("channel") or "")
 97        pypi = channel == "pypi" or item.get("platform") == "pypi"
 98        base = str(item.get("base_url") or "")
 99        # (No download URL: conda list doesn't say whether it was a .conda or a .tar.bz2.)
100        out.append(Package(
101            str(item["name"]), str(item.get("version") or ""), str(item.get("build_string") or ""),
102            "pypi" if pypi else "conda", base or channel))
103    return out

conda list --json (also mamba and micromamba) -> packages.

def python_packages() -> list[Package]:
106def python_packages() -> list[Package]:
107    """The Python packages the app can import (no pixi or conda environment)."""
108    from importlib import metadata
109
110    out = {}
111    for dist in metadata.distributions():
112        meta = dist.metadata
113        name = meta.get("Name") or ""  # (.get: a missing field is None, not deprecated)
114        if name and name.lower() not in out:
115            out[name.lower()] = Package(name, dist.version or "", kind="pypi", license=meta.get("License") or "",
116                                        url=meta.get("Home-page") or "")
117    return sorted(out.values(), key=lambda p: p.name.lower())

The Python packages the app can import (no pixi or conda environment).

def list_packages(environ=os.environ) -> Listing:
129def list_packages(environ=os.environ) -> Listing:
130    """The environment the app runs in, and its packages (blocking: run it
131    in the background)."""
132    manifest = environ.get("PIXI_PROJECT_MANIFEST")
133    if manifest:
134        env_name = environ.get("PIXI_ENVIRONMENT_NAME") or "default"
135        source = f"pixi environment “{env_name}” of {manifest}"
136        pixi = environ.get("PIXI_EXE") or shutil.which("pixi")
137        if not pixi:
138            return Listing(source, [], "pixi wasn't found to list the packages.")
139        try:
140            return Listing(source, parse_pixi(_run([pixi, "list", "--json", "--manifest-path", manifest,
141                                                    "--environment", env_name])))
142        except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as exc:
143            return Listing(source, [], f"pixi list failed: {exc}")
144    prefix = environ.get("CONDA_PREFIX")
145    if prefix:
146        name = environ.get("CONDA_DEFAULT_ENV") or Path(prefix).name
147        source = f"conda environment “{name}” ({prefix})"
148        for exe in (environ.get("CONDA_EXE"), environ.get("MAMBA_EXE"),
149                    shutil.which("conda"), shutil.which("mamba"), shutil.which("micromamba")):
150            if exe and Path(exe).exists():
151                try:
152                    return Listing(source, parse_conda(_run([exe, "list", "--json", "--prefix", prefix])))
153                except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as exc:
154                    return Listing(source, [], f"{Path(exe).name} list failed: {exc}")
155        return Listing(source, [], "conda (or mamba, micromamba) wasn't found to list the packages.")
156    return Listing(f"Python packages of {sys.prefix} (not a pixi or conda environment)", python_packages())

The environment the app runs in, and its packages (blocking: run it in the background).

def human_size(n: int) -> str:
159def human_size(n: int) -> str:
160    if not n:
161        return ""
162    for unit in ("B", "KB", "MB", "GB"):
163        if n < 1024 or unit == "GB":
164            return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
165        n /= 1024
166    return ""
MAX_COLUMN_WIDTH = 220
COLUMNS = ['Name', 'Version', 'Build', 'Type', 'Channel / Index', 'License', 'Size', 'Requested', 'URL']
class PackagesDialog(PySide6.QtWidgets.QDialog):
181class PackagesDialog(QDialog):
182    """The packages of the app's environment, in a table (filled in when
183    the listing, run in the background, is done)."""
184
185    def __init__(self, parent=None, lister=None):
186        """``lister``: what makes the list (list_packages, looked up now: tests replace it)."""
187        super().__init__(parent)
188        self.setWindowTitle("Installed Packages")
189        self.packages: list[Package] = []
190        self.source = QLabel("Listing the packages…")
191        self.source.setWordWrap(True)
192        self.source.setTextInteractionFlags(Qt.TextSelectableByMouse)
193        self.filter = QLineEdit()
194        self.filter.setPlaceholderText("Filter by name…")
195        self.filter.setClearButtonEnabled(True)
196        self.filter.textChanged.connect(self._apply_filter)
197        self.count = QLabel()
198        top = QHBoxLayout()
199        top.addWidget(self.filter, 1)
200        top.addWidget(self.count)
201        self.table = QTableWidget(0, len(COLUMNS))
202        self.table.setHorizontalHeaderLabels(COLUMNS)
203        self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
204        self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
205        self.table.setAlternatingRowColors(True)
206        self.table.verticalHeader().hide()
207        self.table.setSortingEnabled(True)
208        self.table.horizontalHeader().setStretchLastSection(True)
209        self.table.cellDoubleClicked.connect(lambda row, _c: self._open_page(row))
210        self.table.setContextMenuPolicy(Qt.CustomContextMenu)
211        self.table.customContextMenuRequested.connect(self._context_menu)
212        self.table.setToolTip("Double-click a package for its page (anaconda.org or PyPI)")
213        copy = QPushButton("Copy List")
214        copy.setToolTip("Copy the packages shown, one per line (name, version, build, type): for bug reports")
215        copy.clicked.connect(self.copy_list)
216        close = QPushButton("Close")
217        close.setDefault(True)
218        close.clicked.connect(self.accept)
219        buttons = QHBoxLayout()
220        buttons.addWidget(copy)
221        buttons.addStretch(1)
222        buttons.addWidget(close)
223        layout = QVBoxLayout(self)
224        layout.addWidget(self.source)
225        layout.addLayout(top)
226        layout.addWidget(self.table, 1)
227        layout.addLayout(buttons)
228        self.resize(980, 560)
229        call_in_background(lister or list_packages, self.show_listing, self)
230
231    def show_listing(self, listing: Listing):
232        self.packages = listing.packages
233        text = f"<b>{len(listing.packages)} packages</b> in the {listing.source}"
234        if listing.error:
235            text = f"{listing.source}<br><span style='color:#e03131'>{listing.error}</span>"
236        self.source.setText(text)
237        self.table.setSortingEnabled(False)  # (while filling: rows would move under us)
238        self.table.setRowCount(len(listing.packages))
239        for row, p in enumerate(listing.packages):
240            values = [p.name, p.version, p.build, p.kind, p.channel_name, p.license, human_size(p.size),
241                      "✓" if p.requested else "", p.url or p.page]
242            for column, value in enumerate(values):
243                item = _SizeItem(value) if column == 6 else QTableWidgetItem(value)
244                if column == 6:
245                    item.setData(Qt.UserRole, p.size)
246                    item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
247                if column == 7:
248                    item.setTextAlignment(Qt.AlignCenter)
249                    item.setToolTip("Asked for in the manifest" if p.requested else "Needed by another package")
250                if column in (4, 5, 8) and value:
251                    item.setToolTip(p.channel if column == 4 else value)  # (the full text: the column may be narrow)
252                item.setData(Qt.UserRole + 1, row)  # (which package: rows move when sorted)
253                self.table.setItem(row, column, item)
254        self.table.setSortingEnabled(True)
255        self.table.sortByColumn(0, Qt.AscendingOrder)
256        header = self.table.horizontalHeader()
257        header.resizeSections(QHeaderView.ResizeToContents)
258        for column in range(1, len(COLUMNS) - 1):  # (a long license mustn't push the rest out of view)
259            header.resizeSection(column, min(header.sectionSize(column), MAX_COLUMN_WIDTH))
260        self._apply_filter()
261
262    def _package(self, row: int) -> Package | None:
263        item = self.table.item(row, 0)
264        index = item.data(Qt.UserRole + 1) if item is not None else None
265        return self.packages[index] if index is not None and 0 <= index < len(self.packages) else None
266
267    def _apply_filter(self):
268        needle = self.filter.text().strip().lower()
269        shown = 0
270        for row in range(self.table.rowCount()):
271            p = self._package(row)
272            hide = bool(needle) and (p is None or needle not in p.name.lower())
273            self.table.setRowHidden(row, hide)
274            shown += not hide
275        self.count.setText(f"{shown} of {len(self.packages)}" if needle else "")
276
277    def visible_packages(self) -> list[Package]:
278        return [p for row in range(self.table.rowCount())
279                if not self.table.isRowHidden(row) and (p := self._package(row)) is not None]
280
281    def copy_list(self):
282        lines = [f"{p.name} {p.version} {p.build} {p.kind}".rstrip() for p in self.visible_packages()]
283        QApplication.clipboard().setText("\n".join(lines))
284
285    def _open_page(self, row: int):
286        p = self._package(row)
287        if p is not None:
288            QDesktopServices.openUrl(QUrl(p.page))
289
290    def _context_menu(self, pos):
291        row = self.table.rowAt(pos.y())
292        p = self._package(row)
293        if p is None:
294            return
295        menu = QMenu(self)
296        menu.addAction("Open Package Page", lambda: self._open_page(row))
297        menu.addAction("Copy Name and Version", lambda: QApplication.clipboard().setText(f"{p.name} {p.version}"))
298        if p.url:
299            menu.addAction("Copy URL", lambda: QApplication.clipboard().setText(p.url))
300        menu.exec(self.table.viewport().mapToGlobal(pos))
301        menu.deleteLater()

The packages of the app's environment, in a table (filled in when the listing, run in the background, is done).

PackagesDialog(parent=None, lister=None)
185    def __init__(self, parent=None, lister=None):
186        """``lister``: what makes the list (list_packages, looked up now: tests replace it)."""
187        super().__init__(parent)
188        self.setWindowTitle("Installed Packages")
189        self.packages: list[Package] = []
190        self.source = QLabel("Listing the packages…")
191        self.source.setWordWrap(True)
192        self.source.setTextInteractionFlags(Qt.TextSelectableByMouse)
193        self.filter = QLineEdit()
194        self.filter.setPlaceholderText("Filter by name…")
195        self.filter.setClearButtonEnabled(True)
196        self.filter.textChanged.connect(self._apply_filter)
197        self.count = QLabel()
198        top = QHBoxLayout()
199        top.addWidget(self.filter, 1)
200        top.addWidget(self.count)
201        self.table = QTableWidget(0, len(COLUMNS))
202        self.table.setHorizontalHeaderLabels(COLUMNS)
203        self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
204        self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
205        self.table.setAlternatingRowColors(True)
206        self.table.verticalHeader().hide()
207        self.table.setSortingEnabled(True)
208        self.table.horizontalHeader().setStretchLastSection(True)
209        self.table.cellDoubleClicked.connect(lambda row, _c: self._open_page(row))
210        self.table.setContextMenuPolicy(Qt.CustomContextMenu)
211        self.table.customContextMenuRequested.connect(self._context_menu)
212        self.table.setToolTip("Double-click a package for its page (anaconda.org or PyPI)")
213        copy = QPushButton("Copy List")
214        copy.setToolTip("Copy the packages shown, one per line (name, version, build, type): for bug reports")
215        copy.clicked.connect(self.copy_list)
216        close = QPushButton("Close")
217        close.setDefault(True)
218        close.clicked.connect(self.accept)
219        buttons = QHBoxLayout()
220        buttons.addWidget(copy)
221        buttons.addStretch(1)
222        buttons.addWidget(close)
223        layout = QVBoxLayout(self)
224        layout.addWidget(self.source)
225        layout.addLayout(top)
226        layout.addWidget(self.table, 1)
227        layout.addLayout(buttons)
228        self.resize(980, 560)
229        call_in_background(lister or list_packages, self.show_listing, self)

lister: what makes the list (list_packages, looked up now: tests replace it).

packages: list[Package]
source
filter
count
table
def show_listing(self, listing: Listing):
231    def show_listing(self, listing: Listing):
232        self.packages = listing.packages
233        text = f"<b>{len(listing.packages)} packages</b> in the {listing.source}"
234        if listing.error:
235            text = f"{listing.source}<br><span style='color:#e03131'>{listing.error}</span>"
236        self.source.setText(text)
237        self.table.setSortingEnabled(False)  # (while filling: rows would move under us)
238        self.table.setRowCount(len(listing.packages))
239        for row, p in enumerate(listing.packages):
240            values = [p.name, p.version, p.build, p.kind, p.channel_name, p.license, human_size(p.size),
241                      "✓" if p.requested else "", p.url or p.page]
242            for column, value in enumerate(values):
243                item = _SizeItem(value) if column == 6 else QTableWidgetItem(value)
244                if column == 6:
245                    item.setData(Qt.UserRole, p.size)
246                    item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
247                if column == 7:
248                    item.setTextAlignment(Qt.AlignCenter)
249                    item.setToolTip("Asked for in the manifest" if p.requested else "Needed by another package")
250                if column in (4, 5, 8) and value:
251                    item.setToolTip(p.channel if column == 4 else value)  # (the full text: the column may be narrow)
252                item.setData(Qt.UserRole + 1, row)  # (which package: rows move when sorted)
253                self.table.setItem(row, column, item)
254        self.table.setSortingEnabled(True)
255        self.table.sortByColumn(0, Qt.AscendingOrder)
256        header = self.table.horizontalHeader()
257        header.resizeSections(QHeaderView.ResizeToContents)
258        for column in range(1, len(COLUMNS) - 1):  # (a long license mustn't push the rest out of view)
259            header.resizeSection(column, min(header.sectionSize(column), MAX_COLUMN_WIDTH))
260        self._apply_filter()
def visible_packages(self) -> list[Package]:
277    def visible_packages(self) -> list[Package]:
278        return [p for row in range(self.table.rowCount())
279                if not self.table.isRowHidden(row) and (p := self._package(row)) is not None]
def copy_list(self):
281    def copy_list(self):
282        lines = [f"{p.name} {p.version} {p.build} {p.kind}".rstrip() for p in self.visible_packages()]
283        QApplication.clipboard().setText("\n".join(lines))
staticMetaObject = PySide6.QtCore.QMetaObject("PackagesDialog" inherits "QDialog": )