Skip to content

Commit 2daa914

Browse files
authored
Gracefully handle errors in CLI (#12747)
1 parent 6d9205e commit 2daa914

File tree

3 files changed

+100
-84
lines changed

3 files changed

+100
-84
lines changed

crates/red_knot/src/main.rs

+73-58
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
use std::process::ExitCode;
1+
use std::process::{ExitCode, Termination};
22
use std::sync::Mutex;
33

4+
use anyhow::{anyhow, Context};
45
use clap::Parser;
56
use colored::Colorize;
67
use crossbeam::channel as crossbeam_channel;
8+
use salsa::plumbing::ZalsaDatabase;
79

810
use red_knot_server::run_server;
911
use red_knot_workspace::db::RootDatabase;
@@ -12,7 +14,7 @@ use red_knot_workspace::watch;
1214
use red_knot_workspace::watch::WorkspaceWatcher;
1315
use red_knot_workspace::workspace::WorkspaceMetadata;
1416
use ruff_db::program::{ProgramSettings, SearchPathSettings};
15-
use ruff_db::system::{OsSystem, System, SystemPathBuf};
17+
use ruff_db::system::{OsSystem, System, SystemPath, SystemPathBuf};
1618
use target_version::TargetVersion;
1719

1820
use crate::logging::{setup_tracing, Verbosity};
@@ -86,30 +88,25 @@ pub enum Command {
8688
}
8789

8890
#[allow(clippy::print_stdout, clippy::unnecessary_wraps, clippy::print_stderr)]
89-
pub fn main() -> ExitCode {
90-
match run() {
91-
Ok(status) => status.into(),
92-
Err(error) => {
93-
{
94-
use std::io::Write;
95-
96-
// Use `writeln` instead of `eprintln` to avoid panicking when the stderr pipe is broken.
97-
let mut stderr = std::io::stderr().lock();
98-
99-
// This communicates that this isn't a linter error but ruff itself hard-errored for
100-
// some reason (e.g. failed to resolve the configuration)
101-
writeln!(stderr, "{}", "ruff failed".red().bold()).ok();
102-
// Currently we generally only see one error, but e.g. with io errors when resolving
103-
// the configuration it is help to chain errors ("resolving configuration failed" ->
104-
// "failed to read file: subdir/pyproject.toml")
105-
for cause in error.chain() {
106-
writeln!(stderr, " {} {cause}", "Cause:".bold()).ok();
107-
}
108-
}
109-
110-
ExitStatus::Error.into()
91+
pub fn main() -> ExitStatus {
92+
run().unwrap_or_else(|error| {
93+
use std::io::Write;
94+
95+
// Use `writeln` instead of `eprintln` to avoid panicking when the stderr pipe is broken.
96+
let mut stderr = std::io::stderr().lock();
97+
98+
// This communicates that this isn't a linter error but Red Knot itself hard-errored for
99+
// some reason (e.g. failed to resolve the configuration)
100+
writeln!(stderr, "{}", "ruff failed".red().bold()).ok();
101+
// Currently we generally only see one error, but e.g. with io errors when resolving
102+
// the configuration it is help to chain errors ("resolving configuration failed" ->
103+
// "failed to read file: subdir/pyproject.toml")
104+
for cause in error.chain() {
105+
writeln!(stderr, " {} {cause}", "Cause:".bold()).ok();
111106
}
112-
}
107+
108+
ExitStatus::Error
109+
})
113110
}
114111

115112
fn run() -> anyhow::Result<ExitStatus> {
@@ -132,28 +129,43 @@ fn run() -> anyhow::Result<ExitStatus> {
132129
countme::enable(verbosity.is_trace());
133130
let _guard = setup_tracing(verbosity)?;
134131

135-
let cwd = if let Some(cwd) = current_directory {
136-
let canonicalized = cwd.as_utf8_path().canonicalize_utf8().unwrap();
137-
SystemPathBuf::from_utf8_path_buf(canonicalized)
138-
} else {
139-
let cwd = std::env::current_dir().unwrap();
140-
SystemPathBuf::from_path_buf(cwd).unwrap()
132+
// The base path to which all CLI arguments are relative to.
133+
let cli_base_path = {
134+
let cwd = std::env::current_dir().context("Failed to get the current working directory")?;
135+
SystemPathBuf::from_path_buf(cwd).map_err(|path| anyhow!("The current working directory '{}' contains non-unicode characters. Red Knot only supports unicode paths.", path.display()))?
141136
};
142137

138+
let cwd = current_directory
139+
.map(|cwd| {
140+
if cwd.as_std_path().is_dir() {
141+
Ok(SystemPath::absolute(&cwd, &cli_base_path))
142+
} else {
143+
Err(anyhow!(
144+
"Provided current-directory path '{cwd}' is not a directory."
145+
))
146+
}
147+
})
148+
.transpose()?
149+
.unwrap_or_else(|| cli_base_path.clone());
150+
143151
let system = OsSystem::new(cwd.clone());
144-
let workspace_metadata =
145-
WorkspaceMetadata::from_path(system.current_directory(), &system).unwrap();
146-
147-
let site_packages = if let Some(venv_path) = venv_path {
148-
let venv_path = system.canonicalize_path(&venv_path).unwrap_or(venv_path);
149-
assert!(
150-
system.is_directory(&venv_path),
151-
"Provided venv-path {venv_path} is not a directory!"
152-
);
153-
site_packages_dirs_of_venv(&venv_path, &system).unwrap()
154-
} else {
155-
vec![]
156-
};
152+
let workspace_metadata = WorkspaceMetadata::from_path(system.current_directory(), &system)?;
153+
154+
// TODO: Verify the remaining search path settings eagerly.
155+
let site_packages = venv_path
156+
.map(|venv_path| {
157+
let venv_path = SystemPath::absolute(venv_path, &cli_base_path);
158+
159+
if system.is_directory(&venv_path) {
160+
Ok(site_packages_dirs_of_venv(&venv_path, &system)?)
161+
} else {
162+
Err(anyhow!(
163+
"Provided venv-path {venv_path} is not a directory!"
164+
))
165+
}
166+
})
167+
.transpose()?
168+
.unwrap_or_default();
157169

158170
// TODO: Respect the settings from the workspace metadata. when resolving the program settings.
159171
let program_settings = ProgramSettings {
@@ -207,9 +219,9 @@ pub enum ExitStatus {
207219
Error = 2,
208220
}
209221

210-
impl From<ExitStatus> for ExitCode {
211-
fn from(status: ExitStatus) -> Self {
212-
ExitCode::from(status as u8)
222+
impl Termination for ExitStatus {
223+
fn report(self) -> ExitCode {
224+
ExitCode::from(self as u8)
213225
}
214226
}
215227

@@ -262,12 +274,11 @@ impl MainLoop {
262274
result
263275
}
264276

265-
#[allow(clippy::print_stderr)]
266277
fn main_loop(&mut self, db: &mut RootDatabase) -> ExitStatus {
267278
// Schedule the first check.
268279
tracing::debug!("Starting main loop");
269280

270-
let mut revision = 0usize;
281+
let mut revision = 0u64;
271282

272283
while let Ok(message) = self.receiver.recv() {
273284
match message {
@@ -282,7 +293,7 @@ impl MainLoop {
282293
// Send the result back to the main loop for printing.
283294
sender
284295
.send(MainLoopMessage::CheckCompleted { result, revision })
285-
.ok();
296+
.unwrap();
286297
}
287298
});
288299
}
@@ -291,17 +302,20 @@ impl MainLoop {
291302
result,
292303
revision: check_revision,
293304
} => {
305+
let has_diagnostics = !result.is_empty();
294306
if check_revision == revision {
295-
eprintln!("{}", result.join("\n"));
307+
for diagnostic in result {
308+
tracing::error!("{}", diagnostic);
309+
}
296310
} else {
297311
tracing::debug!("Discarding check result for outdated revision: current: {revision}, result revision: {check_revision}");
298312
}
299313

300314
if self.watcher.is_none() {
301-
return if result.is_empty() {
302-
ExitStatus::Success
303-
} else {
315+
return if has_diagnostics {
304316
ExitStatus::Failure
317+
} else {
318+
ExitStatus::Success
305319
};
306320
}
307321

@@ -318,6 +332,10 @@ impl MainLoop {
318332
self.sender.send(MainLoopMessage::CheckWorkspace).unwrap();
319333
}
320334
MainLoopMessage::Exit => {
335+
// Cancel any pending queries and wait for them to complete.
336+
// TODO: Don't use Salsa internal APIs
337+
// [Zulip-Thread](https://salsa.zulipchat.com/#narrow/stream/333573-salsa-3.2E0/topic/Expose.20an.20API.20to.20cancel.20other.20queries)
338+
let _ = db.zalsa_mut();
321339
return ExitStatus::Success;
322340
}
323341
}
@@ -344,10 +362,7 @@ impl MainLoopCancellationToken {
344362
#[derive(Debug)]
345363
enum MainLoopMessage {
346364
CheckWorkspace,
347-
CheckCompleted {
348-
result: Vec<String>,
349-
revision: usize,
350-
},
365+
CheckCompleted { result: Vec<String>, revision: u64 },
351366
ApplyChanges(Vec<watch::ChangeEvent>),
352367
Exit,
353368
}

crates/red_knot_workspace/src/site_packages.rs

+21-18
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,17 @@ fn site_packages_dir_from_sys_prefix(
3434
sys_prefix_path: &SystemPath,
3535
system: &dyn System,
3636
) -> Result<SystemPathBuf, SitePackagesDiscoveryError> {
37+
tracing::debug!("Searching for site-packages directory in '{sys_prefix_path}'");
38+
3739
if cfg!(target_os = "windows") {
3840
let site_packages = sys_prefix_path.join("Lib/site-packages");
39-
return system
40-
.is_directory(&site_packages)
41-
.then_some(site_packages)
42-
.ok_or(SitePackagesDiscoveryError::NoSitePackagesDirFound);
41+
42+
return if system.is_directory(&site_packages) {
43+
tracing::debug!("Resolved site-packages directory to '{site_packages}'");
44+
Ok(site_packages)
45+
} else {
46+
Err(SitePackagesDiscoveryError::NoSitePackagesDirFound)
47+
};
4348
}
4449

4550
// In the Python standard library's `site.py` module (used for finding `site-packages`
@@ -68,11 +73,12 @@ fn site_packages_dir_from_sys_prefix(
6873
let Ok(entry) = entry_result else {
6974
continue;
7075
};
76+
7177
if !entry.file_type().is_directory() {
7278
continue;
7379
}
7480

75-
let path = entry.path();
81+
let mut path = entry.into_path();
7682

7783
// The `python3.x` part of the `site-packages` path can't be computed from
7884
// the `--target-version` the user has passed, as they might be running Python 3.12 locally
@@ -84,29 +90,26 @@ fn site_packages_dir_from_sys_prefix(
8490
// the `pyvenv.cfg` file anyway, in which case we could switch to that method
8591
// rather than iterating through the whole directory until we find
8692
// an entry where the last component of the path starts with `python3.`
87-
if !path
88-
.components()
89-
.next_back()
90-
.is_some_and(|last_part| last_part.as_str().starts_with("python3."))
91-
{
93+
let name = path
94+
.file_name()
95+
.expect("File name to be non-null because path is guaranteed to be a child of `lib`");
96+
97+
if !name.starts_with("python3.") {
9298
continue;
9399
}
94100

95-
let site_packages_candidate = path.join("site-packages");
96-
if system.is_directory(&site_packages_candidate) {
97-
tracing::debug!(
98-
"Resoled site-packages directory: {}",
99-
site_packages_candidate
100-
);
101-
return Ok(site_packages_candidate);
101+
path.push("site-packages");
102+
if system.is_directory(&path) {
103+
tracing::debug!("Resolved site-packages directory to '{path}'");
104+
return Ok(path);
102105
}
103106
}
104107
Err(SitePackagesDiscoveryError::NoSitePackagesDirFound)
105108
}
106109

107110
#[derive(Debug, thiserror::Error)]
108111
pub enum SitePackagesDiscoveryError {
109-
#[error("Failed to search the virtual environment directory for `site-packages` due to {0}")]
112+
#[error("Failed to search the virtual environment directory for `site-packages`")]
110113
CouldNotReadLibDirectory(#[from] io::Error),
111114
#[error("Could not find the `site-packages` directory in the virtual environment")]
112115
NoSitePackagesDirFound,

crates/red_knot_workspace/src/workspace/metadata.rs

+6-8
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,13 @@ pub struct PackageMetadata {
2222
impl WorkspaceMetadata {
2323
/// Discovers the closest workspace at `path` and returns its metadata.
2424
pub fn from_path(path: &SystemPath, system: &dyn System) -> anyhow::Result<WorkspaceMetadata> {
25-
let root = if system.is_file(path) {
26-
path.parent().unwrap().to_path_buf()
27-
} else {
28-
path.to_path_buf()
29-
};
25+
assert!(
26+
system.is_directory(path),
27+
"Workspace root path must be a directory"
28+
);
29+
tracing::debug!("Searching for workspace in '{path}'");
3030

31-
if !system.is_directory(&root) {
32-
anyhow::bail!("no workspace found at {:?}", root);
33-
}
31+
let root = path.to_path_buf();
3432

3533
// TODO: Discover package name from `pyproject.toml`.
3634
let package_name: Name = path.file_name().unwrap_or("<root>").into();

0 commit comments

Comments
 (0)