Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
lazy from __future__ import annotations
from __future__ import generator_stop
46 changes: 40 additions & 6 deletions crates/ruff_linter/src/checkers/ast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ use ruff_python_ast::{PySourceType, helpers, str, visitor};
use ruff_python_codegen::{Generator, Stylist};
use ruff_python_index::Indexer;
use ruff_python_parser::semantic_errors::{
SemanticSyntaxChecker, SemanticSyntaxContext, SemanticSyntaxError, SemanticSyntaxErrorKind,
LazyImportContext, SemanticSyntaxChecker, SemanticSyntaxContext, SemanticSyntaxError,
SemanticSyntaxErrorKind,
};
use ruff_python_parser::typing::{AnnotationKind, ParsedAnnotation, parse_type_annotation};
use ruff_python_parser::{ParseError, Parsed};
Expand Down Expand Up @@ -766,6 +767,9 @@ impl SemanticSyntaxContext for Checker<'_> {
}
}
SemanticSyntaxErrorKind::ReboundComprehensionVariable
| SemanticSyntaxErrorKind::LazyImportNotAllowed { .. }
| SemanticSyntaxErrorKind::LazyImportStar
| SemanticSyntaxErrorKind::LazyFutureImport
| SemanticSyntaxErrorKind::DuplicateTypeParameter
| SemanticSyntaxErrorKind::MultipleCaseAssignment(_)
| SemanticSyntaxErrorKind::IrrefutableCasePattern(_)
Expand Down Expand Up @@ -798,6 +802,29 @@ impl SemanticSyntaxContext for Checker<'_> {
self.semantic.future_annotations_or_stub()
}

fn lazy_import_context(&self) -> Option<LazyImportContext> {
match self.semantic.current_scope().kind {
// Possible, but invalid positions.
ScopeKind::Function(_) => return Some(LazyImportContext::Function),
ScopeKind::Class(_) => return Some(LazyImportContext::Class),
// Valid position.
ScopeKind::Module => {}
// Impossible positions because lambdas and comprehensions can't contain statements.
ScopeKind::Lambda(_)
| ScopeKind::Generator { .. }
| ScopeKind::Type
| ScopeKind::DunderClassCell => {}
}

for statement in self.semantic.current_statements().skip(1) {
if matches!(statement, Stmt::Try(_)) {
return Some(LazyImportContext::TryExceptBlocks);
}
}

None
}

fn in_async_context(&self) -> bool {
self.semantic.in_async_context()
}
Expand Down Expand Up @@ -942,11 +969,17 @@ impl<'a> Visitor<'a> for Checker<'a> {
{
self.semantic.flags |= SemanticModelFlags::MODULE_DOCSTRING_BOUNDARY;
}
Stmt::ImportFrom(ast::StmtImportFrom { module, names, .. }) => {
Stmt::ImportFrom(ast::StmtImportFrom {
module,
names,
is_lazy,
..
}) => {
self.semantic.flags |= SemanticModelFlags::MODULE_DOCSTRING_BOUNDARY;

// Allow __future__ imports until we see a non-__future__ import.
if let Some("__future__") = module.as_deref() {
// Allow eager `__future__` imports until we see any other import. Lazy imports,
// including `lazy from __future__ import ...`, don't enable future annotations.
if !*is_lazy && matches!(module.as_deref(), Some("__future__")) {
if names
.iter()
.any(|alias| alias.name.as_str() == "annotations")
Expand Down Expand Up @@ -1058,7 +1091,7 @@ impl<'a> Visitor<'a> for Checker<'a> {
names,
module,
level,
is_lazy: _,
is_lazy,
range: _,
node_index: _,
}) => {
Expand All @@ -1068,6 +1101,7 @@ impl<'a> Visitor<'a> for Checker<'a> {

let module = module.as_deref();
let level = *level;
let is_lazy = *is_lazy;

// Mark the top-level module as "seen" by the semantic model.
if level == 0 {
Expand All @@ -1077,7 +1111,7 @@ impl<'a> Visitor<'a> for Checker<'a> {
}

for alias in names {
if let Some("__future__") = module {
if !is_lazy && matches!(module, Some("__future__")) {
let name = alias.asname.as_ref().unwrap_or(&alias.name);
self.add_binding(
name,
Expand Down
5 changes: 3 additions & 2 deletions crates/ruff_linter/src/importer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,8 +540,9 @@ impl<'a> Importer<'a> {
let _docstring = body.next_if(|stmt| ast::helpers::is_docstring_stmt(stmt));

body.take_while(|stmt| {
stmt.as_import_from_stmt()
.is_some_and(|import_from| import_from.module.as_deref() == Some("__future__"))
stmt.as_import_from_stmt().is_some_and(|import_from| {
!import_from.is_lazy && import_from.module.as_deref() == Some("__future__")
})
})
.last()
}
Expand Down
1 change: 1 addition & 0 deletions crates/ruff_linter/src/linter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,7 @@ mod tests {
#[test_case(Path::new("invalid_expression.py"), PythonVersion::PY312)]
#[test_case(Path::new("global_parameter.py"), PythonVersion::PY310)]
#[test_case(Path::new("annotated_global.py"), PythonVersion::PY314)]
#[test_case(Path::new("lazy_future_import.py"), PythonVersion::PY315)]
fn test_semantic_errors(path: &Path, python_version: PythonVersion) -> Result<()> {
let snapshot = format!(
"semantic_syntax_error_{}_{}",
Expand Down
7 changes: 7 additions & 0 deletions crates/ruff_linter/src/rules/pyflakes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4060,6 +4060,13 @@ lambda: fu
&[],
);

flakes(
r"
lazy from __future__ import annotations
",
&[Rule::UnusedImport],
);

flakes(
r"
from __future__ import annotations
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
source: crates/ruff_linter/src/linter.rs
---
invalid-syntax: lazy from __future__ import is not allowed
--> resources/test/fixtures/semantic_errors/lazy_future_import.py:1:1
|
1 | lazy from __future__ import annotations
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
2 | from __future__ import generator_stop
|
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# parse_options: {"target-version": "3.15"}
try:
lazy import os
except:
pass

try:
x
except* Exception:
lazy import sys

def func():
lazy import math

async def async_func():
lazy from json import loads

class MyClass:
lazy import typing

def outer():
class Inner:
lazy import json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# parse_options: {"target-version": "3.15"}
lazy from os import *
lazy from __future__ import annotations

def func():
lazy from sys import *
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# parse_options: {"target-version": "3.15"}
import contextlib
with contextlib.nullcontext():
lazy import os
with contextlib.nullcontext():
lazy from sys import path
140 changes: 136 additions & 4 deletions crates/ruff_python_parser/src/semantic_errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,89 @@ impl SemanticSyntaxChecker {
});
}

fn check_lazy_import_context<Ctx: SemanticSyntaxContext>(
ctx: &Ctx,
range: TextRange,
kind: LazyImportKind,
) -> bool {
if let Some(context) = ctx.lazy_import_context() {
Self::add_error(
ctx,
SemanticSyntaxErrorKind::LazyImportNotAllowed { context, kind },
range,
);
return true;
}
false
}

fn check_stmt<Ctx: SemanticSyntaxContext>(&mut self, stmt: &ast::Stmt, ctx: &Ctx) {
match stmt {
Stmt::ImportFrom(StmtImportFrom {
range,
module,
level,
names,
is_lazy,
..
}) => {
if matches!(module.as_deref(), Some("__future__")) {
let mut handled_lazy_error = false;

if *is_lazy {
// test_ok lazy_import_semantic_ok_py315
// # parse_options: {"target-version": "3.15"}
// import contextlib
// with contextlib.nullcontext():
// lazy import os
// with contextlib.nullcontext():
// lazy from sys import path

// test_err lazy_import_invalid_context_py315
// # parse_options: {"target-version": "3.15"}
// try:
// lazy import os
// except:
// pass
//
// try:
// x
// except* Exception:
// lazy import sys
//
// def func():
// lazy import math
//
// async def async_func():
// lazy from json import loads
//
// class MyClass:
// lazy import typing
//
// def outer():
// class Inner:
// lazy import json
if Self::check_lazy_import_context(ctx, *range, LazyImportKind::ImportFrom) {
handled_lazy_error = true;
} else if names.iter().any(|alias| alias.name.as_str() == "*") {
// test_err lazy_import_invalid_from_py315
// # parse_options: {"target-version": "3.15"}
// lazy from os import *
// lazy from __future__ import annotations
//
// def func():
// lazy from sys import *
Self::add_error(ctx, SemanticSyntaxErrorKind::LazyImportStar, *range);
handled_lazy_error = true;
} else if matches!(module.as_deref(), Some("__future__")) {
Self::add_error(ctx, SemanticSyntaxErrorKind::LazyFutureImport, *range);
handled_lazy_error = true;
}
}

if handled_lazy_error {
// Skip the regular `from`-import validations after reporting the lazy-specific
// syntax error with the highest precedence.
} else if matches!(module.as_deref(), Some("__future__")) {
for name in names {
if !is_known_future_feature(&name.name) {
// test_ok valid_future_feature
Expand Down Expand Up @@ -114,6 +187,13 @@ impl SemanticSyntaxChecker {
}
}
}
Stmt::Import(ast::StmtImport {
range,
is_lazy: true,
..
}) => {
Self::check_lazy_import_context(ctx, *range, LazyImportKind::Import);
}
Stmt::Match(match_stmt) => {
Self::irrefutable_match_case(match_stmt, ctx);
for case in &match_stmt.cases {
Expand Down Expand Up @@ -748,9 +828,12 @@ impl SemanticSyntaxChecker {
match stmt {
Stmt::Expr(StmtExpr { value, .. })
if !self.seen_module_docstring_boundary && value.is_string_literal_expr() => {}
Stmt::ImportFrom(StmtImportFrom { module, .. }) => {
// Allow __future__ imports until we see a non-__future__ import.
if !matches!(module.as_deref(), Some("__future__")) {
Stmt::ImportFrom(StmtImportFrom {
module, is_lazy, ..
}) => {
// Allow eager `__future__` imports until we see any other import. Lazy imports,
// including `lazy from __future__ import ...`, always close the boundary.
if *is_lazy || !matches!(module.as_deref(), Some("__future__")) {
self.seen_futures_boundary = true;
}
}
Expand Down Expand Up @@ -1114,6 +1197,19 @@ fn is_known_future_feature(name: &str) -> bool {
)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize)]
pub enum LazyImportKind {
Import,
ImportFrom,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize)]
pub enum LazyImportContext {
Function,
Class,
TryExceptBlocks,
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, get_size2::GetSize)]
pub struct SemanticSyntaxError {
pub kind: SemanticSyntaxErrorKind,
Expand Down Expand Up @@ -1231,6 +1327,24 @@ impl Display for SemanticSyntaxError {
SemanticSyntaxErrorKind::FutureFeatureNotDefined(name) => {
write!(f, "Future feature `{name}` is not defined")
}
SemanticSyntaxErrorKind::LazyImportNotAllowed { context, kind } => {
let statement = match kind {
LazyImportKind::Import => "lazy import",
LazyImportKind::ImportFrom => "lazy from ... import",
};
let location = match context {
LazyImportContext::Function => "functions",
LazyImportContext::Class => "classes",
LazyImportContext::TryExceptBlocks => "try/except blocks",
};
write!(f, "{statement} not allowed inside {location}")
}
SemanticSyntaxErrorKind::LazyImportStar => {
f.write_str("lazy from ... import * is not allowed")
}
SemanticSyntaxErrorKind::LazyFutureImport => {
f.write_str("lazy from __future__ import is not allowed")
}
SemanticSyntaxErrorKind::BreakOutsideLoop => f.write_str("`break` outside loop"),
SemanticSyntaxErrorKind::ContinueOutsideLoop => f.write_str("`continue` outside loop"),
SemanticSyntaxErrorKind::GlobalParameter(name) => {
Expand All @@ -1257,6 +1371,18 @@ impl Ranged for SemanticSyntaxError {

#[derive(Debug, Clone, PartialEq, Eq, Hash, get_size2::GetSize)]
pub enum SemanticSyntaxErrorKind {
/// Represents a `lazy` import statement in an invalid context.
LazyImportNotAllowed {
context: LazyImportContext,
kind: LazyImportKind,
},

/// Represents the use of `lazy from ... import *`.
LazyImportStar,

/// Represents the use of `lazy from __future__ import ...`.
LazyFutureImport,

/// Represents the use of a `__future__` import after the beginning of a file.
///
/// ## Examples
Expand Down Expand Up @@ -2131,6 +2257,12 @@ pub trait SemanticSyntaxContext {
/// Returns `true` if `__future__`-style type annotations are enabled.
fn future_annotations_or_stub(&self) -> bool;

/// Returns the nearest invalid context for a `lazy` import statement, if any.
///
/// This should return the innermost relevant restriction in order of precedence:
/// function, class, then `try`/`except`.
fn lazy_import_context(&self) -> Option<LazyImportContext>;

/// The target Python version for detecting backwards-incompatible syntax changes.
fn python_version(&self) -> PythonVersion;

Expand Down
Loading