55from collections .abc import Iterator , Mapping
66from enum import Enum , auto
77from fnmatch import fnmatch
8+ from functools import cached_property
89from itertools import chain
910from typing import TYPE_CHECKING , Literal
1011
@@ -130,16 +131,38 @@ def get_node_as_parent(self, node_key: str) -> DistPackage | None:
130131 except KeyError :
131132 return None
132133
133- def get_children (self , node_key : str ) -> list [ReqPackage ]:
134+ def get_children (self , node_key : str , parent : DistPackage | ReqPackage | None = None ) -> list [ReqPackage ]:
134135 """
135136 Get child nodes for a node by its key.
136137
137138 :param node_key: key of the node to get children of
139+ :param parent: the edge the node was reached through; gates edge-scoped extras so ``PySocks`` shows only
140+ beneath the ``urllib3[socks]`` that asked for it. ``None`` skips gating, which suits top-level nodes and
141+ the graph renderers
138142 :returns: child nodes
139143
140144 """
141145 node = self .get_node_as_parent (node_key )
142- return self ._obj [node ] if node else []
146+ if node is None :
147+ return []
148+ children = self ._obj [node ]
149+ if isinstance (parent , ReqPackage ):
150+ if node_key in self ._scoped_parent_keys :
151+ return _gate_requested (children , parent .requested_extras )
152+ return children
153+ if isinstance (parent , DistPackage ) and parent .req is not None and (scoped := parent .req .scoped_extra ):
154+ return _gate_dependents (children , scoped )
155+ return children
156+
157+ @cached_property
158+ def _scoped_parent_keys (self ) -> frozenset [str ]:
159+ # Node keys whose children include an edge-scoped extra; only these pay the forward-gating cost. Rendering
160+ # revisits a node once per path, so skipping the per-call allocation for the common ungated node matters.
161+ return frozenset (
162+ node .key
163+ for node , children in self ._obj .items ()
164+ if any (isinstance (c , ReqPackage ) and c .scoped_extra for c in children )
165+ )
143166
144167 def filter_nodes ( # noqa: C901, PLR0912
145168 self ,
@@ -373,6 +396,28 @@ def reverse(self) -> PackageDAG: # ty: ignore[invalid-method-override]
373396 return PackageDAG (dict (forward_dag ))
374397
375398
399+ def _gate_requested (children : list [ReqPackage ], requested_extras : frozenset [str ]) -> list [ReqPackage ]:
400+ """Forward gate: keep an extra-scoped edge only when the requiring edge asked for that extra."""
401+ requested = {canonicalize_name (e ) for e in requested_extras }
402+ return [
403+ c
404+ for c in children
405+ if not isinstance (c , ReqPackage ) or c .scoped_extra is None or canonicalize_name (c .scoped_extra ) in requested
406+ ]
407+
408+
409+ def _gate_dependents (children : list [ReqPackage ], extra : str ) -> list [ReqPackage ]:
410+ """Reverse gate: having descended into a package through ``extra``, keep only dependents that asked for it."""
411+ wanted = canonicalize_name (extra )
412+ return [
413+ c
414+ for c in children
415+ if not isinstance (c , DistPackage )
416+ or c .req is None
417+ or wanted in {canonicalize_name (e ) for e in c .req .requested_extras }
418+ ]
419+
420+
376421def _expand_requested_extras (
377422 idx : dict [str , DistPackage ], requested_extras : Mapping [str , set [str ]] | None
378423) -> dict [str , set [str ]]:
@@ -396,46 +441,84 @@ def _resolve_extras(
396441 requested_extras : dict [str , set [str ]] | None = None ,
397442) -> None :
398443 """Add extra/optional dependencies to the DAG in-place."""
399- extras_needed = _seed_extras (pkg_deps , idx , extras )
444+ seed , unconditional = _seed_extras (pkg_deps , idx , extras )
400445 for key , wanted in (requested_extras or {}).items ():
401- extras_needed .setdefault (key , set ()).update (wanted )
402- processed : dict [str , set [str ]] = {}
403- # The same (parent, child, extra) triple can be reached through multiple req.extras propagation
404- # paths across rounds; without dedup it would be appended once per path.
405- seen_edges : set [tuple [str , str , str ]] = set ()
406- while extras_needed :
407- next_round : dict [str , set [str ]] = {}
408- for pkg_key , wanted in extras_needed .items ():
409- new_extras = wanted - processed .get (pkg_key , set ())
410- if not new_extras :
446+ seed .setdefault (key , set ()).update (wanted )
447+ # ``--packages foo[extra]`` is a direct request, so surface it wherever ``foo`` appears.
448+ unconditional .update ((key , extra ) for extra in wanted )
449+ _ExtrasExpander (pkg_deps , idx , unconditional ).expand (seed )
450+
451+
452+ class _ExtrasExpander :
453+ """
454+ Append extra/optional dependencies to a node-keyed DAG, breadth-first over ``req.extras`` chains.
455+
456+ Each appended edge records the ``scoped_extra`` gating it, unless its ``(pkg_key, extra)`` is unconditional
457+ (active-satisfied or ``--packages`` requested). The renderer reads that tag to place an optional dependency only
458+ beneath the parent that asked for the extra instead of under every occurrence of the provider.
459+ """
460+
461+ def __init__ (
462+ self ,
463+ pkg_deps : dict [DistPackage , list [ReqPackage ]],
464+ idx : dict [str , DistPackage ],
465+ unconditional : set [tuple [str , str ]],
466+ ) -> None :
467+ self ._pkg_deps = pkg_deps
468+ self ._idx = idx
469+ self ._unconditional = unconditional
470+ # The same (parent, child, extra) triple can be reached through multiple req.extras propagation paths across
471+ # rounds; without dedup it would be appended once per path.
472+ self ._seen_edges : set [tuple [str , str , str ]] = set ()
473+ self ._processed : dict [str , set [str ]] = {}
474+
475+ def expand (self , pending : dict [str , set [str ]]) -> None :
476+ while pending :
477+ next_round : dict [str , set [str ]] = {}
478+ for pkg_key , wanted in pending .items ():
479+ if new_extras := wanted - self ._processed .get (pkg_key , set ()):
480+ self ._processed .setdefault (pkg_key , set ()).update (new_extras )
481+ self ._attach (pkg_key , new_extras , next_round )
482+ pending = next_round
483+
484+ def _attach (self , pkg_key : str , new_extras : set [str ], next_round : dict [str , set [str ]]) -> None :
485+ dist_pkg = self ._idx .get (pkg_key )
486+ if dist_pkg is None or dist_pkg not in self ._pkg_deps :
487+ return
488+ for req , extra_name , dep_key in dist_pkg .requires_for_extras (frozenset (new_extras )):
489+ if (dist := self ._idx .get (dep_key )) is None :
411490 continue
412- processed .setdefault (pkg_key , set ()).update (new_extras )
413- dist_pkg = idx .get (pkg_key )
414- if dist_pkg is None or dist_pkg not in pkg_deps :
491+ edge_key = (dist_pkg .key , dist .key , extra_name )
492+ if edge_key in self ._seen_edges :
415493 continue
416- for req , extra_name , dep_key in dist_pkg .requires_for_extras (frozenset (new_extras )):
417- if (dist := idx .get (dep_key )) is None :
418- continue
419- edge_key = (dist_pkg .key , dist .key , extra_name )
420- if edge_key in seen_edges :
421- continue
422- seen_edges .add (edge_key )
423- req .name = dist .project_name
424- pkg_deps [dist_pkg ].append (ReqPackage (req , dist , extra = extra_name ))
425- if req .extras :
426- next_round .setdefault (dist .key , set ()).update (req .extras )
427- extras_needed = next_round
494+ self ._seen_edges .add (edge_key )
495+ req .name = dist .project_name
496+ unconditional = (pkg_key , extra_name ) in self ._unconditional
497+ scoped_extra = None if unconditional else extra_name
498+ self ._pkg_deps [dist_pkg ].append (ReqPackage (req , dist , extra = extra_name , scoped_extra = scoped_extra ))
499+ if req .extras :
500+ next_round .setdefault (dist .key , set ()).update (req .extras )
501+ if unconditional :
502+ # An unconditional extra pulls its own sub-extras unconditionally too.
503+ self ._unconditional .update ((dist .key , sub ) for sub in req .extras )
428504
429505
430506def _seed_extras (
431507 pkg_deps : dict [DistPackage , list [ReqPackage ]], idx : dict [str , DistPackage ], extras : ExtrasMode
432- ) -> dict [str , set [str ]]:
433- """Collect the extras to resolve: requested extras, plus satisfiable ones in active mode."""
508+ ) -> tuple [dict [str , set [str ]], set [tuple [str , str ]]]:
509+ """
510+ Collect the extras to resolve: explicit ``name[extra]`` requests, plus satisfiable ones in active mode.
511+
512+ Returns the extras keyed by package and the set of ``(pkg_key, extra)`` pairs that are unconditional, i.e.
513+ active-satisfied rather than gated behind a specific requiring edge.
514+ """
434515 extras_needed = _collect_explicit_extras (pkg_deps )
516+ unconditional : set [tuple [str , str ]] = set ()
435517 if extras == "active" :
436518 for pkg_key , satisfied in _collect_satisfied_extras (pkg_deps , idx ).items ():
437519 extras_needed .setdefault (pkg_key , set ()).update (satisfied )
438- return extras_needed
520+ unconditional .update ((pkg_key , extra ) for extra in satisfied )
521+ return extras_needed , unconditional
439522
440523
441524def _collect_explicit_extras (pkg_deps : dict [DistPackage , list [ReqPackage ]]) -> dict [str , set [str ]]:
0 commit comments