Skip to content

Commit 521217b

Browse files
[ruff]: Make ruff analyze graph work with jupyter notebooks (#21161)
Co-authored-by: Gautham Venkataraman <gautham@dexterenergy.ai> Co-authored-by: Micha Reiser <micha@reiser.io>
1 parent a32d5b8 commit 521217b

File tree

3 files changed

+164
-14
lines changed

3 files changed

+164
-14
lines changed

crates/ruff/src/commands/analyze_graph.rs

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use path_absolutize::CWD;
77
use ruff_db::system::{SystemPath, SystemPathBuf};
88
use ruff_graph::{Direction, ImportMap, ModuleDb, ModuleImports};
99
use ruff_linter::package::PackageRoot;
10+
use ruff_linter::source_kind::SourceKind;
1011
use ruff_linter::{warn_user, warn_user_once};
1112
use ruff_python_ast::{PySourceType, SourceType};
1213
use ruff_workspace::resolver::{ResolvedFile, match_exclusion, python_files_in_path};
@@ -127,10 +128,6 @@ pub(crate) fn analyze_graph(
127128
},
128129
Some(language) => PySourceType::from(language),
129130
};
130-
if matches!(source_type, PySourceType::Ipynb) {
131-
debug!("Ignoring Jupyter notebook: {}", path.display());
132-
continue;
133-
}
134131

135132
// Convert to system paths.
136133
let Ok(package) = package.map(SystemPathBuf::from_path_buf).transpose() else {
@@ -147,13 +144,34 @@ pub(crate) fn analyze_graph(
147144
let root = root.clone();
148145
let result = inner_result.clone();
149146
scope.spawn(move |_| {
147+
// Extract source code (handles both .py and .ipynb files)
148+
let source_kind = match SourceKind::from_path(path.as_std_path(), source_type) {
149+
Ok(Some(source_kind)) => source_kind,
150+
Ok(None) => {
151+
debug!("Skipping non-Python notebook: {path}");
152+
return;
153+
}
154+
Err(err) => {
155+
warn!("Failed to read source for {path}: {err}");
156+
return;
157+
}
158+
};
159+
160+
let source_code = source_kind.source_code();
161+
150162
// Identify any imports via static analysis.
151-
let mut imports =
152-
ModuleImports::detect(&db, &path, package.as_deref(), string_imports)
153-
.unwrap_or_else(|err| {
154-
warn!("Failed to generate import map for {path}: {err}");
155-
ModuleImports::default()
156-
});
163+
let mut imports = ModuleImports::detect(
164+
&db,
165+
source_code,
166+
source_type,
167+
&path,
168+
package.as_deref(),
169+
string_imports,
170+
)
171+
.unwrap_or_else(|err| {
172+
warn!("Failed to generate import map for {path}: {err}");
173+
ModuleImports::default()
174+
});
157175

158176
debug!("Discovered {} imports for {}", imports.len(), path);
159177

crates/ruff/tests/analyze_graph.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,3 +653,133 @@ fn venv() -> Result<()> {
653653

654654
Ok(())
655655
}
656+
657+
#[test]
658+
fn notebook_basic() -> Result<()> {
659+
let tempdir = TempDir::new()?;
660+
let root = ChildPath::new(tempdir.path());
661+
662+
root.child("ruff").child("__init__.py").write_str("")?;
663+
root.child("ruff")
664+
.child("a.py")
665+
.write_str(indoc::indoc! {r#"
666+
def helper():
667+
pass
668+
"#})?;
669+
670+
// Create a basic notebook with a simple import
671+
root.child("notebook.ipynb").write_str(indoc::indoc! {r#"
672+
{
673+
"cells": [
674+
{
675+
"cell_type": "code",
676+
"execution_count": null,
677+
"metadata": {},
678+
"outputs": [],
679+
"source": [
680+
"from ruff.a import helper"
681+
]
682+
}
683+
],
684+
"metadata": {
685+
"language_info": {
686+
"name": "python",
687+
"version": "3.12.0"
688+
}
689+
},
690+
"nbformat": 4,
691+
"nbformat_minor": 5
692+
}
693+
"#})?;
694+
695+
insta::with_settings!({
696+
filters => INSTA_FILTERS.to_vec(),
697+
}, {
698+
assert_cmd_snapshot!(command().current_dir(&root), @r###"
699+
success: true
700+
exit_code: 0
701+
----- stdout -----
702+
{
703+
"notebook.ipynb": [
704+
"ruff/a.py"
705+
],
706+
"ruff/__init__.py": [],
707+
"ruff/a.py": []
708+
}
709+
710+
----- stderr -----
711+
"###);
712+
});
713+
714+
Ok(())
715+
}
716+
717+
#[test]
718+
fn notebook_with_magic() -> Result<()> {
719+
let tempdir = TempDir::new()?;
720+
let root = ChildPath::new(tempdir.path());
721+
722+
root.child("ruff").child("__init__.py").write_str("")?;
723+
root.child("ruff")
724+
.child("a.py")
725+
.write_str(indoc::indoc! {r#"
726+
def helper():
727+
pass
728+
"#})?;
729+
730+
// Create a notebook with IPython magic commands and imports
731+
root.child("notebook.ipynb").write_str(indoc::indoc! {r#"
732+
{
733+
"cells": [
734+
{
735+
"cell_type": "code",
736+
"execution_count": null,
737+
"metadata": {},
738+
"outputs": [],
739+
"source": [
740+
"%load_ext autoreload\n",
741+
"%autoreload 2"
742+
]
743+
},
744+
{
745+
"cell_type": "code",
746+
"execution_count": null,
747+
"metadata": {},
748+
"outputs": [],
749+
"source": [
750+
"from ruff.a import helper"
751+
]
752+
}
753+
],
754+
"metadata": {
755+
"language_info": {
756+
"name": "python",
757+
"version": "3.12.0"
758+
}
759+
},
760+
"nbformat": 4,
761+
"nbformat_minor": 5
762+
}
763+
"#})?;
764+
765+
insta::with_settings!({
766+
filters => INSTA_FILTERS.to_vec(),
767+
}, {
768+
assert_cmd_snapshot!(command().current_dir(&root), @r###"
769+
success: true
770+
exit_code: 0
771+
----- stdout -----
772+
{
773+
"notebook.ipynb": [
774+
"ruff/a.py"
775+
],
776+
"ruff/__init__.py": [],
777+
"ruff/a.py": []
778+
}
779+
780+
----- stderr -----
781+
"###);
782+
});
783+
784+
Ok(())
785+
}

crates/ruff_graph/src/lib.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ use std::collections::{BTreeMap, BTreeSet};
33
use anyhow::Result;
44

55
use ruff_db::system::{SystemPath, SystemPathBuf};
6+
use ruff_python_ast::PySourceType;
67
use ruff_python_ast::helpers::to_module_path;
7-
use ruff_python_parser::{Mode, ParseOptions, parse};
8+
use ruff_python_parser::{ParseOptions, parse};
89

910
use crate::collector::Collector;
1011
pub use crate::db::ModuleDb;
@@ -24,13 +25,14 @@ impl ModuleImports {
2425
/// Detect the [`ModuleImports`] for a given Python file.
2526
pub fn detect(
2627
db: &ModuleDb,
28+
source: &str,
29+
source_type: PySourceType,
2730
path: &SystemPath,
2831
package: Option<&SystemPath>,
2932
string_imports: StringImports,
3033
) -> Result<Self> {
31-
// Read and parse the source code.
32-
let source = std::fs::read_to_string(path)?;
33-
let parsed = parse(&source, ParseOptions::from(Mode::Module))?;
34+
// Parse the source code.
35+
let parsed = parse(source, ParseOptions::from(source_type))?;
3436

3537
let module_path =
3638
package.and_then(|package| to_module_path(package.as_std_path(), path.as_std_path()));

0 commit comments

Comments
 (0)