Compare commits

...

13 Commits

Author SHA1 Message Date
Alex Waygood
09edde8e39 Try to reduce false positives more 2024-08-29 12:48:37 +01:00
Alex Waygood
6eafadbcf6 . 2024-08-23 16:47:01 +01:00
Alex Waygood
d4ada3f833 Fix new false positive 2024-08-23 14:15:03 +01:00
Alex Waygood
fdf8fc69ac [ruff] Reduce false positives for missing-fstring-syntax (RUF027) by analyzing references to the binding when the string is assigned to a variable 2024-08-23 13:34:38 +01:00
jesse
1f2cb09853 [async-function-with-timeout] Disable check for asyncio before Python 3.11 (ASYNC109) (#13023)
Co-authored-by: Micha Reiser <micha@reiser.io>
2024-08-23 08:02:53 +00:00
Dhruv Manilawala
cfe25ab465 [red-knot] Support untitled files in the server (#13044)
## Summary

This PR adds support for untitled files in the red knot server.

## Test Plan

https://github.com/user-attachments/assets/57fa5db6-e1ad-4694-ae5f-c47a21eaa82b
2024-08-23 12:47:35 +05:30
Dhruv Manilawala
551ed2706b [red-knot] Simplify virtual file support (#13043)
## Summary

This PR simplifies the virtual file support in the red knot core,
specifically:

* Update `File::add_virtual_file` method to `File::virtual_file` which
will always create a new virtual file and override the existing entry in
the lookup table
* Add `VirtualFile` which is a wrapper around `File` and provides
methods to increment the file revision / close the virtual file
* Add a new `File::try_virtual_file` to lookup the `VirtualFile` from
`Files`
* Add `File::sync_virtual_path` which takes in the `SystemVirtualPath`,
looks up the `VirtualFile` for it and calls the `sync` method to
increment the file revision
* Removes the `virtual_path_metadata` method on `System` trait

## Test Plan

- [x] Make sure the existing red knot tests pass
- [x] Updated code works well with the LSP
2024-08-23 07:04:15 +00:00
Dhruv Manilawala
21c5606793 [red-knot] Support textDocument/didChange notification (#13042)
## Summary

This PR adds support for `textDocument/didChange` notification.

There seems to be a bug (probably in Salsa) where it panics with:
```
2024-08-22 15:33:38.802 [info] panicked at /Users/dhruv/.cargo/git/checkouts/salsa-61760caba2b17ca5/f608ff8/src/tracked_struct.rs:377:9:
two concurrent writers to Id(4800), should not be possible
```

## Test Plan


https://github.com/user-attachments/assets/81055feb-ba8e-4acf-ad2f-94084a3efead
2024-08-23 06:58:54 +00:00
Dhruv Manilawala
c73a7bb929 [red-knot] Support files outside of any workspace (#13041)
## Summary

This PR adds basic support for files outside of any workspace in the red
knot server.

This also limits the red knot server to only work in a single workspace.
The server will not start if there are multiple workspaces.

## Test Plan

https://github.com/user-attachments/assets/de601387-0ad5-433c-9d2c-7b6ae5137654
2024-08-23 06:51:48 +00:00
Micha Reiser
4f6accb5c6 Add basic red knot benchmark (#13026)
Co-authored-by: Alex Waygood <Alex.Waygood@Gmail.com>
2024-08-23 08:22:42 +02:00
Micha Reiser
1ca14e4335 Move collection of parse errors to check_file (#13059) 2024-08-23 08:22:12 +02:00
Teodoro Freund
b9c8113a8a Added bytes type and some inference (#13061)
## Summary

This PR adds the `bytes` type to red-knot:
- Added the `bytes` type
- Added support for bytes literals
- Support for the `+` operator

Improves on #12701 

Big TODO on supporting and normalizing r-prefixed bytestrings
(`rb"hello\n"`)

## Test Plan

Added a test for a bytes literals, concatenation, and corner values
2024-08-22 13:27:15 -07:00
Dylan
2edd32aa31 [red-knot] SemanticIndexBuilder visits value before target in named expressions (#13053)
The `SemanticIndexBuilder` was causing a cycle in a salsa query by
attempting to resolve the target before the value in a named expression
(e.g. `x := x+1`). This PR swaps the order, avoiding a panic.

Closes #13012.
2024-08-22 07:59:13 -07:00
51 changed files with 1500 additions and 231 deletions

1
Cargo.lock generated
View File

@@ -1926,6 +1926,7 @@ dependencies = [
"ruff_db",
"ruff_index",
"ruff_python_ast",
"ruff_python_literal",
"ruff_python_parser",
"ruff_python_stdlib",
"ruff_source_file",

View 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 }

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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("]")
}
}
}
}

View File

@@ -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();

View File

@@ -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()
},
)),

View File

@@ -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(&params).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(|_, _| {});

View File

@@ -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;

View File

@@ -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(&params.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(())
}
}

View File

@@ -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(&params.text_document.uri) else {
let Ok(path) = url_to_any_system_path(&params.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(), &notifier)?;

View File

@@ -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(&params.notebook_document.uri) else {
let Ok(path) = url_to_any_system_path(&params.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(())

View File

@@ -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(&params.text_document.uri) else {
let Ok(path) = url_to_any_system_path(&params.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

View File

@@ -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(&params.notebook_document.uri) else {
let Ok(path) = url_to_any_system_path(&params.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

View File

@@ -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(),

View File

@@ -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>;

View File

@@ -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()),
}
}
}

View 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)?

View File

@@ -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,

View File

@@ -0,0 +1,2 @@
x = 0
(x := x + 1)

View File

@@ -0,0 +1,3 @@
x = 0
if x := x + 1:
pass

View File

@@ -0,0 +1,3 @@
match x:
case [1, 0] if x := x[:0]:
y = 1

View File

@@ -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);

View File

@@ -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

View File

@@ -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,
}
}
}

View File

@@ -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());

View File

@@ -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 {

View File

@@ -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());

View File

@@ -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>;

View File

@@ -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>,

View File

@@ -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())
}

View File

@@ -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),

View File

@@ -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)

View File

@@ -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))

View File

@@ -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);
}
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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(())
}
}

View File

@@ -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(),

View File

@@ -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

View File

@@ -0,0 +1,4 @@
---
source: crates/ruff_linter/src/rules/flake8_async/mod.rs
---

View File

@@ -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;

View File

@@ -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)

View File

@@ -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())
}
}

View File

@@ -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__") {

View 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.

View 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
]

View 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,
)

View 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}")

View 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"]

View 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
View 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 },
]