@@ -16,6 +16,7 @@ class PackageInfo:
1616 version : str
1717 dependencies : list [str ] = field (default_factory = list )
1818 is_direct : bool = False
19+ is_dev : bool = False
1920
2021
2122@dataclass
@@ -24,16 +25,19 @@ class DependencyGraph:
2425
2526 packages : dict [str , PackageInfo ] # normalized name -> PackageInfo
2627 reverse_map : dict [str , list [str ]] # package -> list of parents
27- direct_deps : set [str ] # normalized names of direct dependencies
28+ direct_deps : set [str ] # normalized names of direct runtime dependencies
29+ dev_deps : set [str ] = field (default_factory = set ) # direct dev dependencies
2830
2931 def trace_chain (self , package : str ) -> list [str ]:
3032 """Find shortest path from a direct dependency to the given package.
3133
32- Returns a list like ["flask", "werkzeug", "markupsafe"] meaning
33- flask -> werkzeug -> markupsafe.
34+ Searches runtime deps first, then dev deps. Returns a list like
35+ ["flask", "werkzeug", "markupsafe"] meaning flask -> werkzeug -> markupsafe.
3436 """
3537 package = normalize (package )
36- if package in self .direct_deps :
38+ all_direct = self .direct_deps | self .dev_deps
39+
40+ if package in all_direct :
3741 return [package ]
3842
3943 # BFS from package upward through reverse_map to find a direct dep
@@ -49,15 +53,28 @@ def trace_chain(self, package: str) -> list[str]:
4953 continue
5054 visited .add (parent )
5155 new_path = path + [parent ]
52- if parent in self .direct_deps :
53- # Return in top-down order: direct dep -> ... -> target
56+ if parent in all_direct :
5457 new_path .reverse ()
5558 return new_path
5659 queue .append (new_path )
5760
5861 # No path found to a direct dep — return just the package
5962 return [package ]
6063
64+ def is_dev_only (self , package : str ) -> bool :
65+ """Check if a package is only reachable through dev dependencies."""
66+ package = normalize (package )
67+ if package in self .direct_deps :
68+ return False
69+ if package in self .dev_deps :
70+ return True
71+
72+ chain = self .trace_chain (package )
73+ if not chain :
74+ return False
75+ root = chain [0 ]
76+ return root in self .dev_deps and root not in self .direct_deps
77+
6178
6279def normalize (name : str ) -> str :
6380 """Normalize a Python package name per PEP 503."""
@@ -98,16 +115,32 @@ def parse_uv_lock(lock_path: Path | str) -> DependencyGraph:
98115 dependencies = deps ,
99116 )
100117
101- # Direct deps are the runtime dependencies of root packages
118+ # Direct runtime deps from root packages
102119 direct_deps : set [str ] = set ()
103120 for root in root_names :
104121 if root in packages :
105122 direct_deps .update (packages [root ].dependencies )
106123
107- # Mark direct deps
124+ # Dev deps from root packages
125+ dev_deps : set [str ] = set ()
126+ for pkg in data .get ("package" , []):
127+ name = normalize (pkg ["name" ])
128+ if name not in root_names :
129+ continue
130+ for _group , deps in pkg .get ("dev-dependencies" , {}).items ():
131+ for d in deps :
132+ dev_deps .add (normalize (d ["name" ]))
133+
134+ # Remove overlap — if a package is both runtime and dev, treat as runtime
135+ dev_deps -= direct_deps
136+
137+ # Mark flags
108138 for dep_name in direct_deps :
109139 if dep_name in packages :
110140 packages [dep_name ].is_direct = True
141+ for dep_name in dev_deps :
142+ if dep_name in packages :
143+ packages [dep_name ].is_dev = True
111144
112145 # Build reverse map (who depends on whom)
113146 reverse_map : dict [str , list [str ]] = {}
@@ -123,6 +156,7 @@ def parse_uv_lock(lock_path: Path | str) -> DependencyGraph:
123156 packages = packages ,
124157 reverse_map = reverse_map ,
125158 direct_deps = direct_deps ,
159+ dev_deps = dev_deps ,
126160 )
127161
128162
0 commit comments