Compare commits
13 Commits
0.6.2
...
alex/ruf02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09edde8e39 | ||
|
|
6eafadbcf6 | ||
|
|
d4ada3f833 | ||
|
|
fdf8fc69ac | ||
|
|
1f2cb09853 | ||
|
|
cfe25ab465 | ||
|
|
551ed2706b | ||
|
|
21c5606793 | ||
|
|
c73a7bb929 | ||
|
|
4f6accb5c6 | ||
|
|
1ca14e4335 | ||
|
|
b9c8113a8a | ||
|
|
2edd32aa31 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1926,6 +1926,7 @@ dependencies = [
|
||||
"ruff_db",
|
||||
"ruff_index",
|
||||
"ruff_python_ast",
|
||||
"ruff_python_literal",
|
||||
"ruff_python_parser",
|
||||
"ruff_python_stdlib",
|
||||
"ruff_source_file",
|
||||
|
||||
@@ -17,6 +17,7 @@ ruff_python_ast = { workspace = true }
|
||||
ruff_python_stdlib = { workspace = true }
|
||||
ruff_source_file = { workspace = true }
|
||||
ruff_text_size = { workspace = true }
|
||||
ruff_python_literal = { workspace = true }
|
||||
|
||||
anyhow = { workspace = true }
|
||||
bitflags = { workspace = true }
|
||||
|
||||
@@ -658,11 +658,11 @@ where
|
||||
}
|
||||
ast::Expr::Named(node) => {
|
||||
debug_assert!(self.current_assignment.is_none());
|
||||
self.current_assignment = Some(node.into());
|
||||
// TODO walrus in comprehensions is implicitly nonlocal
|
||||
self.visit_expr(&node.value);
|
||||
self.current_assignment = Some(node.into());
|
||||
self.visit_expr(&node.target);
|
||||
self.current_assignment = None;
|
||||
self.visit_expr(&node.value);
|
||||
}
|
||||
ast::Expr::Lambda(lambda) => {
|
||||
if let Some(parameters) = &lambda.parameters {
|
||||
|
||||
@@ -181,6 +181,8 @@ pub enum Type<'db> {
|
||||
IntLiteral(i64),
|
||||
/// A boolean literal, either `True` or `False`.
|
||||
BooleanLiteral(bool),
|
||||
/// A bytes literal
|
||||
BytesLiteral(BytesLiteralType<'db>),
|
||||
// TODO protocols, callable types, overloads, generics, type vars
|
||||
}
|
||||
|
||||
@@ -276,6 +278,10 @@ impl<'db> Type<'db> {
|
||||
Type::Unknown
|
||||
}
|
||||
Type::BooleanLiteral(_) => Type::Unknown,
|
||||
Type::BytesLiteral(_) => {
|
||||
// TODO defer to Type::Instance(<bytes from typeshed>).member
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +378,12 @@ pub struct IntersectionType<'db> {
|
||||
negative: FxOrderSet<Type<'db>>,
|
||||
}
|
||||
|
||||
#[salsa::interned]
|
||||
pub struct BytesLiteralType<'db> {
|
||||
#[return_ref]
|
||||
value: Box<[u8]>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use anyhow::Context;
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use ruff_python_ast::str::Quote;
|
||||
use ruff_python_literal::escape::AsciiEscape;
|
||||
|
||||
use crate::types::{IntersectionType, Type, UnionType};
|
||||
use crate::Db;
|
||||
|
||||
@@ -38,6 +41,14 @@ impl Display for DisplayType<'_> {
|
||||
Type::BooleanLiteral(boolean) => {
|
||||
write!(f, "Literal[{}]", if *boolean { "True" } else { "False" })
|
||||
}
|
||||
Type::BytesLiteral(bytes) => {
|
||||
let escape =
|
||||
AsciiEscape::with_preferred_quote(bytes.value(self.db).as_ref(), Quote::Double);
|
||||
|
||||
f.write_str("Literal[")?;
|
||||
escape.bytes_repr().write(f)?;
|
||||
f.write_str("]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@ use crate::semantic_index::symbol::{FileScopeId, NodeWithScopeKind, NodeWithScop
|
||||
use crate::semantic_index::SemanticIndex;
|
||||
use crate::types::diagnostic::{TypeCheckDiagnostic, TypeCheckDiagnostics};
|
||||
use crate::types::{
|
||||
builtins_symbol_ty_by_name, definitions_ty, global_symbol_ty_by_name, ClassType, FunctionType,
|
||||
Name, Type, UnionBuilder,
|
||||
builtins_symbol_ty_by_name, definitions_ty, global_symbol_ty_by_name, BytesLiteralType,
|
||||
ClassType, FunctionType, Name, Type, UnionBuilder,
|
||||
};
|
||||
use crate::Db;
|
||||
|
||||
@@ -1206,9 +1206,12 @@ impl<'db> TypeInferenceBuilder<'db> {
|
||||
}
|
||||
|
||||
#[allow(clippy::unused_self)]
|
||||
fn infer_bytes_literal_expression(&mut self, _literal: &ast::ExprBytesLiteral) -> Type<'db> {
|
||||
// TODO
|
||||
Type::Unknown
|
||||
fn infer_bytes_literal_expression(&mut self, literal: &ast::ExprBytesLiteral) -> Type<'db> {
|
||||
// TODO: ignoring r/R prefixes for now, should normalize bytes values
|
||||
Type::BytesLiteral(BytesLiteralType::new(
|
||||
self.db,
|
||||
literal.value.bytes().collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn infer_fstring_expression(&mut self, fstring: &ast::ExprFString) -> Type<'db> {
|
||||
@@ -1684,6 +1687,7 @@ impl<'db> TypeInferenceBuilder<'db> {
|
||||
let left_ty = self.infer_expression(left);
|
||||
let right_ty = self.infer_expression(right);
|
||||
|
||||
// TODO flatten the matches by matching on (left_ty, right_ty, op)
|
||||
match left_ty {
|
||||
Type::Any => Type::Any,
|
||||
Type::Unknown => Type::Unknown,
|
||||
@@ -1722,6 +1726,22 @@ impl<'db> TypeInferenceBuilder<'db> {
|
||||
_ => Type::Unknown, // TODO
|
||||
}
|
||||
}
|
||||
Type::BytesLiteral(lhs) => {
|
||||
match right_ty {
|
||||
Type::BytesLiteral(rhs) => {
|
||||
match op {
|
||||
ast::Operator::Add => Type::BytesLiteral(BytesLiteralType::new(
|
||||
self.db,
|
||||
[lhs.value(self.db).as_ref(), rhs.value(self.db).as_ref()]
|
||||
.concat()
|
||||
.into_boxed_slice(),
|
||||
)),
|
||||
_ => Type::Unknown, // TODO
|
||||
}
|
||||
}
|
||||
_ => Type::Unknown, // TODO
|
||||
}
|
||||
}
|
||||
_ => Type::Unknown, // TODO
|
||||
}
|
||||
}
|
||||
@@ -2235,6 +2255,28 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_type() -> anyhow::Result<()> {
|
||||
let mut db = setup_db();
|
||||
|
||||
db.write_dedented(
|
||||
"src/a.py",
|
||||
"
|
||||
w = b'red' b'knot'
|
||||
x = b'hello'
|
||||
y = b'world' + b'!'
|
||||
z = b'\\xff\\x00'
|
||||
",
|
||||
)?;
|
||||
|
||||
assert_public_ty(&db, "src/a.py", "w", "Literal[b\"redknot\"]");
|
||||
assert_public_ty(&db, "src/a.py", "x", "Literal[b\"hello\"]");
|
||||
assert_public_ty(&db, "src/a.py", "y", "Literal[b\"world!\"]");
|
||||
assert_public_ty(&db, "src/a.py", "z", "Literal[b\"\\xff\\x00\"]");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_union() -> anyhow::Result<()> {
|
||||
let mut db = setup_db();
|
||||
@@ -2290,6 +2332,23 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walrus_self_plus_one() -> anyhow::Result<()> {
|
||||
let mut db = setup_db();
|
||||
|
||||
db.write_dedented(
|
||||
"src/a.py",
|
||||
"
|
||||
x = 0
|
||||
(x := x + 1)
|
||||
",
|
||||
)?;
|
||||
|
||||
assert_public_ty(&db, "src/a.py", "x", "Literal[1]");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ifexpr() -> anyhow::Result<()> {
|
||||
let mut db = setup_db();
|
||||
|
||||
@@ -6,7 +6,8 @@ use std::panic::PanicInfo;
|
||||
use lsp_server::Message;
|
||||
use lsp_types::{
|
||||
ClientCapabilities, DiagnosticOptions, DiagnosticServerCapabilities, MessageType,
|
||||
ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncOptions, Url,
|
||||
ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions,
|
||||
Url,
|
||||
};
|
||||
|
||||
use self::connection::{Connection, ConnectionInitializer};
|
||||
@@ -99,6 +100,11 @@ impl Server {
|
||||
anyhow::anyhow!("Failed to get the current working directory while creating a default workspace.")
|
||||
})?;
|
||||
|
||||
if workspaces.len() > 1 {
|
||||
// TODO(dhruvmanila): Support multi-root workspaces
|
||||
anyhow::bail!("Multi-root workspaces are not supported yet");
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
connection,
|
||||
worker_threads,
|
||||
@@ -215,6 +221,7 @@ impl Server {
|
||||
text_document_sync: Some(TextDocumentSyncCapability::Options(
|
||||
TextDocumentSyncOptions {
|
||||
open_close: Some(true),
|
||||
change: Some(TextDocumentSyncKind::INCREMENTAL),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
use crate::{server::schedule::Task, session::Session, system::url_to_system_path};
|
||||
use lsp_server as server;
|
||||
|
||||
use crate::server::schedule::Task;
|
||||
use crate::session::Session;
|
||||
use crate::system::{url_to_any_system_path, AnySystemPath};
|
||||
|
||||
mod diagnostics;
|
||||
mod notifications;
|
||||
mod requests;
|
||||
mod traits;
|
||||
|
||||
use notifications as notification;
|
||||
use red_knot_workspace::db::RootDatabase;
|
||||
use requests as request;
|
||||
|
||||
use self::traits::{NotificationHandler, RequestHandler};
|
||||
@@ -43,6 +45,7 @@ pub(super) fn notification<'a>(notif: server::Notification) -> Task<'a> {
|
||||
match notif.method.as_str() {
|
||||
notification::DidCloseTextDocumentHandler::METHOD => local_notification_task::<notification::DidCloseTextDocumentHandler>(notif),
|
||||
notification::DidOpenTextDocumentHandler::METHOD => local_notification_task::<notification::DidOpenTextDocumentHandler>(notif),
|
||||
notification::DidChangeTextDocumentHandler::METHOD => local_notification_task::<notification::DidChangeTextDocumentHandler>(notif),
|
||||
notification::DidOpenNotebookHandler::METHOD => {
|
||||
local_notification_task::<notification::DidOpenNotebookHandler>(notif)
|
||||
}
|
||||
@@ -82,12 +85,18 @@ fn background_request_task<'a, R: traits::BackgroundDocumentRequestHandler>(
|
||||
Ok(Task::background(schedule, move |session: &Session| {
|
||||
let url = R::document_url(¶ms).into_owned();
|
||||
|
||||
let Ok(path) = url_to_system_path(&url) else {
|
||||
let Ok(path) = url_to_any_system_path(&url) else {
|
||||
return Box::new(|_, _| {});
|
||||
};
|
||||
let db = session
|
||||
.workspace_db_for_path(path.as_std_path())
|
||||
.map(RootDatabase::snapshot);
|
||||
let db = match path {
|
||||
AnySystemPath::System(path) => {
|
||||
match session.workspace_db_for_path(path.as_std_path()) {
|
||||
Some(db) => db.snapshot(),
|
||||
None => session.default_workspace_db().snapshot(),
|
||||
}
|
||||
}
|
||||
AnySystemPath::SystemVirtual(_) => session.default_workspace_db().snapshot(),
|
||||
};
|
||||
|
||||
let Some(snapshot) = session.take_snapshot(url) else {
|
||||
return Box::new(|_, _| {});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
mod did_change;
|
||||
mod did_close;
|
||||
mod did_close_notebook;
|
||||
mod did_open;
|
||||
mod did_open_notebook;
|
||||
mod set_trace;
|
||||
|
||||
pub(super) use did_change::DidChangeTextDocumentHandler;
|
||||
pub(super) use did_close::DidCloseTextDocumentHandler;
|
||||
pub(super) use did_close_notebook::DidCloseNotebookHandler;
|
||||
pub(super) use did_open::DidOpenTextDocumentHandler;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
use lsp_server::ErrorCode;
|
||||
use lsp_types::notification::DidChangeTextDocument;
|
||||
use lsp_types::DidChangeTextDocumentParams;
|
||||
|
||||
use red_knot_workspace::watch::ChangeEvent;
|
||||
|
||||
use crate::server::api::traits::{NotificationHandler, SyncNotificationHandler};
|
||||
use crate::server::api::LSPResult;
|
||||
use crate::server::client::{Notifier, Requester};
|
||||
use crate::server::Result;
|
||||
use crate::session::Session;
|
||||
use crate::system::{url_to_any_system_path, AnySystemPath};
|
||||
|
||||
pub(crate) struct DidChangeTextDocumentHandler;
|
||||
|
||||
impl NotificationHandler for DidChangeTextDocumentHandler {
|
||||
type NotificationType = DidChangeTextDocument;
|
||||
}
|
||||
|
||||
impl SyncNotificationHandler for DidChangeTextDocumentHandler {
|
||||
fn run(
|
||||
session: &mut Session,
|
||||
_notifier: Notifier,
|
||||
_requester: &mut Requester,
|
||||
params: DidChangeTextDocumentParams,
|
||||
) -> Result<()> {
|
||||
let Ok(path) = url_to_any_system_path(¶ms.text_document.uri) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let key = session.key_from_url(params.text_document.uri);
|
||||
|
||||
session
|
||||
.update_text_document(&key, params.content_changes, params.text_document.version)
|
||||
.with_failure_code(ErrorCode::InternalError)?;
|
||||
|
||||
match path {
|
||||
AnySystemPath::System(path) => {
|
||||
let db = match session.workspace_db_for_path_mut(path.as_std_path()) {
|
||||
Some(db) => db,
|
||||
None => session.default_workspace_db_mut(),
|
||||
};
|
||||
db.apply_changes(vec![ChangeEvent::file_content_changed(path)], None);
|
||||
}
|
||||
AnySystemPath::SystemVirtual(virtual_path) => {
|
||||
let db = session.default_workspace_db_mut();
|
||||
db.apply_changes(vec![ChangeEvent::ChangedVirtual(virtual_path)], None);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(dhruvmanila): Publish diagnostics if the client doesnt support pull diagnostics
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
use lsp_server::ErrorCode;
|
||||
use lsp_types::notification::DidCloseTextDocument;
|
||||
use lsp_types::DidCloseTextDocumentParams;
|
||||
|
||||
use ruff_db::files::File;
|
||||
use red_knot_workspace::watch::ChangeEvent;
|
||||
|
||||
use crate::server::api::diagnostics::clear_diagnostics;
|
||||
use crate::server::api::traits::{NotificationHandler, SyncNotificationHandler};
|
||||
@@ -10,7 +9,7 @@ use crate::server::api::LSPResult;
|
||||
use crate::server::client::{Notifier, Requester};
|
||||
use crate::server::Result;
|
||||
use crate::session::Session;
|
||||
use crate::system::url_to_system_path;
|
||||
use crate::system::{url_to_any_system_path, AnySystemPath};
|
||||
|
||||
pub(crate) struct DidCloseTextDocumentHandler;
|
||||
|
||||
@@ -25,7 +24,7 @@ impl SyncNotificationHandler for DidCloseTextDocumentHandler {
|
||||
_requester: &mut Requester,
|
||||
params: DidCloseTextDocumentParams,
|
||||
) -> Result<()> {
|
||||
let Ok(path) = url_to_system_path(¶ms.text_document.uri) else {
|
||||
let Ok(path) = url_to_any_system_path(¶ms.text_document.uri) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -34,8 +33,9 @@ impl SyncNotificationHandler for DidCloseTextDocumentHandler {
|
||||
.close_document(&key)
|
||||
.with_failure_code(ErrorCode::InternalError)?;
|
||||
|
||||
if let Some(db) = session.workspace_db_for_path_mut(path.as_std_path()) {
|
||||
File::sync_path(db, &path);
|
||||
if let AnySystemPath::SystemVirtual(virtual_path) = path {
|
||||
let db = session.default_workspace_db_mut();
|
||||
db.apply_changes(vec![ChangeEvent::DeletedVirtual(virtual_path)], None);
|
||||
}
|
||||
|
||||
clear_diagnostics(key.url(), ¬ifier)?;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use lsp_types::notification::DidCloseNotebookDocument;
|
||||
use lsp_types::DidCloseNotebookDocumentParams;
|
||||
|
||||
use ruff_db::files::File;
|
||||
use red_knot_workspace::watch::ChangeEvent;
|
||||
|
||||
use crate::server::api::traits::{NotificationHandler, SyncNotificationHandler};
|
||||
use crate::server::api::LSPResult;
|
||||
use crate::server::client::{Notifier, Requester};
|
||||
use crate::server::Result;
|
||||
use crate::session::Session;
|
||||
use crate::system::url_to_system_path;
|
||||
use crate::system::{url_to_any_system_path, AnySystemPath};
|
||||
|
||||
pub(crate) struct DidCloseNotebookHandler;
|
||||
|
||||
@@ -23,7 +23,7 @@ impl SyncNotificationHandler for DidCloseNotebookHandler {
|
||||
_requester: &mut Requester,
|
||||
params: DidCloseNotebookDocumentParams,
|
||||
) -> Result<()> {
|
||||
let Ok(path) = url_to_system_path(¶ms.notebook_document.uri) else {
|
||||
let Ok(path) = url_to_any_system_path(¶ms.notebook_document.uri) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -32,8 +32,9 @@ impl SyncNotificationHandler for DidCloseNotebookHandler {
|
||||
.close_document(&key)
|
||||
.with_failure_code(lsp_server::ErrorCode::InternalError)?;
|
||||
|
||||
if let Some(db) = session.workspace_db_for_path_mut(path.as_std_path()) {
|
||||
File::sync_path(db, &path);
|
||||
if let AnySystemPath::SystemVirtual(virtual_path) = path {
|
||||
let db = session.default_workspace_db_mut();
|
||||
db.apply_changes(vec![ChangeEvent::DeletedVirtual(virtual_path)], None);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use lsp_types::notification::DidOpenTextDocument;
|
||||
use lsp_types::DidOpenTextDocumentParams;
|
||||
|
||||
use ruff_db::files::system_path_to_file;
|
||||
use red_knot_workspace::watch::ChangeEvent;
|
||||
use ruff_db::Db;
|
||||
|
||||
use crate::server::api::traits::{NotificationHandler, SyncNotificationHandler};
|
||||
use crate::server::client::{Notifier, Requester};
|
||||
use crate::server::Result;
|
||||
use crate::session::Session;
|
||||
use crate::system::url_to_system_path;
|
||||
use crate::system::{url_to_any_system_path, AnySystemPath};
|
||||
use crate::TextDocument;
|
||||
|
||||
pub(crate) struct DidOpenTextDocumentHandler;
|
||||
@@ -23,17 +24,25 @@ impl SyncNotificationHandler for DidOpenTextDocumentHandler {
|
||||
_requester: &mut Requester,
|
||||
params: DidOpenTextDocumentParams,
|
||||
) -> Result<()> {
|
||||
let Ok(path) = url_to_system_path(¶ms.text_document.uri) else {
|
||||
let Ok(path) = url_to_any_system_path(¶ms.text_document.uri) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let document = TextDocument::new(params.text_document.text, params.text_document.version);
|
||||
session.open_text_document(params.text_document.uri, document);
|
||||
|
||||
if let Some(db) = session.workspace_db_for_path_mut(path.as_std_path()) {
|
||||
// TODO(dhruvmanila): Store the `file` in `DocumentController`
|
||||
let file = system_path_to_file(db, &path).unwrap();
|
||||
file.sync(db);
|
||||
match path {
|
||||
AnySystemPath::System(path) => {
|
||||
let db = match session.workspace_db_for_path_mut(path.as_std_path()) {
|
||||
Some(db) => db,
|
||||
None => session.default_workspace_db_mut(),
|
||||
};
|
||||
db.apply_changes(vec![ChangeEvent::Opened(path)], None);
|
||||
}
|
||||
AnySystemPath::SystemVirtual(virtual_path) => {
|
||||
let db = session.default_workspace_db_mut();
|
||||
db.files().virtual_file(db, &virtual_path);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(dhruvmanila): Publish diagnostics if the client doesn't support pull diagnostics
|
||||
|
||||
@@ -2,7 +2,8 @@ use lsp_server::ErrorCode;
|
||||
use lsp_types::notification::DidOpenNotebookDocument;
|
||||
use lsp_types::DidOpenNotebookDocumentParams;
|
||||
|
||||
use ruff_db::files::system_path_to_file;
|
||||
use red_knot_workspace::watch::ChangeEvent;
|
||||
use ruff_db::Db;
|
||||
|
||||
use crate::edit::NotebookDocument;
|
||||
use crate::server::api::traits::{NotificationHandler, SyncNotificationHandler};
|
||||
@@ -10,7 +11,7 @@ use crate::server::api::LSPResult;
|
||||
use crate::server::client::{Notifier, Requester};
|
||||
use crate::server::Result;
|
||||
use crate::session::Session;
|
||||
use crate::system::url_to_system_path;
|
||||
use crate::system::{url_to_any_system_path, AnySystemPath};
|
||||
|
||||
pub(crate) struct DidOpenNotebookHandler;
|
||||
|
||||
@@ -25,7 +26,7 @@ impl SyncNotificationHandler for DidOpenNotebookHandler {
|
||||
_requester: &mut Requester,
|
||||
params: DidOpenNotebookDocumentParams,
|
||||
) -> Result<()> {
|
||||
let Ok(path) = url_to_system_path(¶ms.notebook_document.uri) else {
|
||||
let Ok(path) = url_to_any_system_path(¶ms.notebook_document.uri) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -38,10 +39,18 @@ impl SyncNotificationHandler for DidOpenNotebookHandler {
|
||||
.with_failure_code(ErrorCode::InternalError)?;
|
||||
session.open_notebook_document(params.notebook_document.uri.clone(), notebook);
|
||||
|
||||
if let Some(db) = session.workspace_db_for_path_mut(path.as_std_path()) {
|
||||
// TODO(dhruvmanila): Store the `file` in `DocumentController`
|
||||
let file = system_path_to_file(db, &path).unwrap();
|
||||
file.sync(db);
|
||||
match path {
|
||||
AnySystemPath::System(path) => {
|
||||
let db = match session.workspace_db_for_path_mut(path.as_std_path()) {
|
||||
Some(db) => db,
|
||||
None => session.default_workspace_db_mut(),
|
||||
};
|
||||
db.apply_changes(vec![ChangeEvent::Opened(path)], None);
|
||||
}
|
||||
AnySystemPath::SystemVirtual(virtual_path) => {
|
||||
let db = session.default_workspace_db_mut();
|
||||
db.files().virtual_file(db, &virtual_path);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(dhruvmanila): Publish diagnostics if the client doesn't support pull diagnostics
|
||||
|
||||
@@ -26,13 +26,11 @@ impl BackgroundDocumentRequestHandler for DocumentDiagnosticRequestHandler {
|
||||
|
||||
fn run_with_snapshot(
|
||||
snapshot: DocumentSnapshot,
|
||||
db: Option<RootDatabase>,
|
||||
db: RootDatabase,
|
||||
_notifier: Notifier,
|
||||
_params: DocumentDiagnosticParams,
|
||||
) -> Result<DocumentDiagnosticReportResult> {
|
||||
let diagnostics = db
|
||||
.map(|db| compute_diagnostics(&snapshot, &db))
|
||||
.unwrap_or_default();
|
||||
let diagnostics = compute_diagnostics(&snapshot, &db);
|
||||
|
||||
Ok(DocumentDiagnosticReportResult::Report(
|
||||
DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport {
|
||||
@@ -48,10 +46,19 @@ impl BackgroundDocumentRequestHandler for DocumentDiagnosticRequestHandler {
|
||||
|
||||
fn compute_diagnostics(snapshot: &DocumentSnapshot, db: &RootDatabase) -> Vec<Diagnostic> {
|
||||
let Some(file) = snapshot.file(db) else {
|
||||
tracing::info!(
|
||||
"No file found for snapshot for '{}'",
|
||||
snapshot.query().file_url()
|
||||
);
|
||||
return vec![];
|
||||
};
|
||||
let Ok(diagnostics) = db.check_file(file) else {
|
||||
return vec![];
|
||||
|
||||
let diagnostics = match db.check_file(file) {
|
||||
Ok(diagnostics) => diagnostics,
|
||||
Err(cancelled) => {
|
||||
tracing::info!("Diagnostics computation {cancelled}");
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
diagnostics
|
||||
@@ -65,12 +72,12 @@ fn to_lsp_diagnostic(message: &str) -> Diagnostic {
|
||||
let words = message.split(':').collect::<Vec<_>>();
|
||||
|
||||
let (range, message) = match words.as_slice() {
|
||||
[_filename, line, column, message] => {
|
||||
let line = line.parse::<u32>().unwrap_or_default();
|
||||
[_, _, line, column, message] | [_, line, column, message] => {
|
||||
let line = line.parse::<u32>().unwrap_or_default().saturating_sub(1);
|
||||
let column = column.parse::<u32>().unwrap_or_default();
|
||||
(
|
||||
Range::new(
|
||||
Position::new(line.saturating_sub(1), column.saturating_sub(1)),
|
||||
Position::new(line, column.saturating_sub(1)),
|
||||
Position::new(line, column),
|
||||
),
|
||||
message.trim(),
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(super) trait BackgroundDocumentRequestHandler: RequestHandler {
|
||||
|
||||
fn run_with_snapshot(
|
||||
snapshot: DocumentSnapshot,
|
||||
db: Option<RootDatabase>,
|
||||
db: RootDatabase,
|
||||
notifier: Notifier,
|
||||
params: <<Self as RequestHandler>::RequestType as Request>::Params,
|
||||
) -> super::Result<<<Self as RequestHandler>::RequestType as Request>::Result>;
|
||||
|
||||
@@ -6,15 +6,16 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use lsp_types::{ClientCapabilities, Url};
|
||||
use lsp_types::{ClientCapabilities, TextDocumentContentChangeEvent, Url};
|
||||
|
||||
use red_knot_workspace::db::RootDatabase;
|
||||
use red_knot_workspace::workspace::WorkspaceMetadata;
|
||||
use ruff_db::files::{system_path_to_file, File};
|
||||
use ruff_db::system::SystemPath;
|
||||
use ruff_db::Db;
|
||||
|
||||
use crate::edit::{DocumentKey, NotebookDocument};
|
||||
use crate::system::{url_to_system_path, LSPSystem};
|
||||
use crate::edit::{DocumentKey, DocumentVersion, NotebookDocument};
|
||||
use crate::system::{url_to_any_system_path, AnySystemPath, LSPSystem};
|
||||
use crate::{PositionEncoding, TextDocument};
|
||||
|
||||
pub(crate) use self::capabilities::ResolvedClientCapabilities;
|
||||
@@ -82,6 +83,12 @@ impl Session {
|
||||
})
|
||||
}
|
||||
|
||||
// TODO(dhruvmanila): Ideally, we should have a single method for `workspace_db_for_path_mut`
|
||||
// and `default_workspace_db_mut` but the borrow checker doesn't allow that.
|
||||
// https://github.com/astral-sh/ruff/pull/13041#discussion_r1726725437
|
||||
|
||||
/// Returns a reference to the workspace [`RootDatabase`] corresponding to the given path, if
|
||||
/// any.
|
||||
pub(crate) fn workspace_db_for_path(&self, path: impl AsRef<Path>) -> Option<&RootDatabase> {
|
||||
self.workspaces
|
||||
.range(..=path.as_ref().to_path_buf())
|
||||
@@ -89,6 +96,8 @@ impl Session {
|
||||
.map(|(_, db)| db)
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the workspace [`RootDatabase`] corresponding to the given
|
||||
/// path, if any.
|
||||
pub(crate) fn workspace_db_for_path_mut(
|
||||
&mut self,
|
||||
path: impl AsRef<Path>,
|
||||
@@ -99,6 +108,19 @@ impl Session {
|
||||
.map(|(_, db)| db)
|
||||
}
|
||||
|
||||
/// Returns a reference to the default workspace [`RootDatabase`]. The default workspace is the
|
||||
/// minimum root path in the workspace map.
|
||||
pub(crate) fn default_workspace_db(&self) -> &RootDatabase {
|
||||
// SAFETY: Currently, red knot only support a single workspace.
|
||||
self.workspaces.values().next().unwrap()
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the default workspace [`RootDatabase`].
|
||||
pub(crate) fn default_workspace_db_mut(&mut self) -> &mut RootDatabase {
|
||||
// SAFETY: Currently, red knot only support a single workspace.
|
||||
self.workspaces.values_mut().next().unwrap()
|
||||
}
|
||||
|
||||
pub fn key_from_url(&self, url: Url) -> DocumentKey {
|
||||
self.index().key_from_url(url)
|
||||
}
|
||||
@@ -125,6 +147,20 @@ impl Session {
|
||||
self.index_mut().open_text_document(url, document);
|
||||
}
|
||||
|
||||
/// Updates a text document at the associated `key`.
|
||||
///
|
||||
/// The document key must point to a text document, or this will throw an error.
|
||||
pub(crate) fn update_text_document(
|
||||
&mut self,
|
||||
key: &DocumentKey,
|
||||
content_changes: Vec<TextDocumentContentChangeEvent>,
|
||||
new_version: DocumentVersion,
|
||||
) -> crate::Result<()> {
|
||||
let position_encoding = self.position_encoding;
|
||||
self.index_mut()
|
||||
.update_text_document(key, content_changes, new_version, position_encoding)
|
||||
}
|
||||
|
||||
/// De-registers a document, specified by its key.
|
||||
/// Calling this multiple times for the same document is a logic error.
|
||||
pub(crate) fn close_document(&mut self, key: &DocumentKey) -> crate::Result<()> {
|
||||
@@ -211,6 +247,7 @@ impl Drop for MutIndexGuard<'_> {
|
||||
|
||||
/// An immutable snapshot of `Session` that references
|
||||
/// a specific document.
|
||||
#[derive(Debug)]
|
||||
pub struct DocumentSnapshot {
|
||||
resolved_client_capabilities: Arc<ResolvedClientCapabilities>,
|
||||
document_ref: index::DocumentQuery,
|
||||
@@ -231,7 +268,12 @@ impl DocumentSnapshot {
|
||||
}
|
||||
|
||||
pub(crate) fn file(&self, db: &RootDatabase) -> Option<File> {
|
||||
let path = url_to_system_path(self.document_ref.file_url()).ok()?;
|
||||
system_path_to_file(db, path).ok()
|
||||
match url_to_any_system_path(self.document_ref.file_url()).ok()? {
|
||||
AnySystemPath::System(path) => system_path_to_file(db, path).ok(),
|
||||
AnySystemPath::SystemVirtual(virtual_path) => db
|
||||
.files()
|
||||
.try_virtual_file(&virtual_path)
|
||||
.map(|virtual_file| virtual_file.file()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,27 +8,40 @@ use ruff_db::file_revision::FileRevision;
|
||||
use ruff_db::system::walk_directory::WalkDirectoryBuilder;
|
||||
use ruff_db::system::{
|
||||
DirectoryEntry, FileType, Metadata, OsSystem, Result, System, SystemPath, SystemPathBuf,
|
||||
SystemVirtualPath,
|
||||
SystemVirtualPath, SystemVirtualPathBuf,
|
||||
};
|
||||
use ruff_notebook::{Notebook, NotebookError};
|
||||
|
||||
use crate::session::index::Index;
|
||||
use crate::DocumentQuery;
|
||||
|
||||
/// Converts the given [`Url`] to a [`SystemPathBuf`].
|
||||
/// Converts the given [`Url`] to an [`AnySystemPath`].
|
||||
///
|
||||
/// If the URL scheme is `file`, then the path is converted to a [`SystemPathBuf`]. Otherwise, the
|
||||
/// URL is converted to a [`SystemVirtualPathBuf`].
|
||||
///
|
||||
/// This fails in the following cases:
|
||||
/// * The URL scheme is not `file`.
|
||||
/// * The URL cannot be converted to a file path (refer to [`Url::to_file_path`]).
|
||||
/// * If the URL is not a valid UTF-8 string.
|
||||
pub(crate) fn url_to_system_path(url: &Url) -> std::result::Result<SystemPathBuf, ()> {
|
||||
pub(crate) fn url_to_any_system_path(url: &Url) -> std::result::Result<AnySystemPath, ()> {
|
||||
if url.scheme() == "file" {
|
||||
Ok(SystemPathBuf::from_path_buf(url.to_file_path()?).map_err(|_| ())?)
|
||||
Ok(AnySystemPath::System(
|
||||
SystemPathBuf::from_path_buf(url.to_file_path()?).map_err(|_| ())?,
|
||||
))
|
||||
} else {
|
||||
Err(())
|
||||
Ok(AnySystemPath::SystemVirtual(
|
||||
SystemVirtualPath::new(url.as_str()).to_path_buf(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents either a [`SystemPath`] or a [`SystemVirtualPath`].
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum AnySystemPath {
|
||||
System(SystemPathBuf),
|
||||
SystemVirtual(SystemVirtualPathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct LSPSystem {
|
||||
/// A read-only copy of the index where the server stores all the open documents and settings.
|
||||
@@ -144,19 +157,6 @@ impl System for LSPSystem {
|
||||
}
|
||||
}
|
||||
|
||||
fn virtual_path_metadata(&self, path: &SystemVirtualPath) -> Result<Metadata> {
|
||||
// Virtual paths only exists in the LSP system, so we don't need to check the OS system.
|
||||
let document = self
|
||||
.system_virtual_path_to_document_ref(path)?
|
||||
.ok_or_else(|| virtual_path_not_found(path))?;
|
||||
|
||||
Ok(Metadata::new(
|
||||
document_revision(&document),
|
||||
None,
|
||||
FileType::File,
|
||||
))
|
||||
}
|
||||
|
||||
fn read_virtual_path_to_string(&self, path: &SystemVirtualPath) -> Result<String> {
|
||||
let document = self
|
||||
.system_virtual_path_to_document_ref(path)?
|
||||
|
||||
@@ -234,13 +234,6 @@ impl System for WasmSystem {
|
||||
Notebook::from_source_code(&content)
|
||||
}
|
||||
|
||||
fn virtual_path_metadata(
|
||||
&self,
|
||||
_path: &SystemVirtualPath,
|
||||
) -> ruff_db::system::Result<Metadata> {
|
||||
Err(not_found())
|
||||
}
|
||||
|
||||
fn read_virtual_path_to_string(
|
||||
&self,
|
||||
_path: &SystemVirtualPath,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
x = 0
|
||||
(x := x + 1)
|
||||
@@ -0,0 +1,3 @@
|
||||
x = 0
|
||||
if x := x + 1:
|
||||
pass
|
||||
@@ -0,0 +1,3 @@
|
||||
match x:
|
||||
case [1, 0] if x := x[:0]:
|
||||
y = 1
|
||||
@@ -50,7 +50,7 @@ impl RootDatabase {
|
||||
};
|
||||
|
||||
for change in changes {
|
||||
if let Some(path) = change.path() {
|
||||
if let Some(path) = change.system_path() {
|
||||
if matches!(
|
||||
path.file_name(),
|
||||
Some(".gitignore" | ".ignore" | "ruff.toml" | ".ruff.toml" | "pyproject.toml")
|
||||
@@ -72,7 +72,8 @@ impl RootDatabase {
|
||||
}
|
||||
|
||||
match change {
|
||||
watch::ChangeEvent::Changed { path, kind: _ } => sync_path(self, &path),
|
||||
watch::ChangeEvent::Changed { path, kind: _ }
|
||||
| watch::ChangeEvent::Opened(path) => sync_path(self, &path),
|
||||
|
||||
watch::ChangeEvent::Created { kind, path } => {
|
||||
match kind {
|
||||
@@ -130,6 +131,17 @@ impl RootDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
watch::ChangeEvent::CreatedVirtual(path)
|
||||
| watch::ChangeEvent::ChangedVirtual(path) => {
|
||||
File::sync_virtual_path(self, &path);
|
||||
}
|
||||
|
||||
watch::ChangeEvent::DeletedVirtual(path) => {
|
||||
if let Some(virtual_file) = self.files().try_virtual_file(&path) {
|
||||
virtual_file.close(self);
|
||||
}
|
||||
}
|
||||
|
||||
watch::ChangeEvent::Rescan => {
|
||||
workspace_change = true;
|
||||
Files::sync_all(self);
|
||||
|
||||
@@ -7,7 +7,7 @@ use red_knot_python_semantic::types::Type;
|
||||
use red_knot_python_semantic::{HasTy, ModuleName, SemanticModel};
|
||||
use ruff_db::files::File;
|
||||
use ruff_db::parsed::{parsed_module, ParsedModule};
|
||||
use ruff_db::source::{line_index, source_text, SourceText};
|
||||
use ruff_db::source::{source_text, SourceText};
|
||||
use ruff_python_ast as ast;
|
||||
use ruff_python_ast::visitor::{walk_expr, walk_stmt, Visitor};
|
||||
use ruff_text_size::{Ranged, TextSize};
|
||||
@@ -48,19 +48,6 @@ pub(crate) fn lint_syntax(db: &dyn Db, file_id: File) -> Vec<String> {
|
||||
};
|
||||
visitor.visit_body(&ast.body);
|
||||
diagnostics = visitor.diagnostics;
|
||||
} else {
|
||||
let path = file_id.path(db);
|
||||
let line_index = line_index(db.upcast(), file_id);
|
||||
diagnostics.extend(parsed.errors().iter().map(|err| {
|
||||
let source_location = line_index.source_location(err.location.start(), source.as_str());
|
||||
format!(
|
||||
"{}:{}:{}: {}",
|
||||
path.as_str(),
|
||||
source_location.row,
|
||||
source_location.column,
|
||||
err,
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
diagnostics
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use ruff_db::system::{SystemPath, SystemPathBuf};
|
||||
use ruff_db::system::{SystemPath, SystemPathBuf, SystemVirtualPathBuf};
|
||||
pub use watcher::{directory_watcher, EventHandler, Watcher};
|
||||
pub use workspace_watcher::WorkspaceWatcher;
|
||||
|
||||
@@ -20,6 +20,9 @@ mod workspace_watcher;
|
||||
/// event instead of emitting an event for each file or subdirectory in that path.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ChangeEvent {
|
||||
/// The file corresponding to the given path was opened in an editor.
|
||||
Opened(SystemPathBuf),
|
||||
|
||||
/// A new path was created
|
||||
Created {
|
||||
path: SystemPathBuf,
|
||||
@@ -38,6 +41,15 @@ pub enum ChangeEvent {
|
||||
kind: DeletedKind,
|
||||
},
|
||||
|
||||
/// A new virtual path was created.
|
||||
CreatedVirtual(SystemVirtualPathBuf),
|
||||
|
||||
/// The content of a virtual path was changed.
|
||||
ChangedVirtual(SystemVirtualPathBuf),
|
||||
|
||||
/// A virtual path was deleted.
|
||||
DeletedVirtual(SystemVirtualPathBuf),
|
||||
|
||||
/// The file watcher failed to observe some changes and now is out of sync with the file system.
|
||||
///
|
||||
/// This can happen if many files are changed at once. The consumer should rescan all files to catch up
|
||||
@@ -46,16 +58,27 @@ pub enum ChangeEvent {
|
||||
}
|
||||
|
||||
impl ChangeEvent {
|
||||
pub fn file_name(&self) -> Option<&str> {
|
||||
self.path().and_then(|path| path.file_name())
|
||||
/// Creates a new [`Changed`] event for the file content at the given path.
|
||||
///
|
||||
/// [`Changed`]: ChangeEvent::Changed
|
||||
pub fn file_content_changed(path: SystemPathBuf) -> ChangeEvent {
|
||||
ChangeEvent::Changed {
|
||||
path,
|
||||
kind: ChangedKind::FileContent,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> Option<&SystemPath> {
|
||||
pub fn file_name(&self) -> Option<&str> {
|
||||
self.system_path().and_then(|path| path.file_name())
|
||||
}
|
||||
|
||||
pub fn system_path(&self) -> Option<&SystemPath> {
|
||||
match self {
|
||||
ChangeEvent::Created { path, .. }
|
||||
ChangeEvent::Opened(path)
|
||||
| ChangeEvent::Created { path, .. }
|
||||
| ChangeEvent::Changed { path, .. }
|
||||
| ChangeEvent::Deleted { path, .. } => Some(path),
|
||||
ChangeEvent::Rescan => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use salsa::{Durability, Setter as _};
|
||||
pub use metadata::{PackageMetadata, WorkspaceMetadata};
|
||||
use red_knot_python_semantic::types::check_types;
|
||||
use red_knot_python_semantic::SearchPathSettings;
|
||||
use ruff_db::parsed::parsed_module;
|
||||
use ruff_db::source::{line_index, source_text, SourceDiagnostic};
|
||||
use ruff_db::{
|
||||
files::{system_path_to_file, File},
|
||||
@@ -404,6 +405,17 @@ pub(super) fn check_file(db: &dyn Db, file: File) -> Vec<String> {
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
let parsed = parsed_module(db.upcast(), file);
|
||||
|
||||
if !parsed.errors().is_empty() {
|
||||
let path = file.path(db);
|
||||
let line_index = line_index(db.upcast(), file);
|
||||
diagnostics.extend(parsed.errors().iter().map(|err| {
|
||||
let source_location = line_index.source_location(err.location.start(), source.as_str());
|
||||
format!("{path}:{source_location}: {message}", message = err.error)
|
||||
}));
|
||||
}
|
||||
|
||||
for diagnostic in check_types(db.upcast(), file) {
|
||||
let index = line_index(db.upcast(), diagnostic.file());
|
||||
let location = index.source_location(diagnostic.start(), source.as_str());
|
||||
|
||||
@@ -12,7 +12,7 @@ use ruff_notebook::{Notebook, NotebookError};
|
||||
use crate::file_revision::FileRevision;
|
||||
use crate::files::file_root::FileRoots;
|
||||
use crate::files::private::FileStatus;
|
||||
use crate::system::{Metadata, SystemPath, SystemPathBuf, SystemVirtualPath, SystemVirtualPathBuf};
|
||||
use crate::system::{SystemPath, SystemPathBuf, SystemVirtualPath, SystemVirtualPathBuf};
|
||||
use crate::vendored::{VendoredPath, VendoredPathBuf};
|
||||
use crate::{vendored, Db, FxDashMap};
|
||||
|
||||
@@ -60,8 +60,8 @@ struct FilesInner {
|
||||
/// so that queries that depend on the existence of a file are re-executed when the file is created.
|
||||
system_by_path: FxDashMap<SystemPathBuf, File>,
|
||||
|
||||
/// Lookup table that maps [`SystemVirtualPathBuf`]s to salsa interned [`File`] instances.
|
||||
system_virtual_by_path: FxDashMap<SystemVirtualPathBuf, File>,
|
||||
/// Lookup table that maps [`SystemVirtualPathBuf`]s to [`VirtualFile`] instances.
|
||||
system_virtual_by_path: FxDashMap<SystemVirtualPathBuf, VirtualFile>,
|
||||
|
||||
/// Lookup table that maps vendored files to the salsa [`File`] ingredients.
|
||||
vendored_by_path: FxDashMap<VendoredPathBuf, File>,
|
||||
@@ -147,31 +147,31 @@ impl Files {
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Looks up a virtual file by its `path`.
|
||||
/// Create a new virtual file at the given path and store it for future lookups.
|
||||
///
|
||||
/// For a non-existing file, creates a new salsa [`File`] ingredient and stores it for future lookups.
|
||||
///
|
||||
/// The operations fails if the system failed to provide a metadata for the path.
|
||||
pub fn add_virtual_file(&self, db: &dyn Db, path: &SystemVirtualPath) -> Option<File> {
|
||||
let file = match self.inner.system_virtual_by_path.entry(path.to_path_buf()) {
|
||||
Entry::Occupied(entry) => *entry.get(),
|
||||
Entry::Vacant(entry) => {
|
||||
let metadata = db.system().virtual_path_metadata(path).ok()?;
|
||||
/// This will always create a new file, overwriting any existing file at `path` in the internal
|
||||
/// storage.
|
||||
pub fn virtual_file(&self, db: &dyn Db, path: &SystemVirtualPath) -> VirtualFile {
|
||||
tracing::trace!("Adding virtual file {}", path);
|
||||
let virtual_file = VirtualFile(
|
||||
File::builder(FilePath::SystemVirtual(path.to_path_buf()))
|
||||
.status(FileStatus::Exists)
|
||||
.revision(FileRevision::zero())
|
||||
.permissions(None)
|
||||
.new(db),
|
||||
);
|
||||
self.inner
|
||||
.system_virtual_by_path
|
||||
.insert(path.to_path_buf(), virtual_file);
|
||||
virtual_file
|
||||
}
|
||||
|
||||
tracing::trace!("Adding virtual file '{}'", path);
|
||||
|
||||
let file = File::builder(FilePath::SystemVirtual(path.to_path_buf()))
|
||||
.revision(metadata.revision())
|
||||
.permissions(metadata.permissions())
|
||||
.new(db);
|
||||
|
||||
entry.insert(file);
|
||||
|
||||
file
|
||||
}
|
||||
};
|
||||
|
||||
Some(file)
|
||||
/// Tries to look up a virtual file by its path. Returns `None` if no such file exists yet.
|
||||
pub fn try_virtual_file(&self, path: &SystemVirtualPath) -> Option<VirtualFile> {
|
||||
self.inner
|
||||
.system_virtual_by_path
|
||||
.get(&path.to_path_buf())
|
||||
.map(|entry| *entry.value())
|
||||
}
|
||||
|
||||
/// Looks up the closest root for `path`. Returns `None` if `path` isn't enclosed by any source root.
|
||||
@@ -318,6 +318,9 @@ impl File {
|
||||
}
|
||||
FilePath::Vendored(vendored) => db.vendored().read_to_string(vendored),
|
||||
FilePath::SystemVirtual(system_virtual) => {
|
||||
// Add a dependency on the revision to ensure the operation gets re-executed when the file changes.
|
||||
let _ = self.revision(db);
|
||||
|
||||
db.system().read_virtual_path_to_string(system_virtual)
|
||||
}
|
||||
}
|
||||
@@ -342,6 +345,9 @@ impl File {
|
||||
"Reading a notebook from the vendored file system is not supported.",
|
||||
))),
|
||||
FilePath::SystemVirtual(system_virtual) => {
|
||||
// Add a dependency on the revision to ensure the operation gets re-executed when the file changes.
|
||||
let _ = self.revision(db);
|
||||
|
||||
db.system().read_virtual_path_to_notebook(system_virtual)
|
||||
}
|
||||
}
|
||||
@@ -354,6 +360,13 @@ impl File {
|
||||
Self::sync_system_path(db, &absolute, None);
|
||||
}
|
||||
|
||||
/// Increments the revision for the virtual file at `path`.
|
||||
pub fn sync_virtual_path(db: &mut dyn Db, path: &SystemVirtualPath) {
|
||||
if let Some(virtual_file) = db.files().try_virtual_file(path) {
|
||||
virtual_file.sync(db);
|
||||
}
|
||||
}
|
||||
|
||||
/// Syncs the [`File`]'s state with the state of the file on the system.
|
||||
pub fn sync(self, db: &mut dyn Db) {
|
||||
let path = self.path(db).clone();
|
||||
@@ -366,29 +379,20 @@ impl File {
|
||||
FilePath::Vendored(_) => {
|
||||
// Readonly, can never be out of date.
|
||||
}
|
||||
FilePath::SystemVirtual(system_virtual) => {
|
||||
Self::sync_system_virtual_path(db, &system_virtual, self);
|
||||
FilePath::SystemVirtual(_) => {
|
||||
VirtualFile(self).sync(db);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Private method providing the implementation for [`Self::sync_path`] and [`Self::sync`] for
|
||||
/// system paths.
|
||||
fn sync_system_path(db: &mut dyn Db, path: &SystemPath, file: Option<File>) {
|
||||
let Some(file) = file.or_else(|| db.files().try_system(db, path)) else {
|
||||
return;
|
||||
};
|
||||
let metadata = db.system().path_metadata(path);
|
||||
Self::sync_impl(db, metadata, file);
|
||||
}
|
||||
|
||||
fn sync_system_virtual_path(db: &mut dyn Db, path: &SystemVirtualPath, file: File) {
|
||||
let metadata = db.system().virtual_path_metadata(path);
|
||||
Self::sync_impl(db, metadata, file);
|
||||
}
|
||||
|
||||
/// Private method providing the implementation for [`Self::sync_system_path`] and
|
||||
/// [`Self::sync_system_virtual_path`].
|
||||
fn sync_impl(db: &mut dyn Db, metadata: crate::system::Result<Metadata>, file: File) {
|
||||
let (status, revision, permission) = match metadata {
|
||||
let (status, revision, permission) = match db.system().path_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_file() => (
|
||||
FileStatus::Exists,
|
||||
metadata.revision(),
|
||||
@@ -422,6 +426,35 @@ impl File {
|
||||
}
|
||||
}
|
||||
|
||||
/// A virtual file that doesn't exist on the file system.
|
||||
///
|
||||
/// This is a wrapper around a [`File`] that provides additional methods to interact with a virtual
|
||||
/// file.
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct VirtualFile(File);
|
||||
|
||||
impl VirtualFile {
|
||||
/// Returns the underlying [`File`].
|
||||
pub fn file(&self) -> File {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Increments the revision of the underlying [`File`].
|
||||
fn sync(&self, db: &mut dyn Db) {
|
||||
let file = self.0;
|
||||
tracing::debug!("Updating the revision of '{}'", file.path(db));
|
||||
let current_revision = file.revision(db);
|
||||
file.set_revision(db)
|
||||
.to(FileRevision::new(current_revision.as_u128() + 1));
|
||||
}
|
||||
|
||||
/// Closes the virtual file.
|
||||
pub fn close(&self, db: &mut dyn Db) {
|
||||
tracing::debug!("Closing virtual file '{}'", self.0.path(db));
|
||||
self.0.set_status(db).to(FileStatus::NotFound);
|
||||
}
|
||||
}
|
||||
|
||||
// The types in here need to be public because they're salsa ingredients but we
|
||||
// don't want them to be publicly accessible. That's why we put them into a private module.
|
||||
mod private {
|
||||
|
||||
@@ -121,9 +121,9 @@ mod tests {
|
||||
|
||||
db.write_virtual_file(path, "x = 10");
|
||||
|
||||
let file = db.files().add_virtual_file(&db, path).unwrap();
|
||||
let virtual_file = db.files().virtual_file(&db, path);
|
||||
|
||||
let parsed = parsed_module(&db, file);
|
||||
let parsed = parsed_module(&db, virtual_file.file());
|
||||
|
||||
assert!(parsed.is_valid());
|
||||
|
||||
@@ -137,9 +137,9 @@ mod tests {
|
||||
|
||||
db.write_virtual_file(path, "%timeit a = b");
|
||||
|
||||
let file = db.files().add_virtual_file(&db, path).unwrap();
|
||||
let virtual_file = db.files().virtual_file(&db, path);
|
||||
|
||||
let parsed = parsed_module(&db, file);
|
||||
let parsed = parsed_module(&db, virtual_file.file());
|
||||
|
||||
assert!(parsed.is_valid());
|
||||
|
||||
|
||||
@@ -63,9 +63,6 @@ pub trait System: Debug {
|
||||
/// representation fall-back to deserializing the notebook from a string.
|
||||
fn read_to_notebook(&self, path: &SystemPath) -> std::result::Result<Notebook, NotebookError>;
|
||||
|
||||
/// Reads the metadata of the virtual file at `path`.
|
||||
fn virtual_path_metadata(&self, path: &SystemVirtualPath) -> Result<Metadata>;
|
||||
|
||||
/// Reads the content of the virtual file at `path` into a [`String`].
|
||||
fn read_virtual_path_to_string(&self, path: &SystemVirtualPath) -> Result<String>;
|
||||
|
||||
|
||||
@@ -136,22 +136,6 @@ impl MemoryFileSystem {
|
||||
ruff_notebook::Notebook::from_source_code(&content)
|
||||
}
|
||||
|
||||
pub(crate) fn virtual_path_metadata(
|
||||
&self,
|
||||
path: impl AsRef<SystemVirtualPath>,
|
||||
) -> Result<Metadata> {
|
||||
let virtual_files = self.inner.virtual_files.read().unwrap();
|
||||
let file = virtual_files
|
||||
.get(&path.as_ref().to_path_buf())
|
||||
.ok_or_else(not_found)?;
|
||||
|
||||
Ok(Metadata {
|
||||
revision: file.last_modified.into(),
|
||||
permissions: Some(MemoryFileSystem::PERMISSION),
|
||||
file_type: FileType::File,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn read_virtual_path_to_string(
|
||||
&self,
|
||||
path: impl AsRef<SystemVirtualPath>,
|
||||
|
||||
@@ -77,10 +77,6 @@ impl System for OsSystem {
|
||||
Notebook::from_path(path.as_std_path())
|
||||
}
|
||||
|
||||
fn virtual_path_metadata(&self, _path: &SystemVirtualPath) -> Result<Metadata> {
|
||||
Err(not_found())
|
||||
}
|
||||
|
||||
fn read_virtual_path_to_string(&self, _path: &SystemVirtualPath) -> Result<String> {
|
||||
Err(not_found())
|
||||
}
|
||||
|
||||
@@ -69,13 +69,6 @@ impl System for TestSystem {
|
||||
}
|
||||
}
|
||||
|
||||
fn virtual_path_metadata(&self, path: &SystemVirtualPath) -> Result<Metadata> {
|
||||
match &self.inner {
|
||||
TestSystemInner::Stub(fs) => fs.virtual_path_metadata(path),
|
||||
TestSystemInner::System(system) => system.virtual_path_metadata(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_virtual_path_to_string(&self, path: &SystemVirtualPath) -> Result<String> {
|
||||
match &self.inner {
|
||||
TestSystemInner::Stub(fs) => fs.read_virtual_path_to_string(path),
|
||||
|
||||
@@ -38,7 +38,7 @@ def tripled_quoted():
|
||||
c = a
|
||||
single_line = """ {a} """ # RUF027
|
||||
# RUF027
|
||||
multi_line = a = """b { # comment
|
||||
multi_line = """b { # comment
|
||||
c} d
|
||||
"""
|
||||
|
||||
@@ -72,3 +72,16 @@ def method_calls():
|
||||
def format_specifiers():
|
||||
a = 4
|
||||
b = "{a:b} {a:^5}"
|
||||
|
||||
|
||||
def implicitly_concatenated_fstring(x: int):
|
||||
y = f"{x}" "{x}" # RUF027
|
||||
z = "{x}" f"{x}" # RUF027
|
||||
|
||||
|
||||
def method_call_passed_to_logging(x: int):
|
||||
# We special-case bindings that are passed to logging calls later on,
|
||||
# but not bindings that involve arbitrary call expressions!
|
||||
y = foo("{x}") # RUF027
|
||||
import logging
|
||||
logging.log(0, y)
|
||||
|
||||
@@ -68,3 +68,33 @@ def negative_cases():
|
||||
@app.get("/items/{item_id}")
|
||||
async def read_item(item_id):
|
||||
return {"item_id": item_id}
|
||||
|
||||
|
||||
# we shouldn't flag either of these, because the bindings are used elsewhere
|
||||
# in contexts that make it clear that they're not meant to be f-strings
|
||||
GLOBAL_STRING = "foo {bar}"
|
||||
LOGGING_TEMPLATE = "{foo} bar"
|
||||
|
||||
def uses_global_strings():
|
||||
print(GLOBAL_STRING.format(bar="whatever"))
|
||||
|
||||
import logging
|
||||
logging.error(LOGGING_TEMPLATE, 42)
|
||||
|
||||
|
||||
def binding_defined_after_string():
|
||||
if bool():
|
||||
x = "{foo}"
|
||||
else:
|
||||
x = "{foo}"
|
||||
foo = 42
|
||||
print(x.format(foo=foo))
|
||||
|
||||
|
||||
def variable_immediately_used_after_more_complex_binding(arg: str, as_pypath: bool):
|
||||
msg = (
|
||||
"module or package not found: {arg} (missing __init__.py?)"
|
||||
if as_pypath
|
||||
else "file or directory not found: {arg}"
|
||||
)
|
||||
raise TypeError(msg.format(arg=arg))
|
||||
|
||||
@@ -15,6 +15,7 @@ pub(crate) fn bindings(checker: &mut Checker) {
|
||||
Rule::UnconventionalImportAlias,
|
||||
Rule::UnsortedDunderSlots,
|
||||
Rule::UnusedVariable,
|
||||
Rule::MissingFStringSyntax,
|
||||
]) {
|
||||
return;
|
||||
}
|
||||
@@ -77,5 +78,11 @@ pub(crate) fn bindings(checker: &mut Checker) {
|
||||
checker.diagnostics.push(diagnostic);
|
||||
}
|
||||
}
|
||||
if checker.enabled(Rule::MissingFStringSyntax) {
|
||||
if let Some(diagnostic) = ruff::rules::missing_fstring_syntax_binding(checker, binding)
|
||||
{
|
||||
checker.diagnostics.push(diagnostic);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1080,7 +1080,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
|
||||
}
|
||||
if checker.enabled(Rule::MissingFStringSyntax) {
|
||||
for string_literal in value.literals() {
|
||||
ruff::rules::missing_fstring_syntax(checker, string_literal);
|
||||
ruff::rules::missing_fstring_syntax_expr(checker, string_literal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1376,7 +1376,7 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
|
||||
}
|
||||
if checker.enabled(Rule::MissingFStringSyntax) {
|
||||
for string_literal in value.as_slice() {
|
||||
ruff::rules::missing_fstring_syntax(checker, string_literal);
|
||||
ruff::rules::missing_fstring_syntax_expr(checker, string_literal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ mod tests {
|
||||
|
||||
use crate::assert_messages;
|
||||
use crate::registry::Rule;
|
||||
use crate::settings::types::PythonVersion;
|
||||
use crate::settings::LinterSettings;
|
||||
use crate::test::test_path;
|
||||
|
||||
@@ -36,4 +37,18 @@ mod tests {
|
||||
assert_messages!(snapshot, diagnostics);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case(Path::new("ASYNC109_0.py"); "asyncio")]
|
||||
#[test_case(Path::new("ASYNC109_1.py"); "trio")]
|
||||
fn async109_python_310_or_older(path: &Path) -> Result<()> {
|
||||
let diagnostics = test_path(
|
||||
Path::new("flake8_async").join(path),
|
||||
&LinterSettings {
|
||||
target_version: PythonVersion::Py310,
|
||||
..LinterSettings::for_rule(Rule::AsyncFunctionWithTimeout)
|
||||
},
|
||||
)?;
|
||||
assert_messages!(path.file_name().unwrap().to_str().unwrap(), diagnostics);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use ruff_text_size::Ranged;
|
||||
|
||||
use crate::checkers::ast::Checker;
|
||||
use crate::rules::flake8_async::helpers::AsyncModule;
|
||||
use crate::settings::types::PythonVersion;
|
||||
|
||||
/// ## What it does
|
||||
/// Checks for `async` functions with a `timeout` argument.
|
||||
@@ -86,6 +87,11 @@ pub(crate) fn async_function_with_timeout(
|
||||
AsyncModule::AsyncIo
|
||||
};
|
||||
|
||||
// asyncio.timeout feature was first introduced in Python 3.11
|
||||
if module == AsyncModule::AsyncIo && checker.settings.target_version < PythonVersion::Py311 {
|
||||
return;
|
||||
}
|
||||
|
||||
checker.diagnostics.push(Diagnostic::new(
|
||||
AsyncFunctionWithTimeout { module },
|
||||
timeout.range(),
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: crates/ruff_linter/src/rules/flake8_async/mod.rs
|
||||
---
|
||||
ASYNC109_0.py:8:16: ASYNC109 Async function definition with a `timeout` parameter
|
||||
|
|
||||
8 | async def func(timeout):
|
||||
| ^^^^^^^ ASYNC109
|
||||
9 | ...
|
||||
|
|
||||
= help: Use `trio.fail_after` instead
|
||||
|
||||
ASYNC109_0.py:12:16: ASYNC109 Async function definition with a `timeout` parameter
|
||||
|
|
||||
12 | async def func(timeout=10):
|
||||
| ^^^^^^^^^^ ASYNC109
|
||||
13 | ...
|
||||
|
|
||||
= help: Use `trio.fail_after` instead
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
source: crates/ruff_linter/src/rules/flake8_async/mod.rs
|
||||
---
|
||||
|
||||
@@ -4,7 +4,7 @@ use ruff_python_ast as ast;
|
||||
use ruff_python_literal::format::FormatSpec;
|
||||
use ruff_python_parser::parse_expression;
|
||||
use ruff_python_semantic::analyze::logging::is_logger_candidate;
|
||||
use ruff_python_semantic::{Modules, SemanticModel};
|
||||
use ruff_python_semantic::{Binding, Modules, ScopeId, SemanticModel};
|
||||
use ruff_source_file::Locator;
|
||||
use ruff_text_size::{Ranged, TextRange};
|
||||
|
||||
@@ -36,6 +36,8 @@ use crate::rules::fastapi::rules::is_fastapi_route_call;
|
||||
/// 6. Any format specifiers in the potential f-string are invalid.
|
||||
/// 7. The string is part of a function call that is known to expect a template string rather than an
|
||||
/// evaluated f-string: for example, a [`logging`] call, a [`gettext`] call, or a [`fastAPI` path].
|
||||
/// 8. The string is assigned to a symbol where any of the *references* to that symbol are part of a
|
||||
/// function call that is known to expect a template string rather than an evaluated f-string.
|
||||
///
|
||||
/// ## Example
|
||||
///
|
||||
@@ -61,16 +63,27 @@ pub struct MissingFStringSyntax;
|
||||
impl AlwaysFixableViolation for MissingFStringSyntax {
|
||||
#[derive_message_formats]
|
||||
fn message(&self) -> String {
|
||||
format!(r#"Possible f-string without an `f` prefix"#)
|
||||
format!("Possible f-string without an `f` prefix")
|
||||
}
|
||||
|
||||
fn fix_title(&self) -> String {
|
||||
"Add `f` prefix".into()
|
||||
"Add `f` prefix".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// RUF027
|
||||
pub(crate) fn missing_fstring_syntax(checker: &mut Checker, literal: &ast::StringLiteral) {
|
||||
///
|
||||
/// Analyze a [`ast::StringLiteral`] node to see if it looks like it could be a string that was
|
||||
/// meant to be an f-string, but is missing an `f` prefix. If so, emit a diagnostic.
|
||||
///
|
||||
/// This routine skips any string literals that are part of "simple assignments", e.g. `x = "foo"`
|
||||
/// or `x: str = "foo"`. These are checked by the [`missing_fstring_syntax_binding`] function
|
||||
/// below, which is part of the same check.
|
||||
pub(crate) fn missing_fstring_syntax_expr(checker: &mut Checker, literal: &ast::StringLiteral) {
|
||||
if !has_brackets(&literal.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
let semantic = checker.semantic();
|
||||
|
||||
// we want to avoid statement expressions that are just a string literal.
|
||||
@@ -82,35 +95,168 @@ pub(crate) fn missing_fstring_syntax(checker: &mut Checker, literal: &ast::Strin
|
||||
}
|
||||
}
|
||||
|
||||
// Simple assignments will be dealt with by `missing_fstring_syntax_binding`
|
||||
if is_simple_assignment_to_literal(literal, semantic) {
|
||||
return;
|
||||
}
|
||||
|
||||
let logger_objects = &checker.settings.logger_objects;
|
||||
let fastapi_seen = semantic.seen_module(Modules::FASTAPI);
|
||||
|
||||
let mut arg_names = FxHashSet::default();
|
||||
|
||||
// We also want to avoid:
|
||||
// - Expressions inside `gettext()` calls
|
||||
// - Expressions passed to logging calls (since the `logging` module evaluates them lazily:
|
||||
// https://docs.python.org/3/howto/logging-cookbook.html#using-particular-formatting-styles-throughout-your-application)
|
||||
// - `fastAPI` paths: https://fastapi.tiangolo.com/tutorial/path-params/
|
||||
// - Expressions where a method is immediately called on the string literal
|
||||
if semantic
|
||||
for call_expr in semantic
|
||||
.current_expressions()
|
||||
.filter_map(ast::Expr::as_call_expr)
|
||||
.any(|call_expr| {
|
||||
is_method_call_on_literal(call_expr, literal)
|
||||
|| is_gettext(call_expr, semantic)
|
||||
|| is_logger_candidate(&call_expr.func, semantic, logger_objects)
|
||||
|| (fastapi_seen && is_fastapi_route_call(call_expr, semantic))
|
||||
})
|
||||
{
|
||||
return;
|
||||
if is_method_call_on_literal(call_expr, literal) {
|
||||
return;
|
||||
}
|
||||
if is_gettext(call_expr, semantic) {
|
||||
return;
|
||||
}
|
||||
if is_logger_candidate(&call_expr.func, semantic, logger_objects) {
|
||||
return;
|
||||
}
|
||||
if fastapi_seen && is_fastapi_route_call(call_expr, semantic) {
|
||||
return;
|
||||
}
|
||||
let ast::Arguments { keywords, args, .. } = &call_expr.arguments;
|
||||
for keyword in &**keywords {
|
||||
if let Some(ident) = keyword.arg.as_ref() {
|
||||
arg_names.insert(&ident.id);
|
||||
}
|
||||
}
|
||||
for arg in &**args {
|
||||
if let ast::Expr::Name(ast::ExprName { id, .. }) = arg {
|
||||
arg_names.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_be_fstring(literal, checker.locator(), semantic) {
|
||||
if should_be_fstring(
|
||||
literal,
|
||||
checker.locator(),
|
||||
semantic,
|
||||
semantic.scope_id,
|
||||
&arg_names,
|
||||
) {
|
||||
let diagnostic = Diagnostic::new(MissingFStringSyntax, literal.range())
|
||||
.with_fix(fix_fstring_syntax(literal.range()));
|
||||
checker.diagnostics.push(diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
/// RUF027
|
||||
///
|
||||
/// Analyze a [`Binding`] to see if the bound variable is a string literal
|
||||
/// that's part of a "simple assignment", e.g. `x = "foo"` or `x: str = "foo"`.
|
||||
/// If it is, check to see if the string looks like it's meant to be an f-string,
|
||||
/// but is missing an `f` prefix. False positives are minimized by analyzing the references
|
||||
/// to the binding, to see if the binding is ever used in a way that indicates that the
|
||||
/// string is clearly meant to be a string template rather than an f-string.
|
||||
pub(crate) fn missing_fstring_syntax_binding(
|
||||
checker: &Checker,
|
||||
binding: &Binding,
|
||||
) -> Option<Diagnostic> {
|
||||
let semantic = checker.semantic();
|
||||
let locator = checker.locator();
|
||||
|
||||
let stmt = binding.statement(semantic)?;
|
||||
let string_literal = match stmt {
|
||||
ast::Stmt::Assign(ast::StmtAssign { targets, value, .. }) => match targets.as_slice() {
|
||||
[_] => value.as_string_literal_expr()?,
|
||||
_ => return None,
|
||||
},
|
||||
ast::Stmt::AnnAssign(ast::StmtAnnAssign {
|
||||
value: Some(value), ..
|
||||
}) => value.as_string_literal_expr()?,
|
||||
_ => return None,
|
||||
};
|
||||
let [string_literal] = string_literal.value.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
if !has_brackets(&string_literal.value) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let logger_objects = &checker.settings.logger_objects;
|
||||
let fastapi_seen = semantic.seen_module(Modules::FASTAPI);
|
||||
let mut arg_names = FxHashSet::default();
|
||||
|
||||
for reference in binding.references().map(|id| semantic.reference(id)) {
|
||||
let Some(expr_id) = reference.expression_id() else {
|
||||
continue;
|
||||
};
|
||||
for call_expr in semantic
|
||||
.expressions(expr_id)
|
||||
.filter_map(ast::Expr::as_call_expr)
|
||||
{
|
||||
if is_method_call_on_name(call_expr, binding.name(locator)) {
|
||||
return None;
|
||||
}
|
||||
if is_gettext(call_expr, semantic) {
|
||||
return None;
|
||||
}
|
||||
if is_logger_candidate(&call_expr.func, semantic, logger_objects) {
|
||||
return None;
|
||||
}
|
||||
if fastapi_seen && is_fastapi_route_call(call_expr, semantic) {
|
||||
return None;
|
||||
}
|
||||
let ast::Arguments { keywords, args, .. } = &call_expr.arguments;
|
||||
for keyword in &**keywords {
|
||||
if let Some(ident) = keyword.arg.as_ref() {
|
||||
arg_names.insert(&ident.id);
|
||||
}
|
||||
}
|
||||
for arg in &**args {
|
||||
if let ast::Expr::Name(ast::ExprName { id, .. }) = arg {
|
||||
arg_names.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
should_be_fstring(string_literal, locator, semantic, binding.scope, &arg_names).then(|| {
|
||||
Diagnostic::new(MissingFStringSyntax, string_literal.range())
|
||||
.with_fix(fix_fstring_syntax(string_literal.range()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Determine whether `stmt` represents a "simple assignment" to the string literal `literal`.
|
||||
///
|
||||
/// For our purposes here, a "simple assignment" is any assignment
|
||||
/// where `literal` appears on the right-hand side
|
||||
/// and `literal` is not part of an implicitly concatenated string.
|
||||
fn is_simple_assignment_to_literal(literal: &ast::StringLiteral, semantic: &SemanticModel) -> bool {
|
||||
if semantic.current_expressions().any(|expr| match expr {
|
||||
ast::Expr::Call(_) => true,
|
||||
ast::Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) => {
|
||||
value.is_implicit_concatenated()
|
||||
}
|
||||
ast::Expr::FString(ast::ExprFString { value, .. }) => value.is_implicit_concatenated(),
|
||||
_ => false,
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
match semantic.current_statement() {
|
||||
ast::Stmt::Assign(ast::StmtAssign { value, .. }) => {
|
||||
value.range().contains_range(literal.range())
|
||||
}
|
||||
ast::Stmt::AnnAssign(ast::StmtAnnAssign {
|
||||
value: Some(value), ..
|
||||
}) => value.range().contains_range(literal.range()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if an expression appears to be a `gettext` call.
|
||||
///
|
||||
/// We want to avoid statement expressions and assignments related to aliases
|
||||
@@ -162,17 +308,28 @@ fn is_method_call_on_literal(call_expr: &ast::ExprCall, literal: &ast::StringLit
|
||||
value.as_slice().contains(literal)
|
||||
}
|
||||
|
||||
/// Determine whether `call_expr` represents a method call on a symbol bound to the name `name`.
|
||||
///
|
||||
/// E.g., `foo.format(bar=3)`, where `name == "foo"`.
|
||||
fn is_method_call_on_name(call_expr: &ast::ExprCall, name: &str) -> bool {
|
||||
let ast::Expr::Attribute(ast::ExprAttribute { value, .. }) = &*call_expr.func else {
|
||||
return false;
|
||||
};
|
||||
let ast::Expr::Name(ast::ExprName { id, .. }) = &**value else {
|
||||
return false;
|
||||
};
|
||||
id == name
|
||||
}
|
||||
|
||||
/// Returns `true` if `literal` is likely an f-string with a missing `f` prefix.
|
||||
/// See [`MissingFStringSyntax`] for the validation criteria.
|
||||
fn should_be_fstring(
|
||||
literal: &ast::StringLiteral,
|
||||
locator: &Locator,
|
||||
semantic: &SemanticModel,
|
||||
scope: ScopeId,
|
||||
relevant_argument_names: &FxHashSet<&ast::name::Name>,
|
||||
) -> bool {
|
||||
if !has_brackets(&literal.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let fstring_expr = format!("f{}", locator.slice(literal));
|
||||
let Ok(parsed) = parse_expression(&fstring_expr) else {
|
||||
return false;
|
||||
@@ -183,35 +340,21 @@ fn should_be_fstring(
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut arg_names = FxHashSet::default();
|
||||
for expr in semantic
|
||||
.current_expressions()
|
||||
.filter_map(ast::Expr::as_call_expr)
|
||||
{
|
||||
let ast::Arguments { keywords, args, .. } = &expr.arguments;
|
||||
for keyword in &**keywords {
|
||||
if let Some(ident) = keyword.arg.as_ref() {
|
||||
arg_names.insert(&ident.id);
|
||||
}
|
||||
}
|
||||
for arg in &**args {
|
||||
if let ast::Expr::Name(ast::ExprName { id, .. }) = arg {
|
||||
arg_names.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for f_string in value.f_strings() {
|
||||
let mut has_name = false;
|
||||
for element in f_string.elements.expressions() {
|
||||
if let ast::Expr::Name(ast::ExprName { id, .. }) = element.expression.as_ref() {
|
||||
if arg_names.contains(id) {
|
||||
if relevant_argument_names.contains(id) {
|
||||
return false;
|
||||
}
|
||||
if semantic
|
||||
.lookup_symbol(id)
|
||||
.map_or(true, |id| semantic.binding(id).kind.is_builtin())
|
||||
{
|
||||
let Some(binding_id) = semantic.lookup_symbol_in_scope(id, scope, false) else {
|
||||
return false;
|
||||
};
|
||||
let binding = semantic.binding(binding_id);
|
||||
if binding.kind.is_builtin() {
|
||||
return false;
|
||||
}
|
||||
if binding.start() > literal.end() {
|
||||
return false;
|
||||
}
|
||||
has_name = true;
|
||||
|
||||
@@ -163,7 +163,7 @@ RUF027_0.py:39:19: RUF027 [*] Possible f-string without an `f` prefix
|
||||
39 | single_line = """ {a} """ # RUF027
|
||||
| ^^^^^^^^^^^ RUF027
|
||||
40 | # RUF027
|
||||
41 | multi_line = a = """b { # comment
|
||||
41 | multi_line = """b { # comment
|
||||
|
|
||||
= help: Add `f` prefix
|
||||
|
||||
@@ -174,15 +174,15 @@ RUF027_0.py:39:19: RUF027 [*] Possible f-string without an `f` prefix
|
||||
39 |- single_line = """ {a} """ # RUF027
|
||||
39 |+ single_line = f""" {a} """ # RUF027
|
||||
40 40 | # RUF027
|
||||
41 41 | multi_line = a = """b { # comment
|
||||
41 41 | multi_line = """b { # comment
|
||||
42 42 | c} d
|
||||
|
||||
RUF027_0.py:41:22: RUF027 [*] Possible f-string without an `f` prefix
|
||||
RUF027_0.py:41:18: RUF027 [*] Possible f-string without an `f` prefix
|
||||
|
|
||||
39 | single_line = """ {a} """ # RUF027
|
||||
40 | # RUF027
|
||||
41 | multi_line = a = """b { # comment
|
||||
| ______________________^
|
||||
41 | multi_line = """b { # comment
|
||||
| __________________^
|
||||
42 | | c} d
|
||||
43 | | """
|
||||
| |_______^ RUF027
|
||||
@@ -193,8 +193,8 @@ RUF027_0.py:41:22: RUF027 [*] Possible f-string without an `f` prefix
|
||||
38 38 | c = a
|
||||
39 39 | single_line = """ {a} """ # RUF027
|
||||
40 40 | # RUF027
|
||||
41 |- multi_line = a = """b { # comment
|
||||
41 |+ multi_line = a = f"""b { # comment
|
||||
41 |- multi_line = """b { # comment
|
||||
41 |+ multi_line = f"""b { # comment
|
||||
42 42 | c} d
|
||||
43 43 | """
|
||||
44 44 |
|
||||
@@ -315,5 +315,64 @@ RUF027_0.py:74:9: RUF027 [*] Possible f-string without an `f` prefix
|
||||
73 73 | a = 4
|
||||
74 |- b = "{a:b} {a:^5}"
|
||||
74 |+ b = f"{a:b} {a:^5}"
|
||||
75 75 |
|
||||
76 76 |
|
||||
77 77 | def implicitly_concatenated_fstring(x: int):
|
||||
|
||||
RUF027_0.py:78:16: RUF027 [*] Possible f-string without an `f` prefix
|
||||
|
|
||||
77 | def implicitly_concatenated_fstring(x: int):
|
||||
78 | y = f"{x}" "{x}" # RUF027
|
||||
| ^^^^^ RUF027
|
||||
79 | z = "{x}" f"{x}" # RUF027
|
||||
|
|
||||
= help: Add `f` prefix
|
||||
|
||||
ℹ Unsafe fix
|
||||
75 75 |
|
||||
76 76 |
|
||||
77 77 | def implicitly_concatenated_fstring(x: int):
|
||||
78 |- y = f"{x}" "{x}" # RUF027
|
||||
78 |+ y = f"{x}" f"{x}" # RUF027
|
||||
79 79 | z = "{x}" f"{x}" # RUF027
|
||||
80 80 |
|
||||
81 81 |
|
||||
|
||||
RUF027_0.py:79:9: RUF027 [*] Possible f-string without an `f` prefix
|
||||
|
|
||||
77 | def implicitly_concatenated_fstring(x: int):
|
||||
78 | y = f"{x}" "{x}" # RUF027
|
||||
79 | z = "{x}" f"{x}" # RUF027
|
||||
| ^^^^^ RUF027
|
||||
|
|
||||
= help: Add `f` prefix
|
||||
|
||||
ℹ Unsafe fix
|
||||
76 76 |
|
||||
77 77 | def implicitly_concatenated_fstring(x: int):
|
||||
78 78 | y = f"{x}" "{x}" # RUF027
|
||||
79 |- z = "{x}" f"{x}" # RUF027
|
||||
79 |+ z = f"{x}" f"{x}" # RUF027
|
||||
80 80 |
|
||||
81 81 |
|
||||
82 82 | def method_call_passed_to_logging(x: int):
|
||||
|
||||
RUF027_0.py:85:13: RUF027 [*] Possible f-string without an `f` prefix
|
||||
|
|
||||
83 | # We special-case bindings that are passed to logging calls later on,
|
||||
84 | # but not bindings that involve arbitrary call expressions!
|
||||
85 | y = foo("{x}") # RUF027
|
||||
| ^^^^^ RUF027
|
||||
86 | import logging
|
||||
87 | logging.log(0, y)
|
||||
|
|
||||
= help: Add `f` prefix
|
||||
|
||||
ℹ Unsafe fix
|
||||
82 82 | def method_call_passed_to_logging(x: int):
|
||||
83 83 | # We special-case bindings that are passed to logging calls later on,
|
||||
84 84 | # but not bindings that involve arbitrary call expressions!
|
||||
85 |- y = foo("{x}") # RUF027
|
||||
85 |+ y = foo(f"{x}") # RUF027
|
||||
86 86 | import logging
|
||||
87 87 | logging.log(0, y)
|
||||
|
||||
@@ -2152,7 +2152,7 @@ impl BytesLiteralValue {
|
||||
}
|
||||
|
||||
/// Returns an iterator over the bytes of the concatenated bytes.
|
||||
fn bytes(&self) -> impl Iterator<Item = u8> + '_ {
|
||||
pub fn bytes(&self) -> impl Iterator<Item = u8> + '_ {
|
||||
self.iter().flat_map(|part| part.as_slice().iter().copied())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,10 +621,22 @@ impl<'a> SemanticModel<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookup a symbol in the current scope. This is a carbon copy of [`Self::resolve_load`], but
|
||||
/// doesn't add any read references to the resolved symbol.
|
||||
/// Lookup a symbol in the current scope
|
||||
pub fn lookup_symbol(&self, symbol: &str) -> Option<BindingId> {
|
||||
if self.in_forward_reference() {
|
||||
self.lookup_symbol_in_scope(symbol, self.scope_id, self.in_forward_reference())
|
||||
}
|
||||
|
||||
/// Lookup a symbol in a certain scope
|
||||
///
|
||||
/// This is a carbon copy of [`Self::resolve_load`], but
|
||||
/// doesn't add any read references to the resolved symbol.
|
||||
pub fn lookup_symbol_in_scope(
|
||||
&self,
|
||||
symbol: &str,
|
||||
scope: ScopeId,
|
||||
in_forward_reference: bool,
|
||||
) -> Option<BindingId> {
|
||||
if in_forward_reference {
|
||||
if let Some(binding_id) = self.scopes.global().get(symbol) {
|
||||
if !self.bindings[binding_id].is_unbound() {
|
||||
return Some(binding_id);
|
||||
@@ -634,7 +646,7 @@ impl<'a> SemanticModel<'a> {
|
||||
|
||||
let mut seen_function = false;
|
||||
let mut class_variables_visible = true;
|
||||
for (index, scope_id) in self.scopes.ancestor_ids(self.scope_id).enumerate() {
|
||||
for (index, scope_id) in self.scopes.ancestor_ids(scope).enumerate() {
|
||||
let scope = &self.scopes[scope_id];
|
||||
if scope.kind.is_class() {
|
||||
if seen_function && matches!(symbol, "__class__") {
|
||||
|
||||
21
scripts/knot_benchmark/README.md
Normal file
21
scripts/knot_benchmark/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
## Getting started
|
||||
|
||||
1. [Install `uv`](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
|
||||
- Unix: `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||
- Windows: `powershell -c "irm https://astral.sh/uv/install.ps1 | iex"`
|
||||
|
||||
1. Build red_knot: `cargo build --bin red_knot --release`
|
||||
1. `cd` into the benchmark directory: `cd scripts/knot_benchmark`
|
||||
1. Run benchmarks: `uv run benchmark`
|
||||
|
||||
## Known limitations
|
||||
|
||||
Red Knot only implements a tiny fraction of Mypy's and Pyright's functionality,
|
||||
so the benchmarks aren't in any way a fair comparison today. However,
|
||||
they'll become more meaningful as we build out more type checking features in Red Knot.
|
||||
|
||||
### Windows support
|
||||
|
||||
The script should work on Windows, but we haven't tested it yet.
|
||||
We do make use of `shlex` which has known limitations when using non-POSIX shells.
|
||||
21
scripts/knot_benchmark/pyproject.toml
Normal file
21
scripts/knot_benchmark/pyproject.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[project]
|
||||
name = "knot_benchmark"
|
||||
version = "0.0.1"
|
||||
description = "Package for running end-to-end Red Knot benchmarks"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = ["mypy", "pyright"]
|
||||
|
||||
[project.scripts]
|
||||
benchmark = "benchmark.run:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/benchmark"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
ignore = [
|
||||
"E501", # We use ruff format
|
||||
]
|
||||
76
scripts/knot_benchmark/src/benchmark/__init__.py
Normal file
76
scripts/knot_benchmark/src/benchmark/__init__.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shlex
|
||||
import subprocess
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Command(typing.NamedTuple):
|
||||
name: str
|
||||
"""The name of the command to benchmark."""
|
||||
|
||||
command: list[str]
|
||||
"""The command to benchmark."""
|
||||
|
||||
prepare: str | None = None
|
||||
"""The command to run before each benchmark run."""
|
||||
|
||||
|
||||
class Hyperfine(typing.NamedTuple):
|
||||
name: str
|
||||
"""The benchmark to run."""
|
||||
|
||||
commands: list[Command]
|
||||
"""The commands to benchmark."""
|
||||
|
||||
warmup: int
|
||||
"""The number of warmup runs to perform."""
|
||||
|
||||
min_runs: int
|
||||
"""The minimum number of runs to perform."""
|
||||
|
||||
verbose: bool
|
||||
"""Whether to print verbose output."""
|
||||
|
||||
json: bool
|
||||
"""Whether to export results to JSON."""
|
||||
|
||||
def run(self, *, cwd: Path | None = None) -> None:
|
||||
"""Run the benchmark using `hyperfine`."""
|
||||
args = [
|
||||
"hyperfine",
|
||||
# Most repositories have some typing errors.
|
||||
# This is annoying because it prevents us from capturing "real" errors.
|
||||
"-i",
|
||||
]
|
||||
|
||||
# Export to JSON.
|
||||
if self.json:
|
||||
args.extend(["--export-json", f"{self.name}.json"])
|
||||
|
||||
# Preamble: benchmark-wide setup.
|
||||
if self.verbose:
|
||||
args.append("--show-output")
|
||||
|
||||
args.extend(["--warmup", str(self.warmup), "--min-runs", str(self.min_runs)])
|
||||
|
||||
# Add all command names,
|
||||
for command in self.commands:
|
||||
args.extend(["--command-name", command.name])
|
||||
|
||||
# Add all prepare statements.
|
||||
for command in self.commands:
|
||||
args.extend(["--prepare", command.prepare or ""])
|
||||
|
||||
# Add all commands.
|
||||
for command in self.commands:
|
||||
args.append(shlex.join(command.command))
|
||||
|
||||
logging.info(f"Running {args}")
|
||||
|
||||
subprocess.run(
|
||||
args,
|
||||
cwd=cwd,
|
||||
)
|
||||
212
scripts/knot_benchmark/src/benchmark/cases.py
Normal file
212
scripts/knot_benchmark/src/benchmark/cases.py
Normal file
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import enum
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from benchmark import Command
|
||||
from benchmark.projects import Project
|
||||
|
||||
|
||||
class Benchmark(enum.Enum):
|
||||
"""Enumeration of the benchmarks to run."""
|
||||
|
||||
COLD = "cold"
|
||||
"""Cold check of an entire project without a cache present."""
|
||||
|
||||
WARM = "warm"
|
||||
"""Re-checking the entire project without any changes"."""
|
||||
|
||||
|
||||
def which_tool(name: str) -> Path:
|
||||
tool = shutil.which(name)
|
||||
|
||||
assert (
|
||||
tool is not None
|
||||
), f"Tool {name} not found. Run the script with `uv run <script>`."
|
||||
|
||||
return Path(tool)
|
||||
|
||||
|
||||
class Tool(abc.ABC):
|
||||
def command(
|
||||
self, benchmark: Benchmark, project: Project, venv: Venv
|
||||
) -> Command | None:
|
||||
"""Generate a command to benchmark a given tool."""
|
||||
match benchmark:
|
||||
case Benchmark.COLD:
|
||||
return self.cold_command(project, venv)
|
||||
case Benchmark.WARM:
|
||||
return self.warm_command(project, venv)
|
||||
case _:
|
||||
raise ValueError(f"Invalid benchmark: {benchmark}")
|
||||
|
||||
@abc.abstractmethod
|
||||
def cold_command(self, project: Project, venv: Venv) -> Command: ...
|
||||
|
||||
def warm_command(self, project: Project, venv: Venv) -> Command | None:
|
||||
return None
|
||||
|
||||
|
||||
class Knot(Tool):
|
||||
path: Path
|
||||
name: str
|
||||
|
||||
def __init__(self, *, path: Path | None = None):
|
||||
self.name = str(path) or "knot"
|
||||
self.path = path or (
|
||||
(Path(__file__) / "../../../../../target/release/red_knot").resolve()
|
||||
)
|
||||
|
||||
assert self.path.is_file(), f"Red Knot not found at '{self.path}'. Run `cargo build --release --bin red_knot`."
|
||||
|
||||
def cold_command(self, project: Project, venv: Venv) -> Command:
|
||||
command = [str(self.path), "-v"]
|
||||
|
||||
assert len(project.include) < 2, "Knot doesn't support multiple source folders"
|
||||
|
||||
if project.include:
|
||||
command.extend(["--current-directory", project.include[0]])
|
||||
|
||||
command.extend(["--venv-path", str(venv.path)])
|
||||
|
||||
return Command(
|
||||
name="knot",
|
||||
command=command,
|
||||
)
|
||||
|
||||
|
||||
class Mypy(Tool):
|
||||
path: Path
|
||||
|
||||
def __init__(self, *, path: Path | None = None):
|
||||
self.path = path or which_tool(
|
||||
"mypy",
|
||||
)
|
||||
|
||||
def cold_command(self, project: Project, venv: Venv) -> Command:
|
||||
command = [
|
||||
*self._base_command(project, venv),
|
||||
"--no-incremental",
|
||||
"--cache-dir",
|
||||
os.devnull,
|
||||
]
|
||||
|
||||
return Command(
|
||||
name="mypy",
|
||||
command=command,
|
||||
)
|
||||
|
||||
def warm_command(self, project: Project, venv: Venv) -> Command | None:
|
||||
command = [
|
||||
str(self.path),
|
||||
*(project.mypy_arguments or project.include),
|
||||
"--python-executable",
|
||||
str(venv.python),
|
||||
]
|
||||
|
||||
return Command(
|
||||
name="mypy",
|
||||
command=command,
|
||||
)
|
||||
|
||||
def _base_command(self, project: Project, venv: Venv) -> list[str]:
|
||||
return [
|
||||
str(self.path),
|
||||
"--python-executable",
|
||||
str(venv.python),
|
||||
*(project.mypy_arguments or project.include),
|
||||
]
|
||||
|
||||
|
||||
class Pyright(Tool):
|
||||
path: Path
|
||||
|
||||
def __init__(self, *, path: Path | None = None):
|
||||
self.path = path or which_tool("pyright")
|
||||
|
||||
def cold_command(self, project: Project, venv: Venv) -> Command:
|
||||
command = [
|
||||
str(self.path),
|
||||
"--venvpath",
|
||||
str(
|
||||
venv.path.parent
|
||||
), # This is not the path to the venv folder, but the folder that contains the venv...
|
||||
*(project.pyright_arguments or project.include),
|
||||
]
|
||||
|
||||
return Command(
|
||||
name="Pyright",
|
||||
command=command,
|
||||
)
|
||||
|
||||
|
||||
class Venv:
|
||||
path: Path
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""The name of the virtual environment directory."""
|
||||
return self.path.name
|
||||
|
||||
@property
|
||||
def python(self):
|
||||
"""Returns the path to the python executable"""
|
||||
return self.script("python")
|
||||
|
||||
@property
|
||||
def bin(self) -> Path:
|
||||
bin_dir = "scripts" if sys.platform == "win32" else "bin"
|
||||
return self.path / bin_dir
|
||||
|
||||
def script(self, name: str) -> Path:
|
||||
extension = ".exe" if sys.platform == "win32" else ""
|
||||
return self.bin / f"{name}{extension}"
|
||||
|
||||
@staticmethod
|
||||
def create(parent: Path) -> Venv:
|
||||
"""Creates a new, empty virtual environment."""
|
||||
|
||||
command = [
|
||||
"uv",
|
||||
"venv",
|
||||
"--quiet",
|
||||
"venv",
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
command, cwd=parent, check=True, capture_output=True, text=True
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(f"Failed to create venv: {e.stderr}")
|
||||
|
||||
root = parent / "venv"
|
||||
return Venv(root)
|
||||
|
||||
def install(self, dependencies: list[str]):
|
||||
"""Installs the dependencies required to type check the project."""
|
||||
|
||||
logging.debug(f"Installing dependencies: {', '.join(dependencies)}")
|
||||
command = [
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"--quiet",
|
||||
*dependencies,
|
||||
]
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
command, cwd=self.path, check=True, capture_output=True, text=True
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(f"Failed to install dependencies: {e.stderr}")
|
||||
142
scripts/knot_benchmark/src/benchmark/projects.py
Normal file
142
scripts/knot_benchmark/src/benchmark/projects.py
Normal file
@@ -0,0 +1,142 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Project(typing.NamedTuple):
|
||||
name: str
|
||||
"""The name of the project to benchmark."""
|
||||
|
||||
repository: str
|
||||
"""The git repository to clone."""
|
||||
|
||||
revision: str
|
||||
|
||||
dependencies: list[str]
|
||||
"""List of type checking dependencies"""
|
||||
|
||||
include: list[str] = []
|
||||
"""The directories and files to check. If empty, checks the current directory"""
|
||||
|
||||
pyright_arguments: list[str] | None = None
|
||||
"""The arguments passed to pyright. Overrides `include` if set."""
|
||||
|
||||
mypy_arguments: list[str] | None = None
|
||||
"""The arguments passed to mypy. Overrides `include` if set."""
|
||||
|
||||
def clone(self, checkout_dir: Path):
|
||||
# Skip cloning if the project has already been cloned (the script doesn't yet support updating)
|
||||
if os.path.exists(os.path.join(checkout_dir, ".git")):
|
||||
return
|
||||
|
||||
logging.debug(f"Cloning {self.repository} to {checkout_dir}")
|
||||
|
||||
try:
|
||||
# git doesn't support cloning a specific revision.
|
||||
# This is the closest that I found to a "shallow clone with a specific revision"
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"init",
|
||||
"--quiet",
|
||||
],
|
||||
env={"GIT_TERMINAL_PROMPT": "0"},
|
||||
cwd=checkout_dir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["git", "remote", "add", "origin", str(self.repository), "--no-fetch"],
|
||||
env={"GIT_TERMINAL_PROMPT": "0"},
|
||||
cwd=checkout_dir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"fetch",
|
||||
"origin",
|
||||
self.revision,
|
||||
"--quiet",
|
||||
"--depth",
|
||||
"1",
|
||||
"--no-tags",
|
||||
],
|
||||
check=True,
|
||||
cwd=checkout_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
subprocess.run(
|
||||
["git", "reset", "--hard", "FETCH_HEAD", "--quiet"],
|
||||
check=True,
|
||||
cwd=checkout_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(f"Failed to clone {self.name}: {e.stderr}")
|
||||
|
||||
logging.info(f"Cloned {self.name} to {checkout_dir}.")
|
||||
|
||||
|
||||
# Selection of projects taken from
|
||||
# [mypy-primer](https://github.com/hauntsaninja/mypy_primer/blob/0ea6cc614b3e91084059b9a3acc58f94c066a211/mypy_primer/projects.py#L71).
|
||||
# May require frequent updating, especially the dependencies list
|
||||
ALL = [
|
||||
Project(
|
||||
name="black",
|
||||
repository="https://github.com/psf/black",
|
||||
revision="c20423249e9d8dfb8581eebbfc67a13984ee45e9",
|
||||
include=["src"],
|
||||
dependencies=[
|
||||
"aiohttp",
|
||||
"click",
|
||||
"pathspec",
|
||||
"tomli",
|
||||
"platformdirs",
|
||||
"packaging",
|
||||
],
|
||||
),
|
||||
Project(
|
||||
name="jinja",
|
||||
repository="https://github.com/pallets/jinja",
|
||||
revision="b490da6b23b7ad25dc969976f64dc4ffb0a2c182",
|
||||
include=[],
|
||||
dependencies=["markupsafe"],
|
||||
),
|
||||
Project(
|
||||
name="pandas",
|
||||
repository="https://github.com/pandas-dev/pandas",
|
||||
revision="7945e563d36bcf4694ccc44698829a6221905839",
|
||||
include=["pandas"],
|
||||
dependencies=[
|
||||
"numpy",
|
||||
"types-python-dateutil",
|
||||
"types-pytz",
|
||||
"types-PyMySQL",
|
||||
"types-setuptools",
|
||||
"pytest",
|
||||
],
|
||||
),
|
||||
Project(
|
||||
name="isort",
|
||||
repository="https://github.com/pycqa/isort",
|
||||
revision="7de182933fd50e04a7c47cc8be75a6547754b19c",
|
||||
mypy_arguments=["--ignore-missing-imports", "isort"],
|
||||
include=["isort"],
|
||||
dependencies=["types-setuptools"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
DEFAULT: list[str] = ["black", "jinja", "isort"]
|
||||
153
scripts/knot_benchmark/src/benchmark/run.py
Normal file
153
scripts/knot_benchmark/src/benchmark/run.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
from benchmark import Hyperfine
|
||||
from benchmark.cases import Benchmark, Knot, Mypy, Pyright, Tool, Venv
|
||||
from benchmark.projects import ALL as all_projects
|
||||
from benchmark.projects import DEFAULT as default_projects
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from benchmark.cases import Tool
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the benchmark."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Benchmark knot against other packaging tools."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", "-v", action="store_true", help="Print verbose output."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--warmup",
|
||||
type=int,
|
||||
help="The number of warmup runs to perform.",
|
||||
default=3,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-runs",
|
||||
type=int,
|
||||
help="The minimum number of runs to perform.",
|
||||
default=10,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--benchmark",
|
||||
"-b",
|
||||
type=str,
|
||||
help="The benchmark(s) to run.",
|
||||
choices=[benchmark.value for benchmark in Benchmark],
|
||||
action="append",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project",
|
||||
"-p",
|
||||
type=str,
|
||||
help="The project(s) to run.",
|
||||
choices=[project.name for project in all_projects],
|
||||
action="append",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mypy",
|
||||
help="Whether to benchmark `mypy`.",
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pyright",
|
||||
help="Whether to benchmark `pyright`.",
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--knot",
|
||||
help="Whether to benchmark knot (assumes a red_knot binary exists at `./target/release/red_knot`).",
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--knot-path",
|
||||
type=str,
|
||||
help="Path(s) to the red_knot binary to benchmark.",
|
||||
action="append",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(
|
||||
level=logging.INFO if args.verbose else logging.WARN,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
verbose = args.verbose
|
||||
warmup = args.warmup
|
||||
min_runs = args.min_runs
|
||||
|
||||
# Determine the tools to benchmark, based on the user-provided arguments.
|
||||
suites: list[Tool] = []
|
||||
if args.pyright:
|
||||
suites.append(Pyright())
|
||||
|
||||
if args.knot:
|
||||
suites.append(Knot())
|
||||
|
||||
for path in args.knot_path or []:
|
||||
suites.append(Knot(path=path))
|
||||
|
||||
if args.mypy:
|
||||
suites.append(Mypy())
|
||||
|
||||
# If no tools were specified, default to benchmarking all tools.
|
||||
suites = suites or [Knot(), Pyright(), Mypy()]
|
||||
|
||||
# Determine the benchmarks to run, based on user input.
|
||||
benchmarks = (
|
||||
[Benchmark(benchmark) for benchmark in args.benchmark]
|
||||
if args.benchmark is not None
|
||||
else list(Benchmark)
|
||||
)
|
||||
|
||||
projects = [
|
||||
project
|
||||
for project in all_projects
|
||||
if project.name in (args.project or default_projects)
|
||||
]
|
||||
|
||||
for project in projects:
|
||||
with tempfile.TemporaryDirectory() as cwd:
|
||||
cwd = Path(cwd)
|
||||
project.clone(cwd)
|
||||
|
||||
venv = Venv.create(cwd)
|
||||
venv.install(project.dependencies)
|
||||
|
||||
# Set the `venv` config for pyright. Pyright only respects the `--venvpath`
|
||||
# CLI option when `venv` is set in the configuration... 🤷♂️
|
||||
with open(cwd / "pyrightconfig.json", "w") as f:
|
||||
f.write(json.dumps(dict(venv=venv.name)))
|
||||
|
||||
for benchmark in benchmarks:
|
||||
# Generate the benchmark command for each tool.
|
||||
commands = [
|
||||
command
|
||||
for suite in suites
|
||||
if (command := suite.command(benchmark, project, venv))
|
||||
]
|
||||
|
||||
# not all tools support all benchmarks.
|
||||
if not commands:
|
||||
continue
|
||||
|
||||
print(f"{project.name} ({benchmark.value})")
|
||||
|
||||
hyperfine = Hyperfine(
|
||||
name=f"{project.name}-{benchmark.value}",
|
||||
commands=commands,
|
||||
warmup=warmup,
|
||||
min_runs=min_runs,
|
||||
verbose=verbose,
|
||||
json=False,
|
||||
)
|
||||
hyperfine.run(cwd=cwd)
|
||||
74
scripts/knot_benchmark/uv.lock
generated
Normal file
74
scripts/knot_benchmark/uv.lock
generated
Normal file
@@ -0,0 +1,74 @@
|
||||
version = 1
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "knot-benchmark"
|
||||
version = "0.0.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pyright" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pyright" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/9c/a4b3bda53823439cf395db8ecdda6229a83f9bf201714a68a15190bb2919/mypy-1.11.1.tar.gz", hash = "sha256:f404a0b069709f18bbdb702eb3dcfe51910602995de00bd39cea3050b5772d08", size = 3078369 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/34/69638cee2e87303f19a0c35e80d42757e14d9aba328f272fdcdc0bf3c9b8/mypy-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f39918a50f74dc5969807dcfaecafa804fa7f90c9d60506835036cc1bc891dc8", size = 10995789 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/3c/3e0611348fc53a4a7c80485959478b4f6eae706baf3b7c03cafa22639216/mypy-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0bc71d1fb27a428139dd78621953effe0d208aed9857cb08d002280b0422003a", size = 10002696 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/21/a6b46c91b4c9d1918ee59c305f46850cde7cbea748635a352e7c3c8ed204/mypy-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b868d3bcff720dd7217c383474008ddabaf048fad8d78ed948bb4b624870a417", size = 12505772 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/55/07904d4c8f408e70308015edcbff067eaa77514475938a9dd81b063de2a8/mypy-1.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a707ec1527ffcdd1c784d0924bf5cb15cd7f22683b919668a04d2b9c34549d2e", size = 12954190 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/b7/3a50f318979c8c541428c2f1ee973cda813bcc89614de982dafdd0df2b3e/mypy-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:64f4a90e3ea07f590c5bcf9029035cf0efeae5ba8be511a8caada1a4893f5525", size = 9663138 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/d4/4960d0df55f30a7625d9c3c9414dfd42f779caabae137ef73ffaed0c97b9/mypy-1.11.1-py3-none-any.whl", hash = "sha256:0624bdb940255d2dd24e829d99a13cfeb72e4e9031f9492148f410ed30bcab54", size = 2619257 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyright"
|
||||
version = "1.1.377"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodeenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/f0/25b0db363d6888164adb7c828b877bbf2c30936955fb9513922ae03e70e4/pyright-1.1.377.tar.gz", hash = "sha256:aabc30fedce0ded34baa0c49b24f10e68f4bfc8f68ae7f3d175c4b0f256b4fcf", size = 17484 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/c9/89c40c4de44fe9463e77dddd0c4e2d2dd7a93e8ddc6858dfe7d5f75d263d/pyright-1.1.377-py3-none-any.whl", hash = "sha256:af0dd2b6b636c383a6569a083f8c5a8748ae4dcde5df7914b3f3f267e14dd162", size = 18223 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.12.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
|
||||
]
|
||||
Reference in New Issue
Block a user