diff --git a/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_5.py b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_5.py new file mode 100644 index 0000000000..f4554f78ea --- /dev/null +++ b/crates/ruff_linter/resources/test/fixtures/pycodestyle/E402_5.py @@ -0,0 +1,19 @@ +# Issue: https://github.com/astral-sh/ruff/issues/16247#event-16362806498 + +import os +import site +import sys +import sysconfig + +site.addsitedir( + os.path.join( + os.path.dirname(os.path.dirname(__file__)), + sysconfig.get_path("purelib", vars={"base": "."}), + ) +) + +from mypkg.__main__ import main + +if __name__ == "__main__": + sys.argv[0] = sys.argv[0].removesuffix(".py") + sys.exit(main()) \ No newline at end of file diff --git a/crates/ruff_linter/src/checkers/ast/mod.rs b/crates/ruff_linter/src/checkers/ast/mod.rs index f34bc44029..e09405250f 100644 --- a/crates/ruff_linter/src/checkers/ast/mod.rs +++ b/crates/ruff_linter/src/checkers/ast/mod.rs @@ -556,7 +556,8 @@ impl<'a> Visitor<'a> for Checker<'a> { || imports::is_matplotlib_activation(stmt, self.semantic()) || imports::is_sys_path_modification(stmt, self.semantic()) || imports::is_os_environ_modification(stmt, self.semantic()) - || imports::is_pytest_importorskip(stmt, self.semantic())) + || imports::is_pytest_importorskip(stmt, self.semantic()) + || imports::is_site_sys_path_modification(stmt, self.semantic())) { self.semantic.flags |= SemanticModelFlags::IMPORT_BOUNDARY; } diff --git a/crates/ruff_linter/src/rules/pycodestyle/mod.rs b/crates/ruff_linter/src/rules/pycodestyle/mod.rs index 84b3363f10..a6a2180bbb 100644 --- a/crates/ruff_linter/src/rules/pycodestyle/mod.rs +++ b/crates/ruff_linter/src/rules/pycodestyle/mod.rs @@ -45,6 +45,7 @@ mod tests { #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_2.py"))] #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_3.py"))] #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_4.py"))] + #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402_5.py"))] #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E402.ipynb"))] #[test_case(Rule::MultipleImportsOnOneLine, Path::new("E40.py"))] #[test_case(Rule::MultipleStatementsOnOneLineColon, Path::new("E70.py"))] diff --git a/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_5.py.snap b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_5.py.snap new file mode 100644 index 0000000000..81d49b4bba --- /dev/null +++ b/crates/ruff_linter/src/rules/pycodestyle/snapshots/ruff_linter__rules__pycodestyle__tests__E402_E402_5.py.snap @@ -0,0 +1,3 @@ +--- +source: crates/ruff_linter/src/rules/pycodestyle/mod.rs +--- \ No newline at end of file diff --git a/crates/ruff_python_semantic/src/analyze/imports.rs b/crates/ruff_python_semantic/src/analyze/imports.rs index 19802a8092..60313eba14 100644 --- a/crates/ruff_python_semantic/src/analyze/imports.rs +++ b/crates/ruff_python_semantic/src/analyze/imports.rs @@ -127,3 +127,23 @@ pub fn is_pytest_importorskip(stmt: &Stmt, semantic: &SemanticModel) -> bool { matches!(qualified_name.segments(), ["pytest", "importorskip"]) }) } + +/// Returns `true` if a [`Stmt`] is a dynamic modification of the Python +/// module search path, e.g., +/// ```python +/// import site +/// +/// site.addsitedir(...) +/// ``` +pub fn is_site_sys_path_modification(stmt: &Stmt, semantic: &SemanticModel) -> bool { + if let Stmt::Expr(ast::StmtExpr { value, .. }) = stmt { + if let Expr::Call(ast::ExprCall { func, .. }) = value.as_ref() { + return semantic + .resolve_qualified_name(func.as_ref()) + .is_some_and(|qualified_name| { + matches!(qualified_name.segments(), ["site", "addsitedir"]) + }); + } + } + false +}