Skip to content

Commit d0659c0

Browse files
impl
1 parent 410697d commit d0659c0

File tree

6 files changed

+356
-200
lines changed

6 files changed

+356
-200
lines changed

crates/pyrefly_config/src/config.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ use pyrefly_python::sys_info::PythonVersion;
3737
use pyrefly_python::sys_info::SysInfo;
3838
use pyrefly_util::absolutize::Absolutize as _;
3939
use pyrefly_util::arc_id::ArcId;
40+
use pyrefly_util::editable_install::get_editable_source_paths;
4041
use pyrefly_util::fs_anyhow;
4142
use pyrefly_util::globs::FilteredGlobs;
4243
use pyrefly_util::globs::Glob;
@@ -915,16 +916,31 @@ impl ConfigFile {
915916
result.insert(WatchPattern::root(config_root, format!("**/{config}")));
916917
});
917918
}
919+
let site_packages: Vec<PathBuf> = config.site_package_path().cloned().collect();
920+
let editable_paths = get_editable_source_paths(&site_packages);
921+
918922
config
919923
.search_path()
920-
.chain(config.site_package_path())
924+
.cloned()
925+
.chain(site_packages.iter().cloned())
926+
.chain(editable_paths.iter().cloned())
921927
.cartesian_product(PYTHON_EXTENSIONS.iter().chain(COMPILED_FILE_SUFFIXES))
922928
.for_each(|(s, suffix)| {
923929
result.insert(WatchPattern::root(
924-
InternedPath::from_path(s),
930+
InternedPath::from_path(&s),
925931
format!("**/*.{suffix}"),
926932
));
927933
});
934+
935+
for site_package_path in &site_packages {
936+
let root = InternedPath::from_path(site_package_path);
937+
result.insert(WatchPattern::root(root.dupe(), "**/*.pth".to_owned()));
938+
result.insert(WatchPattern::root(root.dupe(), "**/*.egg-link".to_owned()));
939+
result.insert(WatchPattern::root(
940+
root,
941+
"**/*.dist-info/direct_url.json".to_owned(),
942+
));
943+
}
928944
}
929945

930946
for source_db in source_dbs {
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
use std::fs;
9+
use std::path::Path;
10+
use std::path::PathBuf;
11+
use std::sync::LazyLock;
12+
13+
use lsp_types::Url;
14+
use serde::Deserialize;
15+
use starlark_map::small_map::SmallMap;
16+
17+
use crate::lock::Mutex;
18+
19+
/// PEP 610 direct_url.json structure for detecting editable installs.
20+
#[derive(Deserialize)]
21+
struct DirectUrl {
22+
url: String,
23+
#[serde(default)]
24+
dir_info: DirInfo,
25+
}
26+
27+
#[derive(Deserialize, Default)]
28+
struct DirInfo {
29+
#[serde(default)]
30+
editable: bool,
31+
}
32+
33+
/// Cache for editable source paths, keyed by sorted site-packages paths.
34+
/// This avoids re-scanning site-packages on every check.
35+
static EDITABLE_PATHS_CACHE: LazyLock<Mutex<SmallMap<Vec<PathBuf>, Vec<PathBuf>>>> =
36+
LazyLock::new(|| Mutex::new(SmallMap::new()));
37+
38+
/// Returns true if the path is an editable-install metadata file.
39+
pub fn is_editable_metadata_file(path: &Path) -> bool {
40+
match path.file_name().and_then(|name| name.to_str()) {
41+
Some("direct_url.json") => true,
42+
Some(name) => name.ends_with(".pth") || name.ends_with(".egg-link"),
43+
None => false,
44+
}
45+
}
46+
47+
/// Clear the editable source paths cache.
48+
pub fn clear_editable_source_paths_cache() {
49+
EDITABLE_PATHS_CACHE.lock().clear();
50+
}
51+
52+
/// Get editable source paths for the given site-packages, using cache.
53+
pub fn get_editable_source_paths(site_packages: &[PathBuf]) -> Vec<PathBuf> {
54+
let mut key: Vec<PathBuf> = site_packages.to_vec();
55+
key.sort();
56+
57+
let mut cache = EDITABLE_PATHS_CACHE.lock();
58+
if let Some(paths) = cache.get(&key) {
59+
return paths.clone();
60+
}
61+
62+
let paths = detect_editable_packages(site_packages);
63+
cache.insert(key, paths.clone());
64+
paths
65+
}
66+
67+
/// Detect editable packages by scanning site-packages for direct_url.json (PEP 610), .pth, and .egg-link files.
68+
fn detect_editable_packages(site_packages: &[PathBuf]) -> Vec<PathBuf> {
69+
let mut editable_paths = Vec::new();
70+
71+
for sp in site_packages {
72+
let Ok(entries) = fs::read_dir(sp) else {
73+
continue;
74+
};
75+
76+
let mut dist_info_dirs = Vec::new();
77+
for entry in entries.filter_map(|e| e.ok()) {
78+
let path = entry.path();
79+
80+
if path.is_dir() {
81+
if path.extension().is_some_and(|ext| ext == "dist-info") {
82+
dist_info_dirs.push(path);
83+
}
84+
continue;
85+
}
86+
87+
// Parse any .pth or .egg-link files in the site-packages root.
88+
if !path.is_file() && !path.is_symlink() {
89+
continue;
90+
}
91+
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
92+
continue;
93+
};
94+
if name.ends_with(".pth") {
95+
editable_paths.extend(read_pth_search_paths(&path));
96+
} else if name.ends_with(".egg-link") {
97+
if let Some(path) = read_egg_link_path(&path) {
98+
editable_paths.push(path);
99+
}
100+
}
101+
}
102+
103+
for path in dist_info_dirs {
104+
let direct_url_path = path.join("direct_url.json");
105+
let Ok(content) = fs::read_to_string(&direct_url_path) else {
106+
continue;
107+
};
108+
let Ok(direct_url) = serde_json::from_str::<DirectUrl>(&content) else {
109+
continue;
110+
};
111+
112+
if !direct_url.dir_info.editable {
113+
continue;
114+
}
115+
116+
let Ok(url) = Url::parse(&direct_url.url) else {
117+
continue;
118+
};
119+
if url.scheme() != "file" {
120+
continue;
121+
}
122+
let Ok(source_path) = url.to_file_path() else {
123+
continue;
124+
};
125+
if source_path.is_dir() {
126+
editable_paths.push(source_path);
127+
}
128+
}
129+
}
130+
131+
editable_paths.sort();
132+
editable_paths.dedup();
133+
editable_paths
134+
}
135+
136+
fn read_pth_search_paths(pth_file: &Path) -> Vec<PathBuf> {
137+
let mut search_paths = Vec::new();
138+
let Ok(metadata) = fs::metadata(pth_file) else {
139+
return search_paths;
140+
};
141+
if metadata.len() > 64 * 1024 {
142+
return search_paths;
143+
}
144+
let Ok(data) = fs::read_to_string(pth_file) else {
145+
return search_paths;
146+
};
147+
let parent = pth_file.parent().unwrap_or_else(|| Path::new(""));
148+
for line in data.lines() {
149+
let trimmed = line.trim();
150+
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("import ") {
151+
continue;
152+
}
153+
let candidate = Path::new(trimmed);
154+
let path = if candidate.is_absolute() {
155+
candidate.to_path_buf()
156+
} else {
157+
parent.join(candidate)
158+
};
159+
if path.is_dir() {
160+
search_paths.push(path);
161+
}
162+
}
163+
search_paths
164+
}
165+
166+
fn read_egg_link_path(egg_link: &Path) -> Option<PathBuf> {
167+
let Ok(data) = fs::read_to_string(egg_link) else {
168+
return None;
169+
};
170+
let first_line = data.lines().find(|line| !line.trim().is_empty())?;
171+
let trimmed = first_line.trim();
172+
let candidate = Path::new(trimmed);
173+
let parent = egg_link.parent().unwrap_or_else(|| Path::new(""));
174+
let path = if candidate.is_absolute() {
175+
candidate.to_path_buf()
176+
} else {
177+
parent.join(candidate)
178+
};
179+
if path.is_dir() { Some(path) } else { None }
180+
}
181+
182+
#[cfg(test)]
183+
mod tests {
184+
use std::fs;
185+
186+
use lsp_types::Url;
187+
188+
use super::get_editable_source_paths;
189+
190+
#[test]
191+
fn test_get_editable_source_paths_finds_editable_package() {
192+
let temp_dir = tempfile::tempdir().unwrap();
193+
let site_packages = temp_dir.path().join("site-packages");
194+
fs::create_dir(&site_packages).unwrap();
195+
196+
let dist_info = site_packages.join("mypackage-1.0.0.dist-info");
197+
fs::create_dir(&dist_info).unwrap();
198+
199+
let source_dir = temp_dir.path().join("mypackage_source");
200+
fs::create_dir(&source_dir).unwrap();
201+
202+
// Use Url::from_file_path to construct a proper file URL that works on all platforms
203+
let source_url = Url::from_file_path(&source_dir).unwrap();
204+
let direct_url_content = format!(
205+
r#"{{"url": "{}", "dir_info": {{"editable": true}}}}"#,
206+
source_url.as_str()
207+
);
208+
fs::write(dist_info.join("direct_url.json"), direct_url_content).unwrap();
209+
210+
let result = get_editable_source_paths(std::slice::from_ref(&site_packages));
211+
212+
assert_eq!(result.len(), 1);
213+
assert_eq!(result[0], source_dir);
214+
}
215+
216+
#[test]
217+
fn test_get_editable_source_paths_ignores_non_editable_package() {
218+
let temp_dir = tempfile::tempdir().unwrap();
219+
let site_packages = temp_dir.path().join("site-packages");
220+
fs::create_dir(&site_packages).unwrap();
221+
222+
let dist_info = site_packages.join("requests-2.28.0.dist-info");
223+
fs::create_dir(&dist_info).unwrap();
224+
225+
let source_dir = temp_dir.path().join("requests_source");
226+
fs::create_dir(&source_dir).unwrap();
227+
228+
// Use Url::from_file_path to construct a proper file URL that works on all platforms
229+
let source_url = Url::from_file_path(&source_dir).unwrap();
230+
let direct_url_content = format!(
231+
r#"{{"url": "{}", "dir_info": {{"editable": false}}}}"#,
232+
source_url.as_str()
233+
);
234+
fs::write(dist_info.join("direct_url.json"), direct_url_content).unwrap();
235+
236+
let result = get_editable_source_paths(&[site_packages]);
237+
238+
assert!(result.is_empty());
239+
}
240+
241+
#[test]
242+
fn test_get_editable_source_paths_ignores_missing_direct_url_json() {
243+
let temp_dir = tempfile::tempdir().unwrap();
244+
let site_packages = temp_dir.path().join("site-packages");
245+
fs::create_dir(&site_packages).unwrap();
246+
247+
let dist_info = site_packages.join("somepackage-1.0.0.dist-info");
248+
fs::create_dir(&dist_info).unwrap();
249+
250+
let result = get_editable_source_paths(&[site_packages]);
251+
252+
assert!(result.is_empty());
253+
}
254+
255+
#[test]
256+
fn test_get_editable_source_paths_ignores_nonexistent_source_directory() {
257+
let temp_dir = tempfile::tempdir().unwrap();
258+
let site_packages = temp_dir.path().join("site-packages");
259+
fs::create_dir(&site_packages).unwrap();
260+
261+
let dist_info = site_packages.join("mypackage-1.0.0.dist-info");
262+
fs::create_dir(&dist_info).unwrap();
263+
264+
let nonexistent_path = temp_dir.path().join("does_not_exist");
265+
266+
// Use Url::from_file_path to construct a proper file URL that works on all platforms
267+
let nonexistent_url = Url::from_file_path(&nonexistent_path).unwrap();
268+
let direct_url_content = format!(
269+
r#"{{"url": "{}", "dir_info": {{"editable": true}}}}"#,
270+
nonexistent_url.as_str()
271+
);
272+
fs::write(dist_info.join("direct_url.json"), direct_url_content).unwrap();
273+
274+
let result = get_editable_source_paths(&[site_packages]);
275+
276+
assert!(result.is_empty());
277+
}
278+
279+
#[test]
280+
fn test_get_editable_source_paths_reads_pth_paths() {
281+
let temp_dir = tempfile::tempdir().unwrap();
282+
let site_packages = temp_dir.path().join("site-packages");
283+
fs::create_dir(&site_packages).unwrap();
284+
285+
let source_dir = temp_dir.path().join("editable_source");
286+
fs::create_dir(&source_dir).unwrap();
287+
288+
let pth_content = format!("# comment\n{}\nimport site\n", source_dir.to_string_lossy());
289+
fs::write(site_packages.join("editable.pth"), pth_content).unwrap();
290+
291+
let result = get_editable_source_paths(&[site_packages]);
292+
293+
assert!(
294+
result.contains(&source_dir),
295+
"missing source_dir in result: {result:?}"
296+
);
297+
}
298+
299+
#[test]
300+
fn test_get_editable_source_paths_reads_egg_link() {
301+
let temp_dir = tempfile::tempdir().unwrap();
302+
let site_packages = temp_dir.path().join("site-packages");
303+
fs::create_dir(&site_packages).unwrap();
304+
305+
let source_dir = temp_dir.path().join("editable_source");
306+
fs::create_dir(&source_dir).unwrap();
307+
308+
let egg_link_content = format!("{}\n", source_dir.to_string_lossy());
309+
fs::write(site_packages.join("editable.egg-link"), egg_link_content).unwrap();
310+
311+
let result = get_editable_source_paths(&[site_packages]);
312+
313+
assert!(
314+
result.contains(&source_dir),
315+
"missing source_dir in result: {result:?}"
316+
);
317+
}
318+
}

crates/pyrefly_util/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ pub mod arc_id;
3232
pub mod args;
3333
pub mod assert_size;
3434
pub mod display;
35+
pub mod editable_install;
3536
pub mod events;
3637
pub mod exclusive_lock;
3738
pub mod forgetter;

0 commit comments

Comments
 (0)