Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-pipdeptree for
openSUSE:Factory checked in at 2026-05-18 17:48:28
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-pipdeptree (Old)
and /work/SRC/openSUSE:Factory/.python-pipdeptree.new.1966 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-pipdeptree"
Mon May 18 17:48:28 2026 rev:14 rq:1353795 version:2.35.3
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-pipdeptree/python-pipdeptree.changes
2026-04-25 23:27:48.440570548 +0200
+++
/work/SRC/openSUSE:Factory/.python-pipdeptree.new.1966/python-pipdeptree.changes
2026-05-18 17:49:22.727679558 +0200
@@ -1,0 +2,10 @@
+Mon May 18 11:22:02 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 2.35.3:
+ * perf(extras): make --extras tractable on real environments
+- update to 2.35.2:
+ * fix(types): resolve ty 0.0.30 check failures
+ * Fix dependency sorting for freeze, text, and rich output
+ formats
+
+-------------------------------------------------------------------
Old:
----
pipdeptree-2.35.1.tar.gz
New:
----
pipdeptree-2.35.3.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-pipdeptree.spec ++++++
--- /var/tmp/diff_new_pack.bN04oP/_old 2026-05-18 17:49:23.355705509 +0200
+++ /var/tmp/diff_new_pack.bN04oP/_new 2026-05-18 17:49:23.359705675 +0200
@@ -23,7 +23,7 @@
%endif
Name: python-pipdeptree
-Version: 2.35.1
+Version: 2.35.3
Release: 0
Summary: Command line utility to show dependency tree of packages
License: MIT
++++++ pipdeptree-2.35.1.tar.gz -> pipdeptree-2.35.3.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pipdeptree-2.35.1/.github/workflows/check.yaml
new/pipdeptree-2.35.3/.github/workflows/check.yaml
--- old/pipdeptree-2.35.1/.github/workflows/check.yaml 2026-04-10
17:32:44.000000000 +0200
+++ new/pipdeptree-2.35.3/.github/workflows/check.yaml 2026-05-17
17:45:31.000000000 +0200
@@ -40,7 +40,7 @@
fetch-depth: 0
persist-credentials: false
- name: Install the latest version of uv
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 #
v8.0.0
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b #
v8.1.0
with:
enable-cache: false
cache-dependency-glob: "pyproject.toml"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pipdeptree-2.35.1/.github/workflows/release.yaml
new/pipdeptree-2.35.3/.github/workflows/release.yaml
--- old/pipdeptree-2.35.1/.github/workflows/release.yaml 2026-04-10
17:32:44.000000000 +0200
+++ new/pipdeptree-2.35.3/.github/workflows/release.yaml 2026-05-17
17:45:31.000000000 +0200
@@ -18,7 +18,7 @@
fetch-depth: 0
persist-credentials: false
- name: Install the latest version of uv
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 #
v8.0.0
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b #
v8.1.0
with:
enable-cache: false
cache-dependency-glob: "pyproject.toml"
@@ -26,7 +26,7 @@
- name: Build package
run: uv build --python 3.14 --python-preference only-managed --sdist
--wheel . --out-dir dist
- name: Store the distribution packages
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
# v7
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
# v7
with:
name: ${{ env.dists-artifact-name }}
path: dist/*
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pipdeptree-2.35.1/.pre-commit-config.yaml
new/pipdeptree-2.35.3/.pre-commit-config.yaml
--- old/pipdeptree-2.35.1/.pre-commit-config.yaml 2026-04-10
17:32:44.000000000 +0200
+++ new/pipdeptree-2.35.3/.pre-commit-config.yaml 2026-05-17
17:45:31.000000000 +0200
@@ -5,7 +5,7 @@
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/python-jsonschema/check-jsonschema
- rev: 0.37.1
+ rev: 0.37.2
hooks:
- id: check-github-workflows
args: ["--verbose"]
@@ -15,28 +15,28 @@
- id: codespell
additional_dependencies: ["tomli>=2.3"]
- repo: https://github.com/tox-dev/tox-toml-fmt
- rev: "v1.9.1"
+ rev: "v1.9.3"
hooks:
- id: tox-toml-fmt
- repo: https://github.com/tox-dev/pyproject-fmt
- rev: "v2.21.0"
+ rev: "v2.21.2"
hooks:
- id: pyproject-fmt
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: "v0.15.9"
+ rev: "v0.15.12"
hooks:
- id: ruff-format
- id: ruff-check
args: ["--fix", "--unsafe-fixes", "--exit-non-zero-on-fix"]
- repo: https://github.com/rbubley/mirrors-prettier
- rev: "v3.8.1"
+ rev: "v3.8.3"
hooks:
- id: prettier
additional_dependencies:
- [email protected]
- "@prettier/[email protected]"
- repo: https://github.com/zizmorcore/zizmor-pre-commit
- rev: v1.23.1
+ rev: v1.24.1
hooks:
- id: zizmor
- repo: meta
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pipdeptree-2.35.1/docs/explanation.rst
new/pipdeptree-2.35.3/docs/explanation.rst
--- old/pipdeptree-2.35.1/docs/explanation.rst 2026-04-10 17:32:44.000000000
+0200
+++ new/pipdeptree-2.35.3/docs/explanation.rst 2026-05-17 17:45:31.000000000
+0200
@@ -65,9 +65,47 @@
style X fill:#e67e22,color:#fff
style Y fill:#e67e22,color:#fff
+Optional dependencies (extras)
+------------------------------
+
+When you pass ``--extras``, pipdeptree augments the tree with optional
dependency edges. Two kinds
+of extras are surfaced:
+
+1. **Explicitly requested extras.** When a parent's metadata records a
dependency like
+ ``oauthlib[signedtoken]``, the deps gated behind oauthlib's ``signedtoken``
extra get
+ added under oauthlib in the tree, annotated with ``extra: signedtoken``.
+
+2. **Active extras.** Python packaging metadata never records *why* a package
was installed,
+ only what it could require. pipdeptree therefore considers an extra
"active" when every dep
+ that extra would have requested is already installed -- the same heuristic
Python libraries use
+ at runtime to decide whether a feature is available (``try: import
optional_dep``). When an
+ extra is active, pipdeptree adds the same edges it would have added if the
extra had been
+ requested explicitly.
+
+Both kinds propagate. If activating ``A[x]`` pulls in ``B[y]``, pipdeptree
also adds the deps
+``B[y]`` would gate. Because metadata cannot tell us that ``B`` was installed
for some other
+reason, the same package may appear under multiple parents through this
mechanism.
+
+.. mermaid::
+
+ flowchart TD
+ A[App] -- requires --> O[oauthlib]
+ A -- "requires<br/>oauthlib[signedtoken]" --> O
+ O -. "extra: signedtoken" .-> C[cryptography]
+ O -. "extra: signedtoken" .-> P[pyjwt]
+ style C fill:#8e44ad,color:#fff
+ style P fill:#8e44ad,color:#fff
+
+To leave optional edges out entirely, omit ``--extras`` (the default). Edges
added through
+extras are always annotated with the originating extra, so they remain
distinguishable from
+mandatory dependencies in every output format.
+
Limitations
-----------
- pipdeptree only sees packages that are already installed. It cannot predict
what a ``pip install`` will do.
- If you need a dependency resolver that works without installing packages
first, consider :pypi:`uv`.
- Extra/optional dependencies are not shown by default; use ``--extras`` to
include them.
+- ``--extras`` cannot reconstruct extras that were requested only on the
command line
+ (e.g. ``pip install foo[dev]`` where ``foo`` is itself top-level), because
that information
+ is never persisted into installed package metadata.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pipdeptree-2.35.1/docs/how-to/usage.rst
new/pipdeptree-2.35.3/docs/how-to/usage.rst
--- old/pipdeptree-2.35.1/docs/how-to/usage.rst 2026-04-10 17:32:44.000000000
+0200
+++ new/pipdeptree-2.35.3/docs/how-to/usage.rst 2026-05-17 17:45:31.000000000
+0200
@@ -320,3 +320,16 @@
Without ``--extras``, only mandatory dependencies are shown. Packages that
declare optional dependency groups (extras)
will have those additional dependencies included when this flag is set.
+
+Edges added through an extra are annotated with that extra's name:
+
+.. code-block:: console
+
+ $ pipdeptree --extras --packages oauthlib
+ oauthlib==3.0.0
+ ├── cryptography [required: Any, installed: 2.7, extra: signedtoken]
+ └── pyjwt [required: >=1.0.0, installed: 1.7.1, extra: signedtoken]
+
+An extra is included not only when a parent explicitly requested it (e.g.
``oauthlib[signedtoken]``)
+but also when every dependency that the extra would require is already
installed in the
+environment. See :doc:`/explanation` for the rationale.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pipdeptree-2.35.1/docs/tutorial/getting-started.rst
new/pipdeptree-2.35.3/docs/tutorial/getting-started.rst
--- old/pipdeptree-2.35.1/docs/tutorial/getting-started.rst 2026-04-10
17:32:44.000000000 +0200
+++ new/pipdeptree-2.35.3/docs/tutorial/getting-started.rst 2026-05-17
17:45:31.000000000 +0200
@@ -142,5 +142,7 @@
Next steps
----------
-See :doc:`/how-to/usage` for filtering, virtualenv support, and warning
control. See :doc:`/how-to/output-formats` for
-all available output formats including JSON, Mermaid, and Graphviz.
+See :doc:`/how-to/usage` for filtering, virtualenv support, warning control,
and how to surface
+optional (extras) dependencies. See :doc:`/how-to/output-formats` for all
available output formats
+including JSON, Mermaid, and Graphviz. See :doc:`/explanation` for how
pipdeptree decides when an
+optional dependency edge is "active".
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pipdeptree-2.35.1/src/pipdeptree/_cli.py
new/pipdeptree-2.35.3/src/pipdeptree/_cli.py
--- old/pipdeptree-2.35.1/src/pipdeptree/_cli.py 2026-04-10
17:32:44.000000000 +0200
+++ new/pipdeptree-2.35.3/src/pipdeptree/_cli.py 2026-05-17
17:45:31.000000000 +0200
@@ -143,7 +143,11 @@
"--extras",
action="store_true",
default=False,
- help="include optional (extras) dependencies in the tree",
+ help=(
+ "include optional (extras) dependencies in the tree; an extra is
included when it is "
+ "explicitly requested (e.g. ``foo[bar]``) or when every dependency
the extra would "
+ "require is already installed"
+ ),
)
scope = select.add_mutually_exclusive_group()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pipdeptree-2.35.1/src/pipdeptree/_models/dag.py
new/pipdeptree-2.35.3/src/pipdeptree/_models/dag.py
--- old/pipdeptree-2.35.1/src/pipdeptree/_models/dag.py 2026-04-10
17:32:44.000000000 +0200
+++ new/pipdeptree-2.35.3/src/pipdeptree/_models/dag.py 2026-05-17
17:45:31.000000000 +0200
@@ -3,6 +3,7 @@
import sys
from collections import defaultdict, deque
from collections.abc import Iterator, Mapping
+from enum import Enum, auto
from fnmatch import fnmatch
from itertools import chain
from typing import TYPE_CHECKING
@@ -12,6 +13,8 @@
if TYPE_CHECKING:
from importlib.metadata import Distribution
+ from packaging.requirements import Requirement
+
from pipdeptree._warning import get_warning_printer
@@ -58,7 +61,7 @@
) -> PackageDAG:
warning_printer = get_warning_printer()
dist_pkgs = [DistPackage(p) for p in pkgs]
- idx = {p.key: p for p in dist_pkgs}
+ idx: dict[str, DistPackage] = {p.key: p for p in dist_pkgs}
pkg_deps: dict[DistPackage, list[ReqPackage]] = {}
dist_name_to_invalid_reqs_dict: dict[str, list[str]] = {}
for pkg in dist_pkgs:
@@ -299,14 +302,18 @@
reversed_dag[parent.as_requirement()] = []
return ReversedPackageDAG(dict(reversed_dag)) # ty:
ignore[invalid-argument-type]
- def sort(self) -> PackageDAG:
+ def sort(self, *, in_place: bool = False) -> PackageDAG:
"""
Return sorted tree in which the underlying _obj dict is an dict,
sorted alphabetically by the keys.
- :returns: Instance of same class with dict
+ :returns: shallow copy of the DAG or the same DAG if in_place is set
"""
- return self.__class__({k: sorted(v) for k, v in
sorted(self._obj.items())})
+ sorted_obj = {k: sorted(v) for k, v in sorted(self._obj.items())}
+ if in_place:
+ self._obj = sorted_obj
+ return self
+ return self.__class__(sorted_obj)
# Methods required by the abstract base class Mapping
def __getitem__(self, arg: DistPackage) -> list[ReqPackage]:
@@ -366,6 +373,9 @@
for pkg_key, extras in _collect_satisfied_extras(pkg_deps, idx).items():
extras_needed.setdefault(pkg_key, set()).update(extras)
processed: dict[str, set[str]] = {}
+ # The same (parent, child, extra) triple can be reached through multiple
req.extras propagation
+ # paths across rounds; without dedup it would be appended once per path.
+ seen_edges: set[tuple[str, str, str]] = set()
while extras_needed:
next_round: dict[str, set[str]] = {}
for pkg_key, extras in extras_needed.items():
@@ -376,10 +386,13 @@
dist_pkg = idx.get(pkg_key)
if dist_pkg is None or dist_pkg not in pkg_deps:
continue
- for req, extra_name in
dist_pkg.requires_for_extras(frozenset(new_extras)):
- dist = idx.get(canonicalize_name(req.name))
- if dist is None:
+ for req, extra_name, dep_key in
dist_pkg.requires_for_extras(frozenset(new_extras)):
+ if (dist := idx.get(dep_key)) is None:
continue
+ edge_key = (dist_pkg.key, dist.key, extra_name)
+ if edge_key in seen_edges:
+ continue
+ seen_edges.add(edge_key)
req.name = dist.project_name
pkg_deps[dist_pkg].append(ReqPackage(req, dist,
extra=extra_name))
if req.extras:
@@ -401,34 +414,164 @@
pkg_deps: dict[DistPackage, list[ReqPackage]], idx: dict[str, DistPackage]
) -> dict[str, set[str]]:
"""Collect extras whose dependencies are all installed in the
environment."""
+ resolver = _ExtrasResolver(pkg_deps, idx)
extras_needed: dict[str, set[str]] = {}
for dist_pkg in pkg_deps:
for extra_name in dist_pkg.provides_extras:
- if _extra_is_satisfied(dist_pkg.key, extra_name, pkg_deps, idx,
set()):
+ if resolver.is_satisfied(dist_pkg.key, extra_name):
extras_needed.setdefault(dist_pkg.key, set()).add(extra_name)
return extras_needed
+class _Action(Enum):
+ SUCCESS = auto()
+ FAIL = auto()
+
+
+class _Frame:
+ __slots__ = ("key", "req_idx", "reqs", "sub_extras", "sub_idx",
"used_assumption")
+
+ def __init__(self, key: tuple[str, str], reqs: list[tuple[Requirement,
str, str]]) -> None:
+ self.key = key
+ self.reqs = reqs
+ self.req_idx = -1
+ self.sub_extras: tuple[str, ...] = ()
+ self.sub_idx = 0
+ self.used_assumption = False
+
+
+class _ExtrasResolver:
+ """
+ A shared cache amortizes satisfaction queries across an environment.
+
+ Without sharing the global cache, the same subgraphs would be re-walked
O(N) times for an
+ N-node graph because each top-level query starts from scratch.
+ """
+
+ def __init__(
+ self,
+ pkg_deps: dict[DistPackage, list[ReqPackage]],
+ idx: dict[str, DistPackage],
+ ) -> None:
+ self._pkg_deps = pkg_deps
+ self._idx = idx
+ self._cache: dict[tuple[str, str], bool] = {}
+ self._in_progress: set[tuple[str, str]] = set()
+
+ def is_satisfied(self, pkg_key: str, extra_name: str) -> bool:
+ result, _ = self._resolve(pkg_key, extra_name)
+ return result
+
+ def _resolve(self, pkg_key: str, extra_name: str) -> tuple[bool, bool]:
+ # Iterative rather than recursive because extras chains can exceed
Python's default 1000
+ # recursion limit and a cyclic SCC's stack grows with the SCC size.
+ root_key = (pkg_key, extra_name)
+ if (shortcut := self._lookup(root_key)) is not None:
+ return shortcut
+ initial = self._build_frame(pkg_key, extra_name)
+ if initial is None:
+ self._cache[root_key] = False
+ return False, False
+
+ self._in_progress.add(root_key)
+ stack: list[_Frame] = [initial]
+ pending: tuple[bool, bool] | None = None
+ while stack:
+ frame = stack[-1]
+ if pending is not None:
+ pending = self._fold_pending(frame, pending, stack)
+ if pending is not None:
+ continue
+ pending = self._advance(frame, stack)
+
+ assert pending is not None
+ return pending
+
+ def _fold_pending(
+ self,
+ frame: _Frame,
+ pending: tuple[bool, bool],
+ stack: list[_Frame],
+ ) -> tuple[bool, bool] | None:
+ # None signals "advance frame"; a returned tuple bubbles a finished
result up. The dual
+ # protocol exists so the caller's loop can distinguish in-progress
from completed frames
+ # without an extra flag.
+ satisfied, used = pending
+ if used:
+ frame.used_assumption = True
+ if not satisfied:
+ result = self._finalize(frame, result=False)
+ stack.pop()
+ return result
+ frame.sub_idx += 1
+ return None
+
+ def _advance(self, frame: _Frame, stack: list[_Frame]) -> tuple[bool,
bool] | None:
+ action = self._step(frame)
+ if action is _Action.SUCCESS:
+ result = self._finalize(frame, result=True)
+ stack.pop()
+ return result
+ if action is _Action.FAIL:
+ result = self._finalize(frame, result=False)
+ stack.pop()
+ return result
+ sub_key = action
+ if (shortcut := self._lookup(sub_key)) is not None:
+ return shortcut
+ sub_frame = self._build_frame(sub_key[0], sub_key[1])
+ if sub_frame is None:
+ self._cache[sub_key] = False
+ return False, False
+ self._in_progress.add(sub_key)
+ stack.append(sub_frame)
+ return None
+
+ def _lookup(self, key: tuple[str, str]) -> tuple[bool, bool] | None:
+ if (cached := self._cache.get(key)) is not None:
+ return cached, False
+ if key in self._in_progress:
+ return True, True
+ return None
+
+ def _build_frame(self, pkg_key: str, extra_name: str) -> _Frame | None:
+ dist_pkg = self._idx.get(pkg_key)
+ if dist_pkg is None or dist_pkg not in self._pkg_deps:
+ return None
+ reqs = list(dist_pkg.requires_for_extras(frozenset({extra_name})))
+ if not reqs:
+ return None
+ return _Frame((pkg_key, extra_name), reqs)
+
+ def _step(self, frame: _Frame) -> _Action | tuple[str, str]:
+ # Empty sub_extras for a req are skipped here so the caller never sees
a no-op resolve.
+ while True:
+ if frame.req_idx >= 0 and frame.sub_idx < len(frame.sub_extras):
+ _, _, dep_key = frame.reqs[frame.req_idx]
+ return dep_key, frame.sub_extras[frame.sub_idx]
+ frame.req_idx += 1
+ if frame.req_idx >= len(frame.reqs):
+ return _Action.SUCCESS
+ req, _, dep_key = frame.reqs[frame.req_idx]
+ if dep_key not in self._idx:
+ return _Action.FAIL
+ frame.sub_extras = tuple(req.extras)
+ frame.sub_idx = 0
+
+ def _finalize(self, frame: _Frame, *, result: bool) -> tuple[bool, bool]:
+ self._in_progress.discard(frame.key)
+ if not frame.used_assumption:
+ self._cache[frame.key] = result
+ return result, frame.used_assumption
+
+
def _extra_is_satisfied(
pkg_key: str,
extra_name: str,
pkg_deps: dict[DistPackage, list[ReqPackage]],
idx: dict[str, DistPackage],
- visited: set[tuple[str, str]],
) -> bool:
- if (pkg_key, extra_name) in visited:
- return True
- visited.add((pkg_key, extra_name))
- if (dist_pkg := idx.get(pkg_key)) is None or dist_pkg not in pkg_deps:
- return False
- if not (reqs :=
list(dist_pkg.requires_for_extras(frozenset({extra_name})))):
- return False
- for req, _ in reqs:
- if (dep_key := canonicalize_name(req.name)) not in idx:
- return False
- if not all(_extra_is_satisfied(dep_key, sub_extra, pkg_deps, idx,
visited) for sub_extra in req.extras):
- return False
- return True
+ return _ExtrasResolver(pkg_deps, idx).is_satisfied(pkg_key, extra_name)
__all__ = [
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pipdeptree-2.35.1/src/pipdeptree/_models/package.py
new/pipdeptree-2.35.3/src/pipdeptree/_models/package.py
--- old/pipdeptree-2.35.1/src/pipdeptree/_models/package.py 2026-04-10
17:32:44.000000000 +0200
+++ new/pipdeptree-2.35.3/src/pipdeptree/_models/package.py 2026-05-17
17:45:31.000000000 +0200
@@ -1,6 +1,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
+from functools import cached_property
from importlib import import_module
from importlib.metadata import Distribution, PackageMetadata,
PackageNotFoundError, metadata, version
from inspect import ismodule
@@ -131,44 +132,63 @@
def _get_dist_metadata(self) -> PackageMetadata:
return self._obj.metadata
+ @cached_property
+ def _parsed_requires(self) -> list[Requirement | str]:
+ # Shared between requires() and _extras_index so PEP 508 parsing
happens at most once per
+ # raw entry. str entries preserve the raw text of invalid requirements
so requires() can
+ # still surface them via InvalidRequirementError, matching the
original semantics.
+ return [_try_parse_requirement(raw_req) for raw_req in
self._obj.requires or []]
+
def requires(self) -> Iterator[Requirement]:
"""
Return an iterator of the distribution's required dependencies.
:raises InvalidRequirementError: If the metadata contains invalid
requirement strings.
"""
- for r in self._obj.requires or []:
- try:
- req = Requirement(r)
- except InvalidRequirement:
- raise InvalidRequirementError(r) from None
- if not req.marker or req.marker.evaluate():
- # Make sure that we're either dealing with a dependency that
has no environment markers or does but
- # are evaluated True against the existing environment (if it's
False, it means they cannot be
- # installed). "extra" markers are always evaluated False here
which is what we want when retrieving
- # only required dependencies.
- yield req
+ for entry in self._parsed_requires:
+ if isinstance(entry, str):
+ raise InvalidRequirementError(entry) from None
+ if not entry.marker or entry.marker.evaluate():
+ # "extra" markers always evaluate False here, which is what
excludes extras-gated
+ # reqs from this mandatory-only iterator.
+ yield entry
- @property
+ @cached_property
def provides_extras(self) -> frozenset[str]:
return frozenset(self._obj.metadata.get_all("Provides-Extra") or ())
- def requires_for_extras(self, extras: frozenset[str]) ->
Iterator[tuple[Requirement, str]]:
- """Yield (requirement, extra_name) for requirements gated behind the
given extras."""
- for raw_req in self._obj.requires or []:
- try:
- req = Requirement(raw_req)
- except InvalidRequirement:
+ @cached_property
+ def _extras_index(self) -> list[tuple[Requirement, list[str], str]]:
+ # Cached because requires_for_extras is called many times per package
across the
+ # satisfaction and resolution passes; without this, PEP 508 parsing
and marker evaluation
+ # dominate --extras runtime. dep_key is precomputed alongside since
canonicalize_name
+ # otherwise shows up as the next-largest contributor in the hot path.
+ extras = sorted(self.provides_extras)
+ if not extras:
+ return []
+ result: list[tuple[Requirement, list[str], str]] = []
+ for entry in self._parsed_requires:
+ if isinstance(entry, str):
continue
- if not req.marker or req.marker.evaluate():
+ if not entry.marker or entry.marker.evaluate():
continue
- for extra in extras:
- if req.marker.evaluate({"extra": extra}):
- yield req, extra
+ if matching := [e for e in extras if
entry.marker.evaluate({"extra": e})]:
+ result.append((entry, matching, canonicalize_name(entry.name)))
+ return result
+
+ def requires_for_extras(self, extras: frozenset[str]) ->
Iterator[tuple[Requirement, str, str]]:
+ """Yield (requirement, extra_name, dep_key) for requirements gated
behind the given extras."""
+ for req, matching, dep_key in self._extras_index:
+ for extra in matching:
+ if extra in extras:
+ yield req, extra, dep_key
break
- @property
+ @cached_property
def version(self) -> str:
+ # Cached because each access reparses the METADATA file on the
underlying Distribution and
+ # the renderer reads it once per occurrence in the tree (tens of
thousands of times for
+ # large environments under --extras).
return self._obj.version
def unwrap(self) -> Distribution:
@@ -254,7 +274,7 @@
@property
def version_spec(self) -> str | None:
- specs = sorted(map(str, self._obj.specifier), reverse=True) # ty:
ignore[invalid-argument-type] # `reverse` makes '>' prior to '<'
+ specs = sorted(map(str, self._obj.specifier), reverse=True) #
`reverse` makes '>' prior to '<'
return ",".join(specs) if specs else None
@property
@@ -309,6 +329,13 @@
return result
+def _try_parse_requirement(raw_req: str) -> Requirement | str:
+ try:
+ return Requirement(raw_req)
+ except InvalidRequirement:
+ return raw_req
+
+
__all__ = [
"DistPackage",
"ReqPackage",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pipdeptree-2.35.1/src/pipdeptree/_render/mermaid.py
new/pipdeptree-2.35.3/src/pipdeptree/_render/mermaid.py
--- old/pipdeptree-2.35.1/src/pipdeptree/_render/mermaid.py 2026-04-10
17:32:44.000000000 +0200
+++ new/pipdeptree-2.35.3/src/pipdeptree/_render/mermaid.py 2026-05-17
17:45:31.000000000 +0200
@@ -78,7 +78,7 @@
package.project_name,
"(missing)" if package.is_missing else package.installed_version,
]
- if extra := context and context.build_node_extra_label(package.key,
tree, "<br/>"):
+ if context and (extra := context.build_node_extra_label(package.key,
tree, "<br/>")):
label_parts.append(extra)
package_key = _mermaid_id(package.key, node_ids_map)
nodes.add(f'{package_key}["{"<br/>".join(label_parts)}"]')
@@ -97,7 +97,7 @@
) -> None:
for package, dependencies in tree.items():
label_parts = [package.project_name, package.version]
- if extra := context and context.build_node_extra_label(package.key,
tree, "<br/>"):
+ if context and (extra := context.build_node_extra_label(package.key,
tree, "<br/>")):
label_parts.append(extra)
package_key = _mermaid_id(package.key, node_ids_map)
nodes.add(f'{package_key}["{"<br/>".join(label_parts)}"]')
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pipdeptree-2.35.1/src/pipdeptree/_render/text.py
new/pipdeptree-2.35.3/src/pipdeptree/_render/text.py
--- old/pipdeptree-2.35.1/src/pipdeptree/_render/text.py 2026-04-10
17:32:44.000000000 +0200
+++ new/pipdeptree-2.35.3/src/pipdeptree/_render/text.py 2026-05-17
17:45:31.000000000 +0200
@@ -39,12 +39,12 @@
def get_top_level_nodes(tree: PackageDAG, *, list_all: bool) ->
list[DistPackage]:
"""
- Get a list of nodes that will appear at the first depth of the dependency
tree.
+ Get a list of nodes that will appear at the first depth of the dependency
tree. This also sorts `tree` in-place.
:param tree: the package tree
:param list_all: whether to list all the pkgs at the root level or only
those that are the sub-dependencies
"""
- tree = tree.sort()
+ tree = tree.sort(in_place=True)
nodes = list(tree.keys())
branch_keys = {r.key for r in chain.from_iterable(tree.values())}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pipdeptree-2.35.1/tests/_models/test_dag.py
new/pipdeptree-2.35.3/tests/_models/test_dag.py
--- old/pipdeptree-2.35.1/tests/_models/test_dag.py 2026-04-10
17:32:44.000000000 +0200
+++ new/pipdeptree-2.35.3/tests/_models/test_dag.py 2026-05-17
17:45:31.000000000 +0200
@@ -192,6 +192,35 @@
assert all(isinstance(v, ReqPackage) for v in
chain.from_iterable(t2.values()))
[email protected](
+ ("in_place"),
+ [
+ pytest.param(False, id="shallow-copy"),
+ pytest.param(True, id="in-place"),
+ ],
+)
+def test_package_dag_sort(in_place: bool, mock_pkgs: Callable[[MockGraph],
Iterator[Mock]]) -> None:
+ unsorted_graph: MockGraph = {
+ ("a", "1.2.3"): [("c", [(">=", "1.0.0")]), ("b", [(">=", "2.0.0")])],
+ ("b", "4.5.6"): [("b", [(">=", "2.0.0")])],
+ ("c", "1.0.0"): [],
+ }
+
+ dag = PackageDAG.from_pkgs(list(mock_pkgs(unsorted_graph)))
+ a_children_unsorted = dag.get_children("a")
+ assert [child.key for child in a_children_unsorted] == ["c", "b"]
+
+ result = dag.sort(in_place=in_place)
+
+ if in_place:
+ assert result is dag
+ else:
+ assert result is not dag
+
+ a_children_sorted = result.get_children("a")
+ assert [child.key for child in a_children_sorted] == ["b", "c"]
+
+
def test_package_dag_from_pkgs(mock_pkgs: Callable[[MockGraph],
Iterator[Mock]]) -> None:
# when pip's _vendor.packaging.requirements.Requirement's requires() gives
a lowercased package name but the actual
# package name in PyPI is mixed case, expect the mixed case version
@@ -360,4 +389,79 @@
dist = DistPackage(make_mock_dist("pkg", "1.0.0",
provides_extras=["feat"], requires=["dep ; extra == 'feat'"]))
idx: dict[str, DistPackage] = {"pkg": dist}
empty_dag: dict[DistPackage, list[ReqPackage]] = {}
- assert not _extra_is_satisfied("pkg", "feat", empty_dag, idx, set())
+ assert not _extra_is_satisfied("pkg", "feat", empty_dag, idx)
+
+
+def test_dag_extras_dedupes_identical_metadata_entries(make_mock_dist:
MockDistMaker) -> None:
+ # Without dedup, malformed metadata with repeated Requires-Dist entries
would yield repeated edges.
+ pkgs = [
+ make_mock_dist(
+ "parent",
+ "1.0.0",
+ requires=["child[feat]"],
+ ),
+ make_mock_dist(
+ "child",
+ "1.0.0",
+ requires=["leaf ; extra == 'feat'", "leaf ; extra == 'feat'"],
+ provides_extras=["feat"],
+ ),
+ make_mock_dist("leaf", "1.0.0"),
+ ]
+ dag = PackageDAG.from_pkgs(pkgs, include_extras=True)
+ leaf_edges = [d for d in dag.get_children("child") if d.key == "leaf"]
+ assert len(leaf_edges) == 1
+
+
+def test_dag_extras_cyclic_convergent_paths_resolve_correctly(make_mock_dist:
MockDistMaker) -> None:
+ pkgs = [
+ make_mock_dist(
+ "a-pkg",
+ "1.0.0",
+ requires=["b-pkg[y] ; extra == 'x'", "c-pkg[y] ; extra == 'x'"],
+ provides_extras=["x"],
+ ),
+ make_mock_dist("b-pkg", "1.0.0", requires=["a-pkg[x] ; extra == 'y'"],
provides_extras=["y"]),
+ make_mock_dist("c-pkg", "1.0.0", requires=["b-pkg[y] ; extra == 'y'"],
provides_extras=["y"]),
+ ]
+ dag = PackageDAG.from_pkgs(pkgs, include_extras=True)
+ assert {d.key for d in dag.get_children("a-pkg")} == {"b-pkg", "c-pkg"}
+
+
+def test_dag_extras_resolver_caches_acyclic_results(make_mock_dist:
MockDistMaker) -> None:
+ # Two independent satisfied extras exercise the global cache path, not
just the per-query scope cache.
+ pkgs = [
+ make_mock_dist("alpha", "1.0.0", requires=["shared ; extra == 'a'"],
provides_extras=["a"]),
+ make_mock_dist("beta", "1.0.0", requires=["shared ; extra == 'b'"],
provides_extras=["b"]),
+ make_mock_dist("shared", "1.0.0"),
+ ]
+ dag = PackageDAG.from_pkgs(pkgs, include_extras=True)
+ assert {d.key for d in dag.get_children("alpha")} == {"shared"}
+ assert {d.key for d in dag.get_children("beta")} == {"shared"}
+
+
+def
test_dag_extras_unsatisfiable_sub_extra_does_not_activate_parent(make_mock_dist:
MockDistMaker) -> None:
+ # Parent's extra requires a sub-package's extra that has no requirements
gated behind it; that
+ # sub-extra is vacuously unsatisfied, so parent's extra must also count as
unsatisfied.
+ pkgs = [
+ make_mock_dist("parent", "1.0.0", requires=["child[empty] ; extra ==
'feat'"], provides_extras=["feat"]),
+ make_mock_dist("child", "1.0.0", provides_extras=["empty"]),
+ ]
+ dag = PackageDAG.from_pkgs(pkgs, include_extras=True)
+ assert dag.get_children("parent") == []
+
+
+def
test_dag_extras_resolves_chains_deeper_than_recursion_limit(make_mock_dist:
MockDistMaker) -> None:
+ # An extras chain longer than Python's default recursion limit must still
resolve; a recursive
+ # implementation of the satisfaction check would raise RecursionError here.
+ chain_length = 1500
+ pkgs: list[Any] = [make_mock_dist("leaf", "1.0.0")]
+ pkgs.append(
+ make_mock_dist(f"l{chain_length - 1}", "1.0.0", requires=["leaf ;
extra == 'x'"], provides_extras=["x"])
+ )
+ pkgs.extend(
+ make_mock_dist(f"l{i}", "1.0.0", requires=[f"l{i + 1}[x] ; extra ==
'x'"], provides_extras=["x"])
+ for i in range(chain_length - 1)
+ )
+ dag = PackageDAG.from_pkgs(pkgs, include_extras=True)
+ assert {dep.key for dep in dag.get_children("l0")} == {"l1"}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/pipdeptree-2.35.1/tests/_models/test_package.py
new/pipdeptree-2.35.3/tests/_models/test_package.py
--- old/pipdeptree-2.35.1/tests/_models/test_package.py 2026-04-10
17:32:44.000000000 +0200
+++ new/pipdeptree-2.35.3/tests/_models/test_package.py 2026-05-17
17:45:31.000000000 +0200
@@ -248,10 +248,10 @@
dp = DistPackage(dist)
results = list(dp.requires_for_extras(frozenset({"signedtoken"})))
assert len(results) == 2
+ assert results[0] == (results[0][0], "signedtoken", "cryptography")
assert results[0][0].name == "cryptography"
- assert results[0][1] == "signedtoken"
+ assert results[1] == (results[1][0], "signedtoken", "pyjwt")
assert results[1][0].name == "pyjwt"
- assert results[1][1] == "signedtoken"
def test_requires_for_extras_skips_non_matching(make_mock_dist: MockDistMaker)
-> None:
@@ -306,6 +306,25 @@
assert results[0][0].name == "bar"
+def test_requires_for_extras_no_extras_provided(make_mock_dist: MockDistMaker)
-> None:
+ # The cached index must short-circuit when Provides-Extra is empty so
unrelated environments stay cheap.
+ dist = make_mock_dist("foo", "1.0.0", requires=["bar ; extra == 'never'"])
+ dp = DistPackage(dist)
+ assert list(dp.requires_for_extras(frozenset({"never"}))) == []
+
+
+def test_requires_for_extras_marker_matches_no_declared_extra(make_mock_dist:
MockDistMaker) -> None:
+ # Reqs gated by extras not in Provides-Extra must not be reported,
otherwise typos in metadata leak edges.
+ dist = make_mock_dist(
+ "foo",
+ "1.0.0",
+ requires=["bar ; extra == 'undeclared'"],
+ provides_extras=["dev"],
+ )
+ dp = DistPackage(dist)
+ assert list(dp.requires_for_extras(frozenset({"dev"}))) == []
+
+
def test_req_package_render_as_branch_with_extra() -> None:
bar = Mock(metadata={"Name": "bar"}, version="4.1.0")
bar_req = MagicMock(specifier=[">=4.0"])