Skip to content

Commit 7ca4cfe

Browse files
committed
Reject semantic syntax errors for lazy imports
1 parent 487b7c5 commit 7ca4cfe

10 files changed

Lines changed: 959 additions & 7 deletions

File tree

crates/ruff_linter/src/checkers/ast/mod.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ use ruff_python_ast::{PySourceType, helpers, str, visitor};
4747
use ruff_python_codegen::{Generator, Stylist};
4848
use ruff_python_index::Indexer;
4949
use ruff_python_parser::semantic_errors::{
50-
SemanticSyntaxChecker, SemanticSyntaxContext, SemanticSyntaxError, SemanticSyntaxErrorKind,
50+
LazyImportContext, SemanticSyntaxChecker, SemanticSyntaxContext, SemanticSyntaxError,
51+
SemanticSyntaxErrorKind,
5152
};
5253
use ruff_python_parser::typing::{AnnotationKind, ParsedAnnotation, parse_type_annotation};
5354
use ruff_python_parser::{ParseError, Parsed};
@@ -766,6 +767,9 @@ impl SemanticSyntaxContext for Checker<'_> {
766767
}
767768
}
768769
SemanticSyntaxErrorKind::ReboundComprehensionVariable
770+
| SemanticSyntaxErrorKind::LazyImportNotAllowed { .. }
771+
| SemanticSyntaxErrorKind::LazyImportStar
772+
| SemanticSyntaxErrorKind::LazyFutureImport
769773
| SemanticSyntaxErrorKind::DuplicateTypeParameter
770774
| SemanticSyntaxErrorKind::MultipleCaseAssignment(_)
771775
| SemanticSyntaxErrorKind::IrrefutableCasePattern(_)
@@ -798,6 +802,27 @@ impl SemanticSyntaxContext for Checker<'_> {
798802
self.semantic.future_annotations_or_stub()
799803
}
800804

805+
fn lazy_import_context(&self) -> Option<LazyImportContext> {
806+
match self.semantic.current_scope().kind {
807+
ScopeKind::Function(_) | ScopeKind::Lambda(_) => {
808+
return Some(LazyImportContext::Function);
809+
}
810+
ScopeKind::Class(_) => return Some(LazyImportContext::Class),
811+
ScopeKind::Generator { .. }
812+
| ScopeKind::Module
813+
| ScopeKind::Type
814+
| ScopeKind::DunderClassCell => {}
815+
}
816+
817+
for statement in self.semantic.current_statements().skip(1) {
818+
if matches!(statement, Stmt::Try(_)) {
819+
return Some(LazyImportContext::TryExceptBlocks);
820+
}
821+
}
822+
823+
None
824+
}
825+
801826
fn in_async_context(&self) -> bool {
802827
self.semantic.in_async_context()
803828
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# parse_options: {"target-version": "3.15"}
2+
try:
3+
lazy import os
4+
except:
5+
pass
6+
7+
try:
8+
x
9+
except* Exception:
10+
lazy import sys
11+
12+
def func():
13+
lazy import math
14+
15+
async def async_func():
16+
lazy from json import loads
17+
18+
class MyClass:
19+
lazy import typing
20+
21+
def outer():
22+
class Inner:
23+
lazy import json
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# parse_options: {"target-version": "3.15"}
2+
lazy from os import *
3+
lazy from __future__ import annotations
4+
5+
def func():
6+
lazy from sys import *
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# parse_options: {"target-version": "3.15"}
2+
import contextlib
3+
with contextlib.nullcontext():
4+
lazy import os
5+
with contextlib.nullcontext():
6+
lazy from sys import path

crates/ruff_python_parser/src/semantic_errors.rs

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,16 +57,89 @@ impl SemanticSyntaxChecker {
5757
});
5858
}
5959

60+
fn check_lazy_import_context<Ctx: SemanticSyntaxContext>(
61+
ctx: &Ctx,
62+
range: TextRange,
63+
kind: LazyImportKind,
64+
) -> bool {
65+
if let Some(context) = ctx.lazy_import_context() {
66+
Self::add_error(
67+
ctx,
68+
SemanticSyntaxErrorKind::LazyImportNotAllowed { context, kind },
69+
range,
70+
);
71+
return true;
72+
}
73+
false
74+
}
75+
6076
fn check_stmt<Ctx: SemanticSyntaxContext>(&mut self, stmt: &ast::Stmt, ctx: &Ctx) {
6177
match stmt {
6278
Stmt::ImportFrom(StmtImportFrom {
6379
range,
6480
module,
6581
level,
6682
names,
83+
is_lazy,
6784
..
6885
}) => {
69-
if matches!(module.as_deref(), Some("__future__")) {
86+
let mut handled_lazy_error = false;
87+
88+
if *is_lazy {
89+
// test_ok lazy_import_semantic_ok_py315
90+
// # parse_options: {"target-version": "3.15"}
91+
// import contextlib
92+
// with contextlib.nullcontext():
93+
// lazy import os
94+
// with contextlib.nullcontext():
95+
// lazy from sys import path
96+
97+
// test_err lazy_import_invalid_context_py315
98+
// # parse_options: {"target-version": "3.15"}
99+
// try:
100+
// lazy import os
101+
// except:
102+
// pass
103+
//
104+
// try:
105+
// x
106+
// except* Exception:
107+
// lazy import sys
108+
//
109+
// def func():
110+
// lazy import math
111+
//
112+
// async def async_func():
113+
// lazy from json import loads
114+
//
115+
// class MyClass:
116+
// lazy import typing
117+
//
118+
// def outer():
119+
// class Inner:
120+
// lazy import json
121+
if Self::check_lazy_import_context(ctx, *range, LazyImportKind::ImportFrom) {
122+
handled_lazy_error = true;
123+
} else if names.iter().any(|alias| alias.name.as_str() == "*") {
124+
// test_err lazy_import_invalid_from_py315
125+
// # parse_options: {"target-version": "3.15"}
126+
// lazy from os import *
127+
// lazy from __future__ import annotations
128+
//
129+
// def func():
130+
// lazy from sys import *
131+
Self::add_error(ctx, SemanticSyntaxErrorKind::LazyImportStar, *range);
132+
handled_lazy_error = true;
133+
} else if matches!(module.as_deref(), Some("__future__")) {
134+
Self::add_error(ctx, SemanticSyntaxErrorKind::LazyFutureImport, *range);
135+
handled_lazy_error = true;
136+
}
137+
}
138+
139+
if handled_lazy_error {
140+
// Skip the regular `from`-import validations after reporting the lazy-specific
141+
// syntax error with the highest precedence.
142+
} else if matches!(module.as_deref(), Some("__future__")) {
70143
for name in names {
71144
if !is_known_future_feature(&name.name) {
72145
// test_ok valid_future_feature
@@ -114,6 +187,13 @@ impl SemanticSyntaxChecker {
114187
}
115188
}
116189
}
190+
Stmt::Import(ast::StmtImport {
191+
range,
192+
is_lazy: true,
193+
..
194+
}) => {
195+
Self::check_lazy_import_context(ctx, *range, LazyImportKind::Import);
196+
}
117197
Stmt::Match(match_stmt) => {
118198
Self::irrefutable_match_case(match_stmt, ctx);
119199
for case in &match_stmt.cases {
@@ -748,9 +828,11 @@ impl SemanticSyntaxChecker {
748828
match stmt {
749829
Stmt::Expr(StmtExpr { value, .. })
750830
if !self.seen_module_docstring_boundary && value.is_string_literal_expr() => {}
751-
Stmt::ImportFrom(StmtImportFrom { module, .. }) => {
831+
Stmt::ImportFrom(StmtImportFrom {
832+
module, is_lazy, ..
833+
}) => {
752834
// Allow __future__ imports until we see a non-__future__ import.
753-
if !matches!(module.as_deref(), Some("__future__")) {
835+
if *is_lazy || !matches!(module.as_deref(), Some("__future__")) {
754836
self.seen_futures_boundary = true;
755837
}
756838
}
@@ -1114,6 +1196,19 @@ fn is_known_future_feature(name: &str) -> bool {
11141196
)
11151197
}
11161198

1199+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize)]
1200+
pub enum LazyImportKind {
1201+
Import,
1202+
ImportFrom,
1203+
}
1204+
1205+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, get_size2::GetSize)]
1206+
pub enum LazyImportContext {
1207+
Function,
1208+
Class,
1209+
TryExceptBlocks,
1210+
}
1211+
11171212
#[derive(Debug, Clone, PartialEq, Eq, Hash, get_size2::GetSize)]
11181213
pub struct SemanticSyntaxError {
11191214
pub kind: SemanticSyntaxErrorKind,
@@ -1231,6 +1326,24 @@ impl Display for SemanticSyntaxError {
12311326
SemanticSyntaxErrorKind::FutureFeatureNotDefined(name) => {
12321327
write!(f, "Future feature `{name}` is not defined")
12331328
}
1329+
SemanticSyntaxErrorKind::LazyImportNotAllowed { context, kind } => {
1330+
let statement = match kind {
1331+
LazyImportKind::Import => "lazy import",
1332+
LazyImportKind::ImportFrom => "lazy from ... import",
1333+
};
1334+
let location = match context {
1335+
LazyImportContext::Function => "functions",
1336+
LazyImportContext::Class => "classes",
1337+
LazyImportContext::TryExceptBlocks => "try/except blocks",
1338+
};
1339+
write!(f, "{statement} not allowed inside {location}")
1340+
}
1341+
SemanticSyntaxErrorKind::LazyImportStar => {
1342+
f.write_str("lazy from ... import * is not allowed")
1343+
}
1344+
SemanticSyntaxErrorKind::LazyFutureImport => {
1345+
f.write_str("lazy from __future__ import is not allowed")
1346+
}
12341347
SemanticSyntaxErrorKind::BreakOutsideLoop => f.write_str("`break` outside loop"),
12351348
SemanticSyntaxErrorKind::ContinueOutsideLoop => f.write_str("`continue` outside loop"),
12361349
SemanticSyntaxErrorKind::GlobalParameter(name) => {
@@ -1257,6 +1370,18 @@ impl Ranged for SemanticSyntaxError {
12571370

12581371
#[derive(Debug, Clone, PartialEq, Eq, Hash, get_size2::GetSize)]
12591372
pub enum SemanticSyntaxErrorKind {
1373+
/// Represents a `lazy` import statement in an invalid context.
1374+
LazyImportNotAllowed {
1375+
context: LazyImportContext,
1376+
kind: LazyImportKind,
1377+
},
1378+
1379+
/// Represents the use of `lazy from ... import *`.
1380+
LazyImportStar,
1381+
1382+
/// Represents the use of `lazy from __future__ import ...`.
1383+
LazyFutureImport,
1384+
12601385
/// Represents the use of a `__future__` import after the beginning of a file.
12611386
///
12621387
/// ## Examples
@@ -2131,6 +2256,12 @@ pub trait SemanticSyntaxContext {
21312256
/// Returns `true` if `__future__`-style type annotations are enabled.
21322257
fn future_annotations_or_stub(&self) -> bool;
21332258

2259+
/// Returns the nearest invalid context for a `lazy` import statement, if any.
2260+
///
2261+
/// This should return the innermost relevant restriction in order of precedence:
2262+
/// function, class, then `try`/`except`.
2263+
fn lazy_import_context(&self) -> Option<LazyImportContext>;
2264+
21342265
/// The target Python version for detecting backwards-incompatible syntax changes.
21352266
fn python_version(&self) -> PythonVersion;
21362267

crates/ruff_python_parser/tests/fixtures.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use ruff_python_ast::visitor::Visitor;
1010
use ruff_python_ast::visitor::source_order::{SourceOrderVisitor, TraversalSignal, walk_module};
1111
use ruff_python_ast::{self as ast, AnyNodeRef, Mod, PythonVersion};
1212
use ruff_python_parser::semantic_errors::{
13-
SemanticSyntaxChecker, SemanticSyntaxContext, SemanticSyntaxError,
13+
LazyImportContext, SemanticSyntaxChecker, SemanticSyntaxContext, SemanticSyntaxError,
1414
};
1515
use ruff_python_parser::{Mode, ParseErrorType, ParseOptions, Parsed, parse_unchecked};
1616
use ruff_source_file::{LineIndex, OneIndexed, SourceCode};
@@ -532,6 +532,7 @@ struct SemanticSyntaxCheckerVisitor<'a> {
532532
python_version: PythonVersion,
533533
source: &'a str,
534534
scopes: Vec<Scope>,
535+
try_depth: u32,
535536
}
536537

537538
impl<'a> SemanticSyntaxCheckerVisitor<'a> {
@@ -542,6 +543,7 @@ impl<'a> SemanticSyntaxCheckerVisitor<'a> {
542543
python_version: PythonVersion::default(),
543544
source,
544545
scopes: vec![Scope::Module],
546+
try_depth: 0,
545547
}
546548
}
547549

@@ -567,6 +569,20 @@ impl SemanticSyntaxContext for SemanticSyntaxCheckerVisitor<'_> {
567569
false
568570
}
569571

572+
fn lazy_import_context(&self) -> Option<LazyImportContext> {
573+
match self.scopes.last() {
574+
Some(Scope::Function { .. }) => return Some(LazyImportContext::Function),
575+
Some(Scope::Class) => return Some(LazyImportContext::Class),
576+
Some(Scope::Module | Scope::Comprehension { .. }) | None => {}
577+
}
578+
579+
if self.try_depth > 0 {
580+
return Some(LazyImportContext::TryExceptBlocks);
581+
}
582+
583+
None
584+
}
585+
570586
fn python_version(&self) -> PythonVersion {
571587
self.python_version
572588
}
@@ -672,6 +688,22 @@ impl Visitor<'_> for SemanticSyntaxCheckerVisitor<'_> {
672688
ast::visitor::walk_stmt(self, stmt);
673689
self.scopes.pop().unwrap();
674690
}
691+
ast::Stmt::Try(ast::StmtTry {
692+
body,
693+
handlers,
694+
orelse,
695+
finalbody,
696+
..
697+
}) => {
698+
self.try_depth += 1;
699+
self.visit_body(body);
700+
for handler in handlers {
701+
self.visit_except_handler(handler);
702+
}
703+
self.visit_body(orelse);
704+
self.visit_body(finalbody);
705+
self.try_depth -= 1;
706+
}
675707
_ => {
676708
ast::visitor::walk_stmt(self, stmt);
677709
}

0 commit comments

Comments
 (0)