diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 278924b8..71ca7d20 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aead1ab7..f25a0352 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index aedaf2a6..b289cbc8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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) ----------------- diff --git a/justfile b/justfile index 6cb864a7..ea2930a4 100644 --- a/justfile +++ b/justfile @@ -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. diff --git a/pyproject.toml b/pyproject.toml index d2d72998..5dc727be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 577c474e..8e192b6e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -548,9 +548,9 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" dependencies = [ "libc", "once_cell", @@ -562,18 +562,18 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" dependencies = [ "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" dependencies = [ "libc", "pyo3-build-config", @@ -581,9 +581,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -593,9 +593,9 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.29.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" dependencies = [ "heck", "proc-macro2", diff --git a/tests/assets/lazyimports/__init__.py b/tests/assets/lazyimports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/assets/lazyimports/one.py b/tests/assets/lazyimports/one.py new file mode 100644 index 00000000..60de6eac --- /dev/null +++ b/tests/assets/lazyimports/one.py @@ -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 \ No newline at end of file diff --git a/tests/assets/lazyimports/three.py b/tests/assets/lazyimports/three.py new file mode 100644 index 00000000..09922488 --- /dev/null +++ b/tests/assets/lazyimports/three.py @@ -0,0 +1 @@ +SOME_CONSTANT = "some-constant" \ No newline at end of file diff --git a/tests/assets/lazyimports/two/__init__.py b/tests/assets/lazyimports/two/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/assets/lazyimports/two/blue.py b/tests/assets/lazyimports/two/blue.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/assets/lazyimports/two/green.py b/tests/assets/lazyimports/two/green.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/functional/test_lazy_imports.py b/tests/functional/test_lazy_imports.py new file mode 100644 index 00000000..e364454b --- /dev/null +++ b/tests/functional/test_lazy_imports.py @@ -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 diff --git a/tests/unit/application/test_scanning.py b/tests/unit/application/test_scanning.py index bdd55516..b63bcd15 100644 --- a/tests/unit/application/test_scanning.py +++ b/tests/unit/application/test_scanning.py @@ -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)