From fb06e5709709d5290753be67b28f587b1fe41af2 Mon Sep 17 00:00:00 2001 From: David Seddon Date: Fri, 7 Aug 2026 16:02:42 +0100 Subject: [PATCH 1/2] Populate is_lazy in DirectImport --- rust/src/caching.rs | 1 + rust/src/import_parsing.rs | 5 +++++ rust/src/import_scanning.rs | 8 ++++++++ src/grimp/domain/valueobjects.py | 5 ++++- tests/unit/application/test_scanning.py | 11 +++++++++++ tests/unit/domain/test_valueobjects.py | 9 +++++++++ 6 files changed, 38 insertions(+), 1 deletion(-) diff --git a/rust/src/caching.rs b/rust/src/caching.rs index 1a0d2531..c6fbcbb9 100644 --- a/rust/src/caching.rs +++ b/rust/src/caching.rs @@ -118,6 +118,7 @@ pub fn parse_json_to_map( imported, line_number, line_contents, + is_lazy: false, // TODO get working with cache. }) .collect(); parsed_map.insert(module, import_set); diff --git a/rust/src/import_parsing.rs b/rust/src/import_parsing.rs index 2ebe036b..2d2fe0ac 100644 --- a/rust/src/import_parsing.rs +++ b/rust/src/import_parsing.rs @@ -10,6 +10,7 @@ pub struct ImportedObject { pub line_number: usize, pub line_contents: String, pub typechecking_only: bool, + pub is_lazy: bool, } impl ImportedObject { @@ -18,12 +19,14 @@ impl ImportedObject { line_number: usize, line_contents: String, typechecking_only: bool, + is_lazy: bool, ) -> Self { Self { name, line_number, line_contents, typechecking_only, + is_lazy, } } } @@ -84,6 +87,7 @@ impl<'a> StatementVisitor<'a> for Visitor<'a> { line_number.get(), self.source_code.line_text(line_number).trim().to_string(), self.typechecking_only, + import_stmt.is_lazy, )) } walk_stmt(self, stmt); @@ -113,6 +117,7 @@ impl<'a> StatementVisitor<'a> for Visitor<'a> { line_number.get(), self.source_code.line_text(line_number).trim().to_string(), self.typechecking_only, + import_from_stmt.is_lazy, )) } walk_stmt(self, stmt); diff --git a/rust/src/import_scanning.rs b/rust/src/import_scanning.rs index f03ea81d..0d494ac2 100644 --- a/rust/src/import_scanning.rs +++ b/rust/src/import_scanning.rs @@ -16,6 +16,7 @@ pub struct DirectImport { pub imported: String, pub line_number: usize, pub line_contents: String, + pub is_lazy: bool, } impl<'a, 'py> FromPyObject<'a, 'py> for DirectImport { @@ -26,12 +27,14 @@ impl<'a, 'py> FromPyObject<'a, 'py> for DirectImport { let imported: String = ob.getattr("imported")?.getattr("name")?.extract()?; let line_number: usize = ob.getattr("line_number")?.extract()?; let line_contents: String = ob.getattr("line_contents")?.extract()?; + let is_lazy: bool = ob.getattr("is_lazy")?.extract()?; Ok(DirectImport { importer, imported, line_number, line_contents, + is_lazy, }) } } @@ -152,6 +155,7 @@ fn scan_for_imports_no_py_single_module( imported: imported_module.name.to_string(), line_number: imported_object.line_number, line_contents: imported_object.line_contents, + is_lazy: imported_object.is_lazy, }); } None => { @@ -165,6 +169,7 @@ fn scan_for_imports_no_py_single_module( imported: imported_module, line_number: imported_object.line_number, line_contents: imported_object.line_contents, + is_lazy: imported_object.is_lazy, }); } } @@ -196,6 +201,9 @@ fn to_py_direct_imports<'a>( kwargs .set_item("line_contents", &rust_import.line_contents) .unwrap(); + kwargs + .set_item("is_lazy", &rust_import.is_lazy) + .unwrap(); let py_direct_import = py_direct_import_class.call((), Some(&kwargs)).unwrap(); pyset.add(&py_direct_import).unwrap(); } diff --git a/src/grimp/domain/valueobjects.py b/src/grimp/domain/valueobjects.py index bdc050fb..d6ee23bb 100644 --- a/src/grimp/domain/valueobjects.py +++ b/src/grimp/domain/valueobjects.py @@ -55,9 +55,12 @@ class DirectImport: imported: Module line_number: int line_contents: str + # Import in the form `lazy import` or `lazy from import`. + is_lazy: bool = False def __str__(self) -> str: - return f"{self.importer} -> {self.imported} (l. {self.line_number})" + lazy_label = ", lazy" if self.is_lazy else "" + return f"{self.importer} -> {self.imported} (l. {self.line_number}{lazy_label})" @dataclass(frozen=True, order=True) diff --git a/tests/unit/application/test_scanning.py b/tests/unit/application/test_scanning.py index b63bcd15..a510e0b3 100644 --- a/tests/unit/application/test_scanning.py +++ b/tests/unit/application/test_scanning.py @@ -152,66 +152,77 @@ def test_lazy_imports(): imported=Module("foo.two"), line_number=2, line_contents="lazy import foo.two", + is_lazy=True, ), DirectImport( importer=Module("foo.one.blue"), imported=Module("externalone"), line_number=3, line_contents="lazy import externalone", + is_lazy=True, ), DirectImport( importer=Module("foo.one.blue"), imported=Module("externaltwo"), line_number=4, line_contents="lazy import externaltwo.subpackage # with comment afterwards.", + is_lazy=True, ), DirectImport( importer=Module("foo.one.blue"), imported=Module("foo.one.green"), line_number=8, line_contents="lazy from foo.one import green", + is_lazy=True, ), DirectImport( importer=Module("foo.one.blue"), imported=Module("foo.two.yellow"), line_number=9, line_contents="lazy from foo.two import yellow", + is_lazy=True, ), DirectImport( importer=Module("foo.one.blue"), imported=Module("foo.three"), line_number=11, line_contents="lazy from foo import three", + is_lazy=True, ), DirectImport( importer=Module("foo.one.blue"), imported=Module("external"), line_number=12, line_contents="lazy from external import one", + is_lazy=True, ), DirectImport( importer=Module("foo.one.blue"), imported=Module("external"), line_number=13, line_contents="lazy from external.two import blue # with comment afterwards.", + is_lazy=True, ), DirectImport( importer=Module("foo.one.blue"), imported=Module("foo.one.green"), line_number=16, line_contents="lazy from . import green", + is_lazy=True, ), DirectImport( importer=Module("foo.one.blue"), imported=Module("foo.two.yellow"), line_number=17, line_contents="lazy from ..two import yellow", + is_lazy=True, ), DirectImport( importer=Module("foo.one.blue"), imported=Module("foo.three"), line_number=18, line_contents="lazy from .. import three", + is_lazy=True, ), } } diff --git a/tests/unit/domain/test_valueobjects.py b/tests/unit/domain/test_valueobjects.py index c6a07cd4..e66c0570 100644 --- a/tests/unit/domain/test_valueobjects.py +++ b/tests/unit/domain/test_valueobjects.py @@ -33,6 +33,15 @@ def test_str(self): line_contents="import bar", ) assert str(import_path) == "foo -> bar (l. 10)" + def test_lazy_str(self): + import_path = DirectImport( + importer=Module("foo"), + imported=Module("bar"), + line_number=10, + line_contents="import bar", + is_lazy=True, + ) + assert str(import_path) == "foo -> bar (l. 10, lazy)" class TestLayer: From 35a7b0a1540d66c9c2b9ea3f06aa84c7549103cc Mon Sep 17 00:00:00 2001 From: David Seddon Date: Fri, 7 Aug 2026 17:14:53 +0100 Subject: [PATCH 2/2] WIP Include is_lazy in import details --- docs/usage.rst | 6 +++-- rust/src/caching.rs | 2 +- rust/src/graph/graph_manipulation.rs | 3 ++- rust/src/graph/mod.rs | 26 +++++++++++++------ rust/src/import_scanning.rs | 4 +-- src/grimp/application/graph.py | 4 +++ src/grimp/application/usecases.py | 1 + tests/functional/test_build_and_use_graph.py | 1 + ...build_and_use_graph_with_multiple_roots.py | 2 ++ tests/functional/test_caching.py | 2 ++ tests/functional/test_encoding_handling.py | 2 ++ tests/functional/test_lazy_imports.py | 10 +++++++ tests/unit/application/graph/test_chains.py | 1 + tests/unit/application/graph/test_copy.py | 1 + .../application/graph/test_direct_imports.py | 3 +++ .../application/graph/test_manipulation.py | 5 ++++ tests/unit/domain/test_valueobjects.py | 1 + 17 files changed, 59 insertions(+), 15 deletions(-) diff --git a/docs/usage.rst b/docs/usage.rst index 169ef18c..71cef0c8 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -164,7 +164,7 @@ Methods for analysing direct imports This method should not be used to determine whether an import is present: some of the imports in the graph may have no available metadata. For example, if an import - has been added by the ``add_import`` method without the ``line_number`` and ``line_contents`` specified, then + has been added by the ``add_import`` method without the optional arguments, then calling this method on the import will return an empty list. If you want to know whether the import is present, use ``direct_import_exists``. @@ -174,6 +174,7 @@ Methods for analysing direct imports { 'importer': 'mypackage.importer', 'imported': 'mypackage.imported', + 'is_lazy': False, 'line_number': 5, 'line_contents': 'from mypackage import imported', }, @@ -560,13 +561,14 @@ Methods for manipulating the graph :param str module: The name of a module, for example ``'mypackage.foo'``. :return: None -.. py:function:: ImportGraph.add_import(importer, imported, line_number=None, line_contents=None) +.. py:function:: ImportGraph.add_import(importer, imported, is_lazy=False, line_number=None, line_contents=None) Add a direct import between two modules to the graph. If the modules are not already present, they will be added to the graph. :param str importer: The name of the module that is importing the other module. :param str imported: The name of the module being imported. + :param bool is_lazy: Whether the import is an explicit lazy import. :param int line_number: The line number of the import statement in the module. :param str line_contents: The line that contains the import statement. :return: None diff --git a/rust/src/caching.rs b/rust/src/caching.rs index c6fbcbb9..3d82034f 100644 --- a/rust/src/caching.rs +++ b/rust/src/caching.rs @@ -118,7 +118,7 @@ pub fn parse_json_to_map( imported, line_number, line_contents, - is_lazy: false, // TODO get working with cache. + is_lazy: false, // TODO get working with cache. }) .collect(); parsed_map.insert(module, import_set); diff --git a/rust/src/graph/graph_manipulation.rs b/rust/src/graph/graph_manipulation.rs index 88c1e3aa..b6c762c3 100644 --- a/rust/src/graph/graph_manipulation.rs +++ b/rust/src/graph/graph_manipulation.rs @@ -120,6 +120,7 @@ impl Graph { imported: ModuleToken, line_number: u32, line_contents: &str, + is_lazy: bool, ) { self.imports .entry(importer) @@ -137,7 +138,7 @@ impl Graph { self.import_details .entry((importer, imported)) .or_default() - .insert(PyImportDetails::new(line_number, line_contents)); + .insert(PyImportDetails::new(line_number, line_contents, is_lazy)); } } diff --git a/rust/src/graph/mod.rs b/rust/src/graph/mod.rs index 822184b5..4051dd4e 100644 --- a/rust/src/graph/mod.rs +++ b/rust/src/graph/mod.rs @@ -264,27 +264,31 @@ impl GraphWrapper { Ok(self.get_visible_module_by_name(module)?.is_squashed()) } - #[pyo3(signature = (*, importer, imported, line_number=None, line_contents=None))] + #[pyo3(signature = (*, importer, imported, is_lazy=None, line_number=None, line_contents=None))] pub fn add_import( &mut self, importer: &str, imported: &str, + is_lazy: Option, line_number: Option, line_contents: Option<&str>, ) { let importer = self._graph.get_or_add_module(importer).token(); let imported = self._graph.get_or_add_module(imported).token(); - match (line_number, line_contents) { - (Some(line_number), Some(line_contents)) => { - self._graph - .add_detailed_import(importer, imported, line_number, line_contents) - } - (None, None) => { + match (is_lazy, line_number, line_contents) { + (Some(is_lazy), Some(line_number), Some(line_contents)) => self._graph.add_detailed_import( + importer, + imported, + line_number, + line_contents, + is_lazy, + ), + (None, None, None) => { self._graph.add_import(importer, imported); } _ => { // TODO handle better. - panic!("Expected line_number and line_contents, or neither."); + panic!("If any of is_lazy, line_number and line_contents are provided, they all must be provided."); } } } @@ -402,6 +406,7 @@ impl GraphWrapper { imported.name(), import_details.line_number(), import_details.line_contents(), + import_details.is_lazy(), ) }) .sorted() @@ -417,6 +422,7 @@ impl GraphWrapper { "line_contents", import_details.line_contents.into_py_any(py).unwrap(), ), + ("is_lazy", import_details.is_lazy.into_py_any(py).unwrap()), ] .into_py_dict(py) .unwrap() @@ -667,6 +673,7 @@ struct ImportDetails { imported: String, line_number: u32, line_contents: String, + is_lazy: bool, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, new)] @@ -754,6 +761,9 @@ pub struct PyImportDetails { #[getset(get_copy = "pub")] interned_line_contents: DefaultSymbol, + + #[getset(get_copy = "pub")] + is_lazy: bool, } impl PyImportDetails { diff --git a/rust/src/import_scanning.rs b/rust/src/import_scanning.rs index 0d494ac2..033d3526 100644 --- a/rust/src/import_scanning.rs +++ b/rust/src/import_scanning.rs @@ -201,9 +201,7 @@ fn to_py_direct_imports<'a>( kwargs .set_item("line_contents", &rust_import.line_contents) .unwrap(); - kwargs - .set_item("is_lazy", &rust_import.is_lazy) - .unwrap(); + kwargs.set_item("is_lazy", rust_import.is_lazy).unwrap(); let py_direct_import = py_direct_import_class.call((), Some(&kwargs)).unwrap(); pyset.add(&py_direct_import).unwrap(); } diff --git a/src/grimp/application/graph.py b/src/grimp/application/graph.py index e50c9682..288f549c 100644 --- a/src/grimp/application/graph.py +++ b/src/grimp/application/graph.py @@ -27,6 +27,7 @@ class Import(TypedDict): class DetailedImport(Import): line_number: int line_contents: str + is_lazy: bool class ImportGraph: @@ -138,6 +139,7 @@ def add_import( imported: str, line_number: int | None = None, line_contents: str | None = None, + is_lazy: bool | None = None, ) -> None: """ Add a direct import between two modules to the graph. If the modules are not already @@ -149,6 +151,7 @@ def add_import( imported=imported, line_number=line_number, line_contents=line_contents, + is_lazy=is_lazy, ) def remove_import(self, *, importer: str, imported: str) -> None: @@ -249,6 +252,7 @@ def get_import_details(self, *, importer: str, imported: str) -> list[DetailedIm 'imported': 'mypackage.imported', 'line_number': 5, 'line_contents': 'from mypackage import imported', + 'is_lazy': False, }, (additional imports here) ] diff --git a/src/grimp/application/usecases.py b/src/grimp/application/usecases.py index 1e2fec61..34c4e934 100644 --- a/src/grimp/application/usecases.py +++ b/src/grimp/application/usecases.py @@ -184,6 +184,7 @@ def _assemble_graph( imported=direct_import.imported.name, line_number=direct_import.line_number, line_contents=direct_import.line_contents, + is_lazy=direct_import.is_lazy, ) return graph diff --git a/tests/functional/test_build_and_use_graph.py b/tests/functional/test_build_and_use_graph.py index 1d2f603a..bf365b11 100644 --- a/tests/functional/test_build_and_use_graph.py +++ b/tests/functional/test_build_and_use_graph.py @@ -162,6 +162,7 @@ def test_get_import_details(): { "importer": "testpackage.utils", "imported": "testpackage.two.alpha", + "is_lazy": False, "line_number": 5, "line_contents": "from .two import alpha", } diff --git a/tests/functional/test_build_and_use_graph_with_multiple_roots.py b/tests/functional/test_build_and_use_graph_with_multiple_roots.py index d8fda118..13a23f7f 100644 --- a/tests/functional/test_build_and_use_graph_with_multiple_roots.py +++ b/tests/functional/test_build_and_use_graph_with_multiple_roots.py @@ -46,6 +46,7 @@ def test_stores_import_within_package(self, root_packages): { "importer": "rootpackageblue.two", "imported": "rootpackageblue.one.alpha", + "is_lazy": False, "line_number": 1, "line_contents": "from .one.alpha import BAR", } @@ -60,6 +61,7 @@ def test_stores_import_between_root_packages(self, root_packages): { "importer": "rootpackagegreen.two", "imported": "rootpackageblue.one.alpha", + "is_lazy": False, "line_number": 1, "line_contents": "from rootpackageblue.one import alpha", } diff --git a/tests/functional/test_caching.py b/tests/functional/test_caching.py index f69ea8aa..cb8cb599 100644 --- a/tests/functional/test_caching.py +++ b/tests/functional/test_caching.py @@ -49,6 +49,7 @@ def test_build_graph_uses_cache(copied_cachingpackage): { "importer": "cachingpackage.two.alpha", "imported": "cachingpackage.one.alpha", + "is_lazy": False, "line_contents": "from ..one import alpha", "line_number": 1, }, @@ -80,6 +81,7 @@ def test_build_graph_uses_cache(copied_cachingpackage): { "importer": "cachingpackage.two.alpha", "imported": "cachingpackage.one.alpha", + "is_lazy": False, "line_contents": replacement, "line_number": 1, }, diff --git a/tests/functional/test_encoding_handling.py b/tests/functional/test_encoding_handling.py index 21a8f5a2..f1c148ef 100644 --- a/tests/functional/test_encoding_handling.py +++ b/tests/functional/test_encoding_handling.py @@ -15,6 +15,7 @@ def test_build_graph_of_non_ascii_source(): { "importer": "encodingpackage.importer", "imported": "encodingpackage.imported", + "is_lazy": False, "line_number": 1, "line_contents": "from .imported import π", }, @@ -35,6 +36,7 @@ def test_build_graph_of_non_utf8_source(): { "importer": "encodingpackage.shift_jis_importer", "imported": "encodingpackage.imported", + "is_lazy": False, "line_number": 3, "line_contents": "from .imported import π", }, diff --git a/tests/functional/test_lazy_imports.py b/tests/functional/test_lazy_imports.py index e364454b..d12ac714 100644 --- a/tests/functional/test_lazy_imports.py +++ b/tests/functional/test_lazy_imports.py @@ -18,3 +18,13 @@ def test_build_graph_with_lazy_imports(): "lazyimports.two.blue", "lazyimports.two.green", } == result + # Spot check that one is stored as is_lazy. + assert [ + { + "importer": "lazyimports.one", + "imported": "lazyimports.two", + "is_lazy": True, + "line_number": 2, + "line_contents": "lazy from lazyimports import two", + } + ] == graph.get_import_details(importer="lazyimports.one", imported="lazyimports.two") diff --git a/tests/unit/application/graph/test_chains.py b/tests/unit/application/graph/test_chains.py index 4f960c1e..63047a51 100644 --- a/tests/unit/application/graph/test_chains.py +++ b/tests/unit/application/graph/test_chains.py @@ -517,6 +517,7 @@ def test_doesnt_lose_import_details(self, as_packages: bool): { "importer": "green.foo", "imported": "blue.bar", + "is_lazy": False, "line_contents": "import blue.bar", "line_number": 5, } diff --git a/tests/unit/application/graph/test_copy.py b/tests/unit/application/graph/test_copy.py index f563f6b3..5359fa60 100644 --- a/tests/unit/application/graph/test_copy.py +++ b/tests/unit/application/graph/test_copy.py @@ -21,6 +21,7 @@ def test_removing_import_doesnt_affect_copy(self): { "importer": "foo", "imported": "bar", + "is_lazy": False, "line_number": 3, "line_contents": "import bar", } diff --git a/tests/unit/application/graph/test_direct_imports.py b/tests/unit/application/graph/test_direct_imports.py index dcf58f4e..d7d724a3 100644 --- a/tests/unit/application/graph/test_direct_imports.py +++ b/tests/unit/application/graph/test_direct_imports.py @@ -230,12 +230,14 @@ def test_happy_path(self): dict( importer="mypackage.foo", imported="mypackage.bar", + is_lazy=False, line_number=1, line_contents="from . import bar", ), dict( importer="mypackage.foo", imported="mypackage.bar", + is_lazy=True, line_number=10, line_contents="from .bar import a_function", ), @@ -267,6 +269,7 @@ def test_returns_only_relevant_imports(self): dict( importer="mypackage.foo", imported="mypackage.bar", + is_lazy=False, line_number=1, line_contents="from . import bar", ) diff --git a/tests/unit/application/graph/test_manipulation.py b/tests/unit/application/graph/test_manipulation.py index d4854855..563bf8a0 100644 --- a/tests/unit/application/graph/test_manipulation.py +++ b/tests/unit/application/graph/test_manipulation.py @@ -48,6 +48,7 @@ def test_removes_module_removes_import_details_for_imported(self): { "importer": a, "imported": c, + "is_lazy": False, "line_contents": "import mypackage.yellow", "line_number": 2, } @@ -87,6 +88,7 @@ def test_removes_module_removes_import_details_for_importer(self): { "importer": a, "imported": c, + "is_lazy": False, "line_contents": "import mypackage.yellow", "line_number": 2, } @@ -207,6 +209,7 @@ def test_removes_from_import_details(self): { "importer": a, "imported": c, + "is_lazy": False, "line_contents": "import mypackage.yellow", "line_number": 2, } @@ -326,6 +329,7 @@ def test_import_details_from_squashed_root_are_preserved(self): import_details = dict( importer="foo", imported="bar.blue", + is_lazy=False, line_number=1, line_contents="from . import bar", ) @@ -346,6 +350,7 @@ def test_import_details_to_squashed_root_are_preserved(self): import_details = dict( importer="bar.blue", imported="foo", + is_lazy=False, line_number=1, line_contents="from . import foo", ) diff --git a/tests/unit/domain/test_valueobjects.py b/tests/unit/domain/test_valueobjects.py index e66c0570..827fe29c 100644 --- a/tests/unit/domain/test_valueobjects.py +++ b/tests/unit/domain/test_valueobjects.py @@ -33,6 +33,7 @@ def test_str(self): line_contents="import bar", ) assert str(import_path) == "foo -> bar (l. 10)" + def test_lazy_str(self): import_path = DirectImport( importer=Module("foo"),