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
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
strategy:
matrix:
python-version: [
"3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"
"3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "3.15t"
]
os: [ubuntu-latest, macos-latest, windows-latest]

Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ jobs:
3.13
3.14
3.14t
3.15
3.15t
allow-prereleases: true
- name: Build wheels
uses: PyO3/maturin-action@v1
Expand Down Expand Up @@ -81,6 +83,8 @@ jobs:
3.13
3.14
3.14t
3.15
3.15t
allow-prereleases: true
- name: Build wheels
uses: PyO3/maturin-action@v1
Expand Down Expand Up @@ -115,6 +119,8 @@ jobs:
3.13
3.14
3.14t
3.15
3.15t
allow-prereleases: true
architecture: ${{ matrix.platform.target }}
- name: Build wheels
Expand Down Expand Up @@ -149,6 +155,8 @@ jobs:
3.13
3.14
3.14t
3.15
3.15t
allow-prereleases: true
- name: Build wheels
uses: PyO3/maturin-action@v1
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ latest

* Fix panic error when scanning imports of nonexistent namespace children
(https://github.com/python-grimp/grimp/issues/308).
* Officially support freethreaded Python 3.14.
* Officially support freethreaded Python 3.14+.
* Officially support Python 3.15, including lazy imports.

3.15 (2026-07-03)
-----------------
Expand Down
2 changes: 2 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ test-all:
just test-python 3.13
just test-python 3.14
just test-python 3.14t
just test-python 3.15
just test-python 3.15t
just test-rust

# Populate missing Syrupy snapshots.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Rust",
"Topic :: Utilities",
Expand Down
20 changes: 10 additions & 10 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Empty file.
5 changes: 5 additions & 0 deletions tests/assets/lazyimports/one.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
lazy import lazyimports.two.blue
lazy from lazyimports import two
if TYPE_CHECKING:
lazy import lazyimports.two.green
lazy from lazyimports.three import SOME_CONSTANT
1 change: 1 addition & 0 deletions tests/assets/lazyimports/three.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SOME_CONSTANT = "some-constant"
Empty file.
Empty file.
Empty file.
20 changes: 20 additions & 0 deletions tests/functional/test_lazy_imports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import grimp


def test_build_graph_with_lazy_imports():
"""
Tests we can cope with lazy imports (Python 3.15+).

Under the hood we use ruff's parser, which understands lazy imports even
when this is running under a different Python version.
"""
graph = grimp.build_graph("lazyimports", cache_dir=None)

result = graph.find_modules_directly_imported_by("lazyimports.one")

assert {
"lazyimports.three",
"lazyimports.two",
"lazyimports.two.blue",
"lazyimports.two.green",
} == result
137 changes: 137 additions & 0 deletions tests/unit/application/test_scanning.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,143 @@ def test_absolute_imports(include_external_packages, expected_result):
assert {module_file_to_scan: expected_result} == result


def test_lazy_imports():
module_file_to_scan = _module_to_module_file(Module("foo.one.blue"))
all_modules = {
Module("foo"),
Module("foo.one"),
Module("foo.one.blue"),
Module("foo.one.green"),
Module("foo.two"),
Module("foo.two.brown"),
Module("foo.two.yellow"),
Module("foo.three"),
}
file_system = rust.FakeBasicFileSystem(
contents="""
/path/to/foo/
__init__.py
one/
__init__.py
blue.py
green.py
two/
__init__.py
brown.py
yellow.py
three.py
""",
content_map={
"/path/to/foo/one/blue.py": """
# Absolute no-from
lazy import foo.two
lazy import externalone
lazy import externaltwo.subpackage # with comment afterwards.
arbitrary_expression = 1

# Absolute from
lazy from foo.one import green
lazy from foo.two import yellow
if t.TYPE_CHECKING:
lazy from foo import three
lazy from external import one
lazy from external.two import blue # with comment afterwards.

# Relative from
lazy from . import green
lazy from ..two import yellow
lazy from .. import three
"""
},
)

with override_settings(FILE_SYSTEM=file_system):
result = scanning.scan_imports(
{module_file_to_scan},
found_packages={
FoundPackage(
name="foo",
directory="/path/to/foo",
module_files=_modules_to_module_files(all_modules),
)
},
include_external_packages=True,
exclude_type_checking_imports=False,
)

expected = {
module_file_to_scan: {
DirectImport(
importer=Module("foo.one.blue"),
imported=Module("foo.two"),
line_number=2,
line_contents="lazy import foo.two",
),
DirectImport(
importer=Module("foo.one.blue"),
imported=Module("externalone"),
line_number=3,
line_contents="lazy import externalone",
),
DirectImport(
importer=Module("foo.one.blue"),
imported=Module("externaltwo"),
line_number=4,
line_contents="lazy import externaltwo.subpackage # with comment afterwards.",
),
DirectImport(
importer=Module("foo.one.blue"),
imported=Module("foo.one.green"),
line_number=8,
line_contents="lazy from foo.one import green",
),
DirectImport(
importer=Module("foo.one.blue"),
imported=Module("foo.two.yellow"),
line_number=9,
line_contents="lazy from foo.two import yellow",
),
DirectImport(
importer=Module("foo.one.blue"),
imported=Module("foo.three"),
line_number=11,
line_contents="lazy from foo import three",
),
DirectImport(
importer=Module("foo.one.blue"),
imported=Module("external"),
line_number=12,
line_contents="lazy from external import one",
),
DirectImport(
importer=Module("foo.one.blue"),
imported=Module("external"),
line_number=13,
line_contents="lazy from external.two import blue # with comment afterwards.",
),
DirectImport(
importer=Module("foo.one.blue"),
imported=Module("foo.one.green"),
line_number=16,
line_contents="lazy from . import green",
),
DirectImport(
importer=Module("foo.one.blue"),
imported=Module("foo.two.yellow"),
line_number=17,
line_contents="lazy from ..two import yellow",
),
DirectImport(
importer=Module("foo.one.blue"),
imported=Module("foo.three"),
line_number=18,
line_contents="lazy from .. import three",
),
}
}
assert result == expected


def test_non_ascii():
blue_module = Module("mypackage.blue")
blue_module_file = _module_to_module_file(blue_module)
Expand Down
Loading