uv_distribution_types/
installed_modules.rs1use std::collections::BTreeSet;
11use std::path::{Component, Path};
12
13use fs_err::File;
14use uv_fs::normalize_path;
15use uv_install_wheel::read_record;
16use uv_pypi_types::ModuleName;
17
18use crate::installed::{InstalledDist, InstalledDistError};
19
20impl InstalledDist {
21 pub fn read_modules(
23 &self,
24 extension_suffixes: &[Box<str>],
25 ) -> Result<BTreeSet<ModuleName>, InstalledDistError> {
26 let dist_info = self.install_path();
27 if !has_extension(dist_info, "dist-info") {
28 return Ok(BTreeSet::new());
29 }
30
31 let record_path = dist_info.join("RECORD");
32 let record = read_record(File::open(&record_path)?)?;
33
34 let mut modules = BTreeSet::new();
35 for entry in record {
36 add_record_module(&entry.path, extension_suffixes, &mut modules);
37 }
38
39 Ok(modules)
40 }
41}
42
43fn add_record_module(
44 path: &str,
45 extension_suffixes: &[Box<str>],
46 modules: &mut BTreeSet<ModuleName>,
47) {
48 let Some(components) = record_path_components(path) else {
49 return;
50 };
51 let Some((file_name, parents)) = components.split_last() else {
52 return;
53 };
54 let file_name = file_name.as_ref();
55
56 if components
58 .iter()
59 .any(|component| has_extension(component.as_ref(), "dist-info"))
60 {
61 return;
62 }
63 if components
66 .first()
67 .is_some_and(|component| has_extension(component.as_ref(), "data"))
68 {
69 return;
70 }
71
72 let mut module_components = parents
73 .iter()
74 .map(std::convert::AsRef::as_ref)
75 .collect::<Vec<_>>();
76 if file_name == "__init__.py" {
79 } else if let Some(stem) = file_name.strip_suffix(".py") {
81 module_components.push(stem);
82 } else if let Some(stem) = bytecode_module_stem(file_name, parents) {
83 if stem != "__init__" {
84 module_components.push(stem);
85 }
86 } else if let Some(stem) = {
87 extension_suffixes.iter().find_map(|suffix| {
91 let stem = file_name.strip_suffix(suffix.as_ref())?;
92 (!stem.is_empty()).then_some(stem)
93 })
94 } {
95 if stem != "__init__" {
96 module_components.push(stem);
97 }
98 } else {
99 return;
100 }
101
102 add_module_components(&module_components, modules);
103}
104
105fn record_path_components(path: &str) -> Option<Vec<Box<str>>> {
106 let normalized = normalize_path(Path::new(path));
107 let path = normalized.as_ref();
108
109 if path.is_absolute() {
112 return None;
113 }
114
115 let mut components = Vec::new();
116 for component in path.components() {
117 match component {
118 Component::Normal(component) => {
119 components.push(Box::from(component.to_str()?));
120 }
121 Component::CurDir => {}
122 Component::ParentDir | Component::Prefix(_) | Component::RootDir => return None,
123 }
124 }
125
126 Some(components)
127}
128
129fn bytecode_module_stem<'a>(file_name: &'a str, parents: &[Box<str>]) -> Option<&'a str> {
135 let stem = file_name.strip_suffix(".pyc")?;
136 if parents
137 .last()
138 .is_some_and(|parent| parent.as_ref() == "__pycache__")
139 {
140 return None;
144 }
145
146 Some(stem)
147}
148
149fn has_extension(path: impl AsRef<Path>, extension: &str) -> bool {
150 path.as_ref()
151 .extension()
152 .is_some_and(|candidate| candidate == extension)
153}
154
155fn add_module_components(components: &[&str], modules: &mut BTreeSet<ModuleName>) {
156 let Ok(module) = ModuleName::from_components(components.iter().copied()) else {
157 return;
158 };
159
160 modules.extend(module.prefixes());
161}
162
163#[cfg(test)]
164mod tests {
165 use std::collections::BTreeSet;
166
167 use indoc::indoc;
168 use uv_pypi_types::ModuleName;
169
170 use super::add_record_module;
171
172 fn extension_suffixes() -> Vec<Box<str>> {
173 [
174 ".cpython-312-darwin.so",
175 ".cpython-314td-darwin.so",
176 ".abi3.so",
177 ".cp312-win_amd64.pyd",
178 ".so",
179 ]
180 .into_iter()
181 .map(Box::from)
182 .collect()
183 }
184
185 fn module_names(modules: BTreeSet<ModuleName>) -> String {
186 modules
187 .into_iter()
188 .map(|module| module.to_string())
189 .collect::<Vec<_>>()
190 .join("\n")
191 }
192
193 #[test]
194 fn record_module_normalizes_record_paths() {
195 let mut modules = BTreeSet::new();
196 add_record_module("./package/../café.py", &[], &mut modules);
197
198 assert_eq!(module_names(modules), "café");
199 }
200
201 #[test]
202 fn record_module_from_legacy_bytecode() {
203 let mut modules = BTreeSet::new();
204 add_record_module("package/module.pyc", &[], &mut modules);
205 add_record_module("legacy.pyc", &[], &mut modules);
206
207 assert_eq!(
208 module_names(modules),
209 indoc! {"
210 legacy
211 package
212 package.module"
213 }
214 );
215 }
216
217 #[test]
218 fn record_module_ignores_pycache_bytecode() {
219 let mut modules = BTreeSet::new();
220 add_record_module(
221 "package/__pycache__/module.cpython-312.opt-1.pyc",
222 &[],
223 &mut modules,
224 );
225 add_record_module(
226 "package/__pycache__/__init__.cpython-312.pyc",
227 &[],
228 &mut modules,
229 );
230
231 assert_eq!(module_names(modules), "");
232 }
233
234 #[test]
235 fn record_module_from_extension_module() {
236 let extension_suffixes = extension_suffixes();
237 let mut modules = BTreeSet::new();
238 add_record_module(
239 "package/extension.cpython-312-darwin.so",
240 &extension_suffixes,
241 &mut modules,
242 );
243 add_record_module(
244 "package/free_threaded.cpython-314td-darwin.so",
245 &extension_suffixes,
246 &mut modules,
247 );
248 add_record_module("package/limited.abi3.so", &extension_suffixes, &mut modules);
249 add_record_module(
250 "package/windows.cp312-win_amd64.pyd",
251 &extension_suffixes,
252 &mut modules,
253 );
254 add_record_module(
255 "package/__init__.cpython-312-darwin.so",
256 &extension_suffixes,
257 &mut modules,
258 );
259 add_record_module("plain.so", &extension_suffixes, &mut modules);
260
261 assert_eq!(
262 module_names(modules),
263 indoc! {"
264 package
265 package.extension
266 package.free_threaded
267 package.limited
268 package.windows
269 plain"
270 }
271 );
272 }
273
274 #[test]
275 fn record_module_ignores_unrecognized_extension_suffixes() {
276 let extension_suffixes = extension_suffixes();
277 let mut modules = BTreeSet::new();
278 add_record_module(
279 "package/extension.not-an-extension-tag.so",
280 &extension_suffixes,
281 &mut modules,
282 );
283 add_record_module(
284 "package/bogus.pypynonsense.so",
285 &extension_suffixes,
286 &mut modules,
287 );
288
289 assert_eq!(module_names(modules), "");
290 }
291}