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"])

Reply via email to