├── .git-blame-ignore-revs ├── .github ├── dependabot.yml └── workflows │ ├── coverage.yml │ ├── label-check.yaml │ ├── milestone-merged-prs.yaml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .spin └── cmds.py ├── CHANGELOG.md ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── RELEASE.md ├── SECURITY.md ├── pyproject.toml ├── src └── lazy_loader │ └── __init__.py └── tests ├── __init__.py ├── fake_pkg ├── __init__.py ├── __init__.pyi └── some_func.py ├── import_np_parallel.py └── test_lazy_loader.py /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | 1f423a22d8b27926da7d7b7393c833da0f3714a4 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | labels: 8 | - "type: Maintenance" 9 | groups: 10 | actions: 11 | patterns: 12 | - "*" 13 | - package-ecosystem: "pip" 14 | directory: "/" 15 | schedule: 16 | interval: "monthly" 17 | labels: 18 | - "type: Maintenance" 19 | groups: 20 | actions: 21 | patterns: 22 | - "*" 23 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | report: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.10"] 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - name: Install packages 23 | run: | 24 | python -m pip install --upgrade pip wheel setuptools spin 25 | python -m pip install ".[test]" 26 | python -m pip install --upgrade numpy 27 | python -m pip uninstall --yes scipy 28 | pip list 29 | 30 | - name: Measure test coverage 31 | run: | 32 | spin test -c -- --durations=10 33 | # Tests fail if using `--doctest-modules`. I.e., 34 | # spin test -- --doctest-modules 35 | 36 | - name: Upload coverage to Codecov 37 | uses: codecov/codecov-action@v5 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/label-check.yaml: -------------------------------------------------------------------------------- 1 | name: Labels 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - reopened 8 | - labeled 9 | - unlabeled 10 | - synchronize 11 | 12 | env: 13 | LABELS: ${{ join( github.event.pull_request.labels.*.name, ' ' ) }} 14 | 15 | jobs: 16 | check-type-label: 17 | name: ensure type label 18 | runs-on: ubuntu-latest 19 | steps: 20 | - if: "contains( env.LABELS, 'type: ' ) == false" 21 | run: exit 1 22 | -------------------------------------------------------------------------------- /.github/workflows/milestone-merged-prs.yaml: -------------------------------------------------------------------------------- 1 | name: Milestone 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - closed 7 | branches: 8 | - "main" 9 | 10 | jobs: 11 | milestone_pr: 12 | name: attach to PR 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: scientific-python/attach-next-milestone-action@c9cfab10ad0c67fed91b01103db26b7f16634639 16 | with: 17 | token: ${{ secrets.MILESTONE_LABELER_TOKEN }} 18 | force: true 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build Wheel and Release 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | 7 | jobs: 8 | pypi-publish: 9 | name: upload release to PyPI 10 | runs-on: ubuntu-latest 11 | # Specifying a GitHub environment is optional, but strongly encouraged 12 | environment: release 13 | permissions: 14 | # IMPORTANT: this permission is mandatory for trusted publishing 15 | id-token: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - uses: actions/setup-python@v5 22 | name: Install Python 23 | with: 24 | python-version: "3.11" 25 | 26 | - name: Build wheels 27 | run: | 28 | git clean -fxd 29 | pip install -U build twine wheel spin 30 | spin sdist -- --wheel 31 | 32 | - name: Publish package distributions to PyPI 33 | uses: pypa/gh-action-pypi-publish@release/v1 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [workflow_dispatch, push, pull_request] 4 | 5 | concurrency: 6 | group: ${{ github.workflow }}-${{ github.ref }} 7 | cancel-in-progress: true 8 | 9 | jobs: 10 | default: 11 | runs-on: ${{ matrix.os }}-latest 12 | strategy: 13 | matrix: 14 | os: [ubuntu, macos, windows] 15 | python-version: 16 | ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.9", "pypy-3.10"] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | cache: "pip" 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip spin 29 | python -m pip install ".[test]" 30 | 31 | - name: Test 32 | run: | 33 | spin test 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | # Byte-compiled / optimized / DLL files 3 | **/__pycache__ 4 | *.py[cod] 5 | 6 | # Distribution / packaging 7 | dist/ 8 | build/ 9 | lazy_loader.egg-info/ 10 | 11 | # Unit test / coverage reports 12 | .pytest_cache/ 13 | 14 | # General 15 | .DS_Store 16 | 17 | # Environments 18 | .env 19 | .venv 20 | env/ 21 | venv/ 22 | ENV/ 23 | env.bak/ 24 | venv.bak/ 25 | 26 | # IntelliJ project files 27 | .idea 28 | 29 | # Spyder project settings 30 | .spyderproject 31 | .spyproject 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # Install pre-commit hooks via 2 | # pre-commit install 3 | 4 | ci: 5 | autofix_prs: false 6 | autofix_commit_msg: | 7 | '[pre-commit.ci 🤖] Apply code format tools to PR' 8 | autoupdate_schedule: quarterly 9 | 10 | repos: 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b # frozen: v5.0.0 13 | hooks: 14 | - id: trailing-whitespace 15 | - id: end-of-file-fixer 16 | - id: debug-statements 17 | - id: check-ast 18 | - id: mixed-line-ending 19 | - id: check-yaml 20 | args: [--allow-multiple-documents] 21 | - id: check-json 22 | - id: check-toml 23 | - id: check-added-large-files 24 | 25 | - repo: https://github.com/rbubley/mirrors-prettier 26 | rev: 787fb9f542b140ba0b2aced38e6a3e68021647a3 # frozen: v3.5.3 27 | hooks: 28 | - id: prettier 29 | files: \.(html|md|yml|yaml|toml) 30 | args: [--prose-wrap=preserve] 31 | 32 | - repo: https://github.com/astral-sh/ruff-pre-commit 33 | rev: 971923581912ef60a6b70dbf0c3e9a39563c9d47 # frozen: v0.11.4 34 | hooks: 35 | - id: ruff 36 | args: ["--fix", "--show-fixes", "--exit-non-zero-on-fix"] 37 | - id: ruff-format 38 | 39 | - repo: https://github.com/codespell-project/codespell 40 | rev: "63c8f8312b7559622c0d82815639671ae42132ac" # frozen: v2.4.1 41 | hooks: 42 | - id: codespell 43 | -------------------------------------------------------------------------------- /.spin/cmds.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import sys 3 | import textwrap 4 | 5 | import click 6 | from spin.cmds.util import run 7 | 8 | 9 | @click.command() 10 | @click.argument("pytest_args", nargs=-1) 11 | @click.option( 12 | "-c", 13 | "--coverage", 14 | is_flag=True, 15 | help="Generate a coverage report of executed tests.", 16 | ) 17 | def test(pytest_args, coverage=False): 18 | """🔧 Run tests""" 19 | if not importlib.util.find_spec("lazy_loader"): 20 | click.secho( 21 | textwrap.dedent("""\ 22 | ERROR: The package is not installed. 23 | 24 | Please do an editable install: 25 | 26 | pip install -e .[test] 27 | 28 | prior to running the tests."""), 29 | fg="red", 30 | ) 31 | sys.exit(1) 32 | 33 | if coverage: 34 | pytest_args = ("--cov=lazy_loader", *pytest_args) 35 | run([sys.executable, "-m", "pytest", *list(pytest_args)]) 36 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # lazy-loader 0.4 2 | 3 | We're happy to announce the release of lazy-loader 0.4! 4 | 5 | ## Enhancements 6 | 7 | - ENH: Add require argument to load() to accept version specifiers ([#48](https://github.com/scientific-python/lazy-loader/pull/48)). 8 | - Add version as **version** ([#97](https://github.com/scientific-python/lazy-loader/pull/97)). 9 | 10 | ## Bug Fixes 11 | 12 | - Avoid exception when \_\_frame_data["code_context"] is None ([#83](https://github.com/scientific-python/lazy-loader/pull/83)). 13 | - Make `lazy_load.load` partially thread-safe ([#90](https://github.com/scientific-python/lazy-loader/pull/90)). 14 | 15 | ## Documentation 16 | 17 | - Add security contact ([#91](https://github.com/scientific-python/lazy-loader/pull/91)). 18 | - Recommend newer Python versions to avoid race ([#102](https://github.com/scientific-python/lazy-loader/pull/102)). 19 | 20 | ## Maintenance 21 | 22 | - Use label-check and attach-next-milestone-action ([#64](https://github.com/scientific-python/lazy-loader/pull/64)). 23 | - Use setuptools ([#65](https://github.com/scientific-python/lazy-loader/pull/65)). 24 | - Specify what goes in sdist ([#66](https://github.com/scientific-python/lazy-loader/pull/66)). 25 | - Use changelist ([#67](https://github.com/scientific-python/lazy-loader/pull/67)). 26 | - Used dependabot ([#68](https://github.com/scientific-python/lazy-loader/pull/68)). 27 | - Bump pre-commit from 3.3 to 3.3.3 ([#69](https://github.com/scientific-python/lazy-loader/pull/69)). 28 | - Bump scientific-python/attach-next-milestone-action from f94a5235518d4d34911c41e19d780b8e79d42238 to a4889cfde7d2578c1bc7400480d93910d2dd34f6 ([#72](https://github.com/scientific-python/lazy-loader/pull/72)). 29 | - Bump scientific-python/attach-next-milestone-action from a4889cfde7d2578c1bc7400480d93910d2dd34f6 to bc07be829f693829263e57d5e8489f4e57d3d420 ([#74](https://github.com/scientific-python/lazy-loader/pull/74)). 30 | - Bump actions/checkout from 3 to 4 ([#75](https://github.com/scientific-python/lazy-loader/pull/75)). 31 | - Bump changelist from 0.1 to 0.3 ([#77](https://github.com/scientific-python/lazy-loader/pull/77)). 32 | - Bump pre-commit from 3.3.3 to 3.4.0 ([#76](https://github.com/scientific-python/lazy-loader/pull/76)). 33 | - Use trusted publisher ([#78](https://github.com/scientific-python/lazy-loader/pull/78)). 34 | - Bump pre-commit from 3.4.0 to 3.5.0 ([#80](https://github.com/scientific-python/lazy-loader/pull/80)). 35 | - Bump changelist from 0.3 to 0.4 ([#81](https://github.com/scientific-python/lazy-loader/pull/81)). 36 | - Bump actions/checkout from 3 to 4 ([#82](https://github.com/scientific-python/lazy-loader/pull/82)). 37 | - Bump actions/setup-python from 4 to 5 ([#85](https://github.com/scientific-python/lazy-loader/pull/85)). 38 | - Bump pre-commit from 3.5.0 to 3.6.0 ([#84](https://github.com/scientific-python/lazy-loader/pull/84)). 39 | - Update pre-commit ([#87](https://github.com/scientific-python/lazy-loader/pull/87)). 40 | - Use setup-python pip cache ([#95](https://github.com/scientific-python/lazy-loader/pull/95)). 41 | - Bump codecov/codecov-action from 3 to 4 ([#93](https://github.com/scientific-python/lazy-loader/pull/93)). 42 | - Bump pre-commit from 3.6.0 to 3.6.2 ([#100](https://github.com/scientific-python/lazy-loader/pull/100)). 43 | - Bump changelist from 0.4 to 0.5 ([#99](https://github.com/scientific-python/lazy-loader/pull/99)). 44 | - Refuse star imports in stub loader ([#101](https://github.com/scientific-python/lazy-loader/pull/101)). 45 | - Bump pre-commit from 3.6.2 to 3.7.0 ([#103](https://github.com/scientific-python/lazy-loader/pull/103)). 46 | - Update pre-commit repos ([#104](https://github.com/scientific-python/lazy-loader/pull/104)). 47 | 48 | ## Contributors 49 | 50 | 4 authors added to this release (alphabetically): 51 | 52 | - Chris Markiewicz ([@effigies](https://github.com/effigies)) 53 | - Dan Schult ([@dschult](https://github.com/dschult)) 54 | - Jarrod Millman ([@jarrodmillman](https://github.com/jarrodmillman)) 55 | - Stefan van der Walt ([@stefanv](https://github.com/stefanv)) 56 | 57 | 5 reviewers added to this release (alphabetically): 58 | 59 | - Brigitta Sipőcz ([@bsipocz](https://github.com/bsipocz)) 60 | - Chris Markiewicz ([@effigies](https://github.com/effigies)) 61 | - Dan Schult ([@dschult](https://github.com/dschult)) 62 | - Jarrod Millman ([@jarrodmillman](https://github.com/jarrodmillman)) 63 | - Stefan van der Walt ([@stefanv](https://github.com/stefanv)) 64 | 65 | _These lists are automatically generated, and may not be complete or may contain 66 | duplicates._ 67 | 68 | # Changelog 69 | 70 | ## [v0.3](https://github.com/scientific-python/lazy-loader/tree/v0.3) (2023-06-30) 71 | 72 | [Full Changelog](https://github.com/scientific-python/lazy-loader/compare/v0.2...v0.3) 73 | 74 | **Merged pull requests:** 75 | 76 | - Announce Python 3.12 support [\#63](https://github.com/scientific-python/lazy-loader/pull/63) ([jarrodmillman](https://github.com/jarrodmillman)) 77 | - Ignore B028 [\#62](https://github.com/scientific-python/lazy-loader/pull/62) ([jarrodmillman](https://github.com/jarrodmillman)) 78 | - Use dependabot to update requirements [\#61](https://github.com/scientific-python/lazy-loader/pull/61) ([jarrodmillman](https://github.com/jarrodmillman)) 79 | - Use dependabot to update GH actions [\#60](https://github.com/scientific-python/lazy-loader/pull/60) ([jarrodmillman](https://github.com/jarrodmillman)) 80 | - Use ruff [\#59](https://github.com/scientific-python/lazy-loader/pull/59) ([jarrodmillman](https://github.com/jarrodmillman)) 81 | - Update requirements [\#58](https://github.com/scientific-python/lazy-loader/pull/58) ([jarrodmillman](https://github.com/jarrodmillman)) 82 | - Warn and discourage lazy.load of subpackages [\#57](https://github.com/scientific-python/lazy-loader/pull/57) ([dschult](https://github.com/dschult)) 83 | - Test on Python 3.12.0-beta.2 [\#53](https://github.com/scientific-python/lazy-loader/pull/53) ([jarrodmillman](https://github.com/jarrodmillman)) 84 | 85 | ## [v0.2](https://github.com/scientific-python/lazy-loader/tree/v0.2) 86 | 87 | [Full Changelog](https://github.com/scientific-python/lazy-loader/compare/v0.1...v0.2) 88 | 89 | There were no changes since the release candidate, so 90 | see release notes for v0.2rc0 below for details. 91 | 92 | ## [v0.2rc0](https://github.com/scientific-python/lazy-loader/tree/v0.2rc0) 93 | 94 | [Full Changelog](https://github.com/scientific-python/lazy-loader/compare/v0.1...v0.2rc0) 95 | 96 | **Closed issues:** 97 | 98 | - Allow to not fail on stub attach in frozen apps [\#38](https://github.com/scientific-python/lazy-loader/issues/38) 99 | - Stub files with absolute imports [\#36](https://github.com/scientific-python/lazy-loader/issues/36) 100 | - Help to packaging Debian package [\#35](https://github.com/scientific-python/lazy-loader/issues/35) 101 | - conda upload [\#33](https://github.com/scientific-python/lazy-loader/issues/33) 102 | - Possible issues with partial lazy loading [\#32](https://github.com/scientific-python/lazy-loader/issues/32) 103 | - Type hints/Mypy best practices? [\#28](https://github.com/scientific-python/lazy-loader/issues/28) 104 | - Re-export non descendant attribute? [\#27](https://github.com/scientific-python/lazy-loader/issues/27) 105 | - This is awesome [\#6](https://github.com/scientific-python/lazy-loader/issues/6) 106 | 107 | **Merged pull requests:** 108 | 109 | - Add information that `pyi` files are used in runtime when use `attach\_stub` [\#47](https://github.com/scientific-python/lazy-loader/pull/47) ([Czaki](https://github.com/Czaki)) 110 | - Update tests to improve coverage [\#45](https://github.com/scientific-python/lazy-loader/pull/45) ([jarrodmillman](https://github.com/jarrodmillman)) 111 | - Use codecov GH action [\#44](https://github.com/scientific-python/lazy-loader/pull/44) ([jarrodmillman](https://github.com/jarrodmillman)) 112 | - Update year [\#43](https://github.com/scientific-python/lazy-loader/pull/43) ([jarrodmillman](https://github.com/jarrodmillman)) 113 | - Update GH actions [\#42](https://github.com/scientific-python/lazy-loader/pull/42) ([jarrodmillman](https://github.com/jarrodmillman)) 114 | - Update pre-commit [\#41](https://github.com/scientific-python/lazy-loader/pull/41) ([jarrodmillman](https://github.com/jarrodmillman)) 115 | - Update optional dependencies [\#40](https://github.com/scientific-python/lazy-loader/pull/40) ([jarrodmillman](https://github.com/jarrodmillman)) 116 | - Fix extension substitution to work with `\*.pyc` files [\#39](https://github.com/scientific-python/lazy-loader/pull/39) ([Czaki](https://github.com/Czaki)) 117 | - Sort returned \_\_all\_\_ [\#34](https://github.com/scientific-python/lazy-loader/pull/34) ([stefanv](https://github.com/stefanv)) 118 | 119 | ## [v0.1](https://github.com/scientific-python/lazy-loader/tree/v0.1) (2022-09-21) 120 | 121 | [Full Changelog](https://github.com/scientific-python/lazy-loader/compare/v0.1rc3...v0.1) 122 | 123 | **Merged pull requests:** 124 | 125 | - Update classifiers [\#31](https://github.com/scientific-python/lazy-loader/pull/31) ([jarrodmillman](https://github.com/jarrodmillman)) 126 | - Update precommit hooks [\#30](https://github.com/scientific-python/lazy-loader/pull/30) ([jarrodmillman](https://github.com/jarrodmillman)) 127 | - Refer to SPEC for stub usage [\#29](https://github.com/scientific-python/lazy-loader/pull/29) ([stefanv](https://github.com/stefanv)) 128 | 129 | ## [v0.1rc3](https://github.com/scientific-python/lazy-loader/tree/v0.1rc3) (2022-08-29) 130 | 131 | [Full Changelog](https://github.com/scientific-python/lazy-loader/compare/v0.1rc2...v0.1rc3) 132 | 133 | **Merged pull requests:** 134 | 135 | - Add test and coverage badges [\#26](https://github.com/scientific-python/lazy-loader/pull/26) ([jarrodmillman](https://github.com/jarrodmillman)) 136 | - Fix typos [\#25](https://github.com/scientific-python/lazy-loader/pull/25) ([jarrodmillman](https://github.com/jarrodmillman)) 137 | - Measure test coverage [\#23](https://github.com/scientific-python/lazy-loader/pull/23) ([jarrodmillman](https://github.com/jarrodmillman)) 138 | - Document release process [\#22](https://github.com/scientific-python/lazy-loader/pull/22) ([jarrodmillman](https://github.com/jarrodmillman)) 139 | - Add classifiers [\#21](https://github.com/scientific-python/lazy-loader/pull/21) ([jarrodmillman](https://github.com/jarrodmillman)) 140 | - Lint toml files [\#20](https://github.com/scientific-python/lazy-loader/pull/20) ([jarrodmillman](https://github.com/jarrodmillman)) 141 | - Test on more versions and platforms [\#19](https://github.com/scientific-python/lazy-loader/pull/19) ([jarrodmillman](https://github.com/jarrodmillman)) 142 | - Update GH actions [\#18](https://github.com/scientific-python/lazy-loader/pull/18) ([jarrodmillman](https://github.com/jarrodmillman)) 143 | - Split out linting CI from testing [\#17](https://github.com/scientific-python/lazy-loader/pull/17) ([jarrodmillman](https://github.com/jarrodmillman)) 144 | - Update precommit hooks [\#16](https://github.com/scientific-python/lazy-loader/pull/16) ([jarrodmillman](https://github.com/jarrodmillman)) 145 | - Specify lower bounds on dependencies [\#15](https://github.com/scientific-python/lazy-loader/pull/15) ([jarrodmillman](https://github.com/jarrodmillman)) 146 | - Lower min required Python version to 3.7 [\#14](https://github.com/scientific-python/lazy-loader/pull/14) ([donatasm](https://github.com/donatasm)) 147 | - feat: add attach_stub function to load imports from type stubs [\#10](https://github.com/scientific-python/lazy-loader/pull/10) ([tlambert03](https://github.com/tlambert03)) 148 | - Avoid conflicts when function is implemented in same-named submodule [\#9](https://github.com/scientific-python/lazy-loader/pull/9) ([stefanv](https://github.com/stefanv)) 149 | - DOC fix missing comma in usage example in README.md [\#7](https://github.com/scientific-python/lazy-loader/pull/7) ([adrinjalali](https://github.com/adrinjalali)) 150 | 151 | ## [v0.1rc2](https://github.com/scientific-python/lazy-loader/tree/v0.1rc2) (2022-03-10) 152 | 153 | [Full Changelog](https://github.com/scientific-python/lazy-loader/compare/v0.1rc1...v0.1rc2) 154 | 155 | **Merged pull requests:** 156 | 157 | - Add contributor README [\#5](https://github.com/scientific-python/lazy-loader/pull/5) ([stefanv](https://github.com/stefanv)) 158 | - Simplify delayed exception handling and improve reporting [\#4](https://github.com/scientific-python/lazy-loader/pull/4) ([stefanv](https://github.com/stefanv)) 159 | 160 | ## [v0.1rc1](https://github.com/scientific-python/lazy-loader/tree/v0.1rc1) (2022-03-01) 161 | 162 | [Full Changelog](https://github.com/scientific-python/lazy-loader/compare/v0.0...v0.1rc1) 163 | 164 | **Closed issues:** 165 | 166 | - Create package on pypi [\#1](https://github.com/scientific-python/lazy-loader/issues/1) 167 | 168 | **Merged pull requests:** 169 | 170 | - Run pre-commit hooks [\#3](https://github.com/scientific-python/lazy-loader/pull/3) ([tupui](https://github.com/tupui)) 171 | - Add the packaging infrastructure [\#2](https://github.com/scientific-python/lazy-loader/pull/2) ([tupui](https://github.com/tupui)) 172 | 173 | \* _This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)_ 174 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022--2023, Scientific Python project 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.md 2 | include .spin/cmds.py 3 | recursive-include tests * 4 | 5 | global-exclude *~ 6 | global-exclude *.pyc 7 | 8 | prune */.pytest_cache 9 | prune */__pycache__ 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI](https://img.shields.io/pypi/v/lazy-loader)](https://pypi.org/project/lazy-loader/) 2 | [![Test status](https://github.com/scientific-python/lazy-loader/workflows/test/badge.svg?branch=main)](https://github.com/scientific-python/lazy-loader/actions?query=workflow%3A%22test%22) 3 | [![Test coverage](https://codecov.io/gh/scientific-python/lazy-loader/branch/main/graph/badge.svg)](https://app.codecov.io/gh/scientific-python/lazy-loader/branch/main) 4 | 5 | `lazy-loader` makes it easy to load subpackages and functions on demand. 6 | 7 | ## Motivation 8 | 9 | 1. Allow subpackages to be made visible to users without incurring import costs. 10 | 2. Allow external libraries to be imported only when used, improving import times. 11 | 12 | For a more detailed discussion, see [the SPEC](https://scientific-python.org/specs/spec-0001/). 13 | 14 | ## Installation 15 | 16 | ``` 17 | pip install -U lazy-loader 18 | ``` 19 | 20 | We recommend using `lazy-loader` with Python >= 3.11. 21 | If using Python 3.11, please upgrade to 3.11.9 or later. 22 | If using Python 3.12, please upgrade to 3.12.3 or later. 23 | These versions [avoid](https://github.com/python/cpython/pull/114781) a [known race condition](https://github.com/python/cpython/issues/114763). 24 | 25 | ## Usage 26 | 27 | ### Lazily load subpackages 28 | 29 | Consider the `__init__.py` from [scikit-image](https://scikit-image.org): 30 | 31 | ```python 32 | subpackages = [ 33 | ..., 34 | 'filters', 35 | ... 36 | ] 37 | 38 | import lazy_loader as lazy 39 | __getattr__, __dir__, _ = lazy.attach(__name__, subpackages) 40 | ``` 41 | 42 | You can now do: 43 | 44 | ```python 45 | import skimage as ski 46 | ski.filters.gaussian(...) 47 | ``` 48 | 49 | The `filters` subpackages will only be loaded once accessed. 50 | 51 | ### Lazily load subpackages and functions 52 | 53 | Consider `skimage/filters/__init__.py`: 54 | 55 | ```python 56 | from ..util import lazy 57 | 58 | __getattr__, __dir__, __all__ = lazy.attach( 59 | __name__, 60 | submodules=['rank'], 61 | submod_attrs={ 62 | '_gaussian': ['gaussian', 'difference_of_gaussians'], 63 | 'edges': ['sobel', 'scharr', 'prewitt', 'roberts', 64 | 'laplace', 'farid'] 65 | } 66 | ) 67 | ``` 68 | 69 | The above is equivalent to: 70 | 71 | ```python 72 | from . import rank 73 | from ._gaussian import gaussian, difference_of_gaussians 74 | from .edges import (sobel, scharr, prewitt, roberts, 75 | laplace, farid) 76 | ``` 77 | 78 | Except that all subpackages (such as `rank`) and functions (such as `sobel`) are loaded upon access. 79 | 80 | ### Type checkers 81 | 82 | Static type checkers and IDEs cannot infer type information from 83 | lazily loaded imports. As a workaround you can load [type 84 | stubs](https://mypy.readthedocs.io/en/stable/stubs.html) (`.pyi` 85 | files) with `lazy.attach_stub`: 86 | 87 | ```python 88 | import lazy_loader as lazy 89 | __getattr__, __dir__, _ = lazy.attach_stub(__name__, "subpackages.pyi") 90 | ``` 91 | 92 | Note that, since imports are now defined in `.pyi` files, those 93 | are not only necessary for type checking but also at runtime. 94 | 95 | The SPEC [describes this workaround in more 96 | detail](https://scientific-python.org/specs/spec-0001/#type-checkers). 97 | 98 | ### Early failure 99 | 100 | With lazy loading, missing imports no longer fail upon loading the 101 | library. During development and testing, you can set the `EAGER_IMPORT` 102 | environment variable to disable lazy loading. 103 | 104 | ### External libraries 105 | 106 | The `lazy.attach` function discussed above is used to set up package 107 | internal imports. 108 | 109 | Use `lazy.load` to lazily import external libraries: 110 | 111 | ```python 112 | sp = lazy.load('scipy') # `sp` will only be loaded when accessed 113 | sp.linalg.norm(...) 114 | ``` 115 | 116 | _Note that lazily importing *sub*packages, 117 | i.e. `load('scipy.linalg')` will cause the package containing the 118 | subpackage to be imported immediately; thus, this usage is 119 | discouraged._ 120 | 121 | You can ask `lazy.load` to raise import errors as soon as it is called: 122 | 123 | ```python 124 | linalg = lazy.load('scipy.linalg', error_on_import=True) 125 | ``` 126 | 127 | #### Optional requirements 128 | 129 | One use for lazy loading is for loading optional dependencies, with 130 | `ImportErrors` only arising when optional functionality is accessed. If optional 131 | functionality depends on a specific version, a version requirement can 132 | be set: 133 | 134 | ```python 135 | np = lazy.load("numpy", require="numpy >=1.24") 136 | ``` 137 | 138 | In this case, if `numpy` is installed, but the version is less than 1.24, 139 | the `np` module returned will raise an error on attribute access. Using 140 | this feature is not all-or-nothing: One module may rely on one version of 141 | numpy, while another module may not set any requirement. 142 | 143 | _Note that the requirement must use the package [distribution name][] instead 144 | of the module [import name][]. For example, the `pyyaml` distribution provides 145 | the `yaml` module for import._ 146 | 147 | [distribution name]: https://packaging.python.org/en/latest/glossary/#term-Distribution-Package 148 | [import name]: https://packaging.python.org/en/latest/glossary/#term-Import-Package 149 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release process for `lazy-loader` 2 | 3 | ## Introduction 4 | 5 | Example `version number` 6 | 7 | - 1.8.dev0 # development version of 1.8 (release candidate 1) 8 | - 1.8rc1 # 1.8 release candidate 1 9 | - 1.8rc2.dev0 # development version of 1.8 release candidate 2 10 | - 1.8 # 1.8 release 11 | - 1.9.dev0 # development version of 1.9 (release candidate 1) 12 | 13 | ## Process 14 | 15 | - Set release variables: 16 | 17 | export VERSION= 18 | export PREVIOUS= 19 | export ORG="scientific-python" 20 | export REPO="lazy-loader" 21 | export LOG="CHANGELOG.md" 22 | 23 | - Autogenerate release notes 24 | 25 | changelist ${ORG}/${REPO} v${PREVIOUS} main --version ${VERSION} --config pyproject.toml --out ${VERSION}.md 26 | 27 | - Put the output of the above command at the top of `CHANGELOG.md` 28 | 29 | cat ${VERSION}.md | cat - ${LOG} > temp && mv temp ${LOG} 30 | 31 | - Update `version` in `lazy_loader/__init__.py`. 32 | 33 | - Commit changes: 34 | 35 | git add lazy_loader/__init__.py ${LOG} 36 | git commit -m "Designate ${VERSION} release" 37 | 38 | - Tag the release in git: 39 | 40 | git tag -s v${VERSION} -m "signed ${VERSION} tag" 41 | 42 | If you do not have a gpg key, use -u instead; it is important for 43 | Debian packaging that the tags are annotated 44 | 45 | - Push the new meta-data to github: 46 | 47 | git push --tags origin main 48 | 49 | where `origin` is the name of the `github.com:scientific-python/lazy-loader` 50 | repository 51 | 52 | - Create release from tag 53 | 54 | - go to https://github.com/scientific-python/lazy-loader/releases/new?tag=v${VERSION} 55 | - add v${VERSION} for the `Release title` 56 | - paste contents (or upload) of ${VERSION}.md in the `Describe this release section` 57 | - if pre-release check the box labelled `Set as a pre-release` 58 | 59 | - Update https://github.com/scientific-python/lazy-loader/milestones: 60 | 61 | - close old milestone 62 | - ensure new milestone exists (perhaps setting due date) 63 | 64 | - Update `version` in `lazy_loader/__init__.py`. 65 | 66 | - Commit changes: 67 | 68 | git add lazy_loader/__init__.py 69 | git commit -m 'Bump version' 70 | git push origin main 71 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security contact information 2 | 3 | To report a security vulnerability, please use the 4 | [Tidelift security contact](https://tidelift.com/security). 5 | Tidelift will coordinate the fix and disclosure. 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "lazy-loader" 7 | requires-python = ">=3.9" 8 | authors = [{name = "Scientific Python Developers"}] 9 | readme = "README.md" 10 | license = "BSD-3-Clause" 11 | dynamic = ['version'] 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3.9", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | ] 21 | description = "Makes it easy to load subpackages and functions on demand." 22 | dependencies = [ 23 | "packaging", 24 | ] 25 | 26 | [project.optional-dependencies] 27 | test = ["pytest >= 8.0", "pytest-cov >= 5.0", "coverage[toml] >= 7.2"] 28 | lint = ["pre-commit == 4.2.0"] 29 | dev = ["changelist == 0.5", "spin == 0.14"] 30 | 31 | [project.urls] 32 | Home = "https://scientific-python.org/specs/spec-0001/" 33 | Source = "https://github.com/scientific-python/lazy-loader" 34 | 35 | [tool.setuptools.dynamic.version] 36 | attr = 'lazy_loader.__version__' 37 | 38 | [tool.changelist] 39 | ignored_user_logins = ["dependabot[bot]", "pre-commit-ci[bot]", "web-flow"] 40 | 41 | [tool.pytest.ini_options] 42 | minversion = "8.0" 43 | addopts = ["-ra", "--showlocals", "--strict-markers", "--strict-config"] 44 | xfail_strict = true 45 | filterwarnings = ["error"] 46 | log_cli_level = "info" 47 | 48 | [tool.ruff] 49 | exclude = [ 50 | "tests/fake_pkg/__init__.pyi", 51 | ] 52 | 53 | [tool.ruff.lint] 54 | extend-select = [ 55 | "B", # flake8-bugbear 56 | "I", # isort 57 | "ARG", # flake8-unused-arguments 58 | "C4", # flake8-comprehensions 59 | "EM", # flake8-errmsg 60 | "ICN", # flake8-import-conventions 61 | "G", # flake8-logging-format 62 | "PGH", # pygrep-hooks 63 | "PIE", # flake8-pie 64 | "PL", # pylint 65 | "PT", # flake8-pytest-style 66 | # "PTH", # flake8-use-pathlib 67 | "RET", # flake8-return 68 | "RUF", # Ruff-specific 69 | "SIM", # flake8-simplify 70 | "T20", # flake8-print 71 | "UP", # pyupgrade 72 | "YTT", # flake8-2020 73 | "EXE", # flake8-executable 74 | "NPY", # NumPy specific rules 75 | "PD", # pandas-vet 76 | "FURB", # refurb 77 | "PYI", # flake8-pyi 78 | ] 79 | ignore = [ 80 | "PLR09", # Too many <...> 81 | "PLR2004", # Magic value used in comparison 82 | "ISC001", # Conflicts with formatter 83 | "B018", # Found useless expression 84 | "B028", # No explicit `stacklevel` keyword argument found 85 | "PT011", # `pytest.raises(ValueError)` is too broad 86 | "EM102", # Exception must not use an f-string literal 87 | "EM101", # Exception must not use a string literal 88 | "RET505", # Unnecessary `elif` after `return` statement 89 | "SIM108", # Use ternary operator 90 | ] 91 | 92 | [tool.ruff.lint.per-file-ignores] 93 | "tests/test_*.py" = [ 94 | "ARG001", # Pytest fixtures are passed as arguments 95 | ] 96 | 97 | [tool.ruff.format] 98 | docstring-code-format = true 99 | 100 | [tool.coverage.run] 101 | branch = true 102 | source = ["lazy_loader", "tests"] 103 | 104 | [tool.coverage.paths] 105 | source = [ 106 | "src/lazy_loader", 107 | "*/site-packages/lazy_loader", 108 | ] 109 | 110 | [tool.spin] 111 | package = 'lazy_loader' 112 | 113 | [tool.spin.commands] 114 | Build = [ 115 | 'spin.cmds.pip.install', 116 | '.spin/cmds.py:test', 117 | 'spin.cmds.build.sdist', 118 | ] 119 | -------------------------------------------------------------------------------- /src/lazy_loader/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | lazy_loader 3 | =========== 4 | 5 | Makes it easy to load subpackages and functions on demand. 6 | """ 7 | 8 | import ast 9 | import importlib 10 | import importlib.util 11 | import os 12 | import sys 13 | import threading 14 | import types 15 | import warnings 16 | 17 | __version__ = "0.5rc0.dev0" 18 | __all__ = ["attach", "attach_stub", "load"] 19 | 20 | 21 | threadlock = threading.Lock() 22 | 23 | 24 | def attach(package_name, submodules=None, submod_attrs=None): 25 | """Attach lazily loaded submodules, functions, or other attributes. 26 | 27 | Typically, modules import submodules and attributes as follows:: 28 | 29 | import mysubmodule 30 | import anothersubmodule 31 | 32 | from .foo import someattr 33 | 34 | The idea is to replace a package's `__getattr__`, `__dir__`, and 35 | `__all__`, such that all imports work exactly the way they would 36 | with normal imports, except that the import occurs upon first use. 37 | 38 | The typical way to call this function, replacing the above imports, is:: 39 | 40 | __getattr__, __dir__, __all__ = lazy.attach( 41 | __name__, ["mysubmodule", "anothersubmodule"], {"foo": ["someattr"]} 42 | ) 43 | 44 | Parameters 45 | ---------- 46 | package_name : str 47 | Typically use ``__name__``. 48 | submodules : set 49 | List of submodules to attach. 50 | submod_attrs : dict 51 | Dictionary of submodule -> list of attributes / functions. 52 | These attributes are imported as they are used. 53 | 54 | Returns 55 | ------- 56 | __getattr__, __dir__, __all__ 57 | 58 | """ 59 | if submod_attrs is None: 60 | submod_attrs = {} 61 | 62 | if submodules is None: 63 | submodules = set() 64 | else: 65 | submodules = set(submodules) 66 | 67 | attr_to_modules = { 68 | attr: mod for mod, attrs in submod_attrs.items() for attr in attrs 69 | } 70 | 71 | __all__ = sorted(submodules | attr_to_modules.keys()) 72 | 73 | def __getattr__(name): 74 | if name in submodules: 75 | return importlib.import_module(f"{package_name}.{name}") 76 | elif name in attr_to_modules: 77 | submod_path = f"{package_name}.{attr_to_modules[name]}" 78 | submod = importlib.import_module(submod_path) 79 | attr = getattr(submod, name) 80 | 81 | # If the attribute lives in a file (module) with the same 82 | # name as the attribute, ensure that the attribute and *not* 83 | # the module is accessible on the package. 84 | if name == attr_to_modules[name]: 85 | pkg = sys.modules[package_name] 86 | pkg.__dict__[name] = attr 87 | 88 | return attr 89 | else: 90 | raise AttributeError(f"No {package_name} attribute {name}") 91 | 92 | def __dir__(): 93 | return __all__.copy() 94 | 95 | if os.environ.get("EAGER_IMPORT", ""): 96 | for attr in set(attr_to_modules.keys()) | submodules: 97 | __getattr__(attr) 98 | 99 | return __getattr__, __dir__, __all__.copy() 100 | 101 | 102 | class DelayedImportErrorModule(types.ModuleType): 103 | def __init__(self, frame_data, *args, message, **kwargs): 104 | self.__frame_data = frame_data 105 | self.__message = message 106 | super().__init__(*args, **kwargs) 107 | 108 | def __getattr__(self, x): 109 | fd = self.__frame_data 110 | raise ModuleNotFoundError( 111 | f"{self.__message}\n\n" 112 | "This error is lazily reported, having originally occurred in\n" 113 | f" File {fd['filename']}, line {fd['lineno']}, in {fd['function']}\n\n" 114 | f"----> {''.join(fd['code_context'] or '').strip()}" 115 | ) 116 | 117 | 118 | def load(fullname, *, require=None, error_on_import=False, suppress_warning=False): 119 | """Return a lazily imported proxy for a module. 120 | 121 | We often see the following pattern:: 122 | 123 | def myfunc(): 124 | import numpy as np 125 | np.norm(...) 126 | .... 127 | 128 | Putting the import inside the function prevents, in this case, 129 | `numpy`, from being imported at function definition time. 130 | That saves time if `myfunc` ends up not being called. 131 | 132 | This `load` function returns a proxy module that, upon access, imports 133 | the actual module. So the idiom equivalent to the above example is:: 134 | 135 | np = lazy.load("numpy") 136 | 137 | def myfunc(): 138 | np.norm(...) 139 | .... 140 | 141 | The initial import time is fast because the actual import is delayed 142 | until the first attribute is requested. The overall import time may 143 | decrease as well for users that don't make use of large portions 144 | of your library. 145 | 146 | Warning 147 | ------- 148 | While lazily loading *sub*packages technically works, it causes the 149 | package (that contains the subpackage) to be eagerly loaded even 150 | if the package is already lazily loaded. 151 | So, you probably shouldn't use subpackages with this `load` feature. 152 | Instead you should encourage the package maintainers to use the 153 | `lazy_loader.attach` to make their subpackages load lazily. 154 | 155 | Parameters 156 | ---------- 157 | fullname : str 158 | The full name of the module or submodule to import. For example:: 159 | 160 | sp = lazy.load("scipy") # import scipy as sp 161 | 162 | require : str 163 | A dependency requirement as defined in PEP-508. For example:: 164 | 165 | "numpy >=1.24" 166 | 167 | If defined, the proxy module will raise an error if the installed 168 | version does not satisfy the requirement. 169 | 170 | error_on_import : bool 171 | Whether to postpone raising import errors until the module is accessed. 172 | If set to `True`, import errors are raised as soon as `load` is called. 173 | 174 | suppress_warning : bool 175 | Whether to prevent emitting a warning when loading subpackages. 176 | If set to `True`, no warning will occur. 177 | 178 | Returns 179 | ------- 180 | pm : importlib.util._LazyModule 181 | Proxy module. Can be used like any regularly imported module. 182 | Actual loading of the module occurs upon first attribute request. 183 | 184 | """ 185 | with threadlock: 186 | module = sys.modules.get(fullname) 187 | have_module = module is not None 188 | 189 | # Most common, short-circuit 190 | if have_module and require is None: 191 | return module 192 | 193 | if not suppress_warning and "." in fullname: 194 | msg = ( 195 | "subpackages can technically be lazily loaded, but it causes the " 196 | "package to be eagerly loaded even if it is already lazily loaded. " 197 | "So, you probably shouldn't use subpackages with this lazy feature." 198 | ) 199 | warnings.warn(msg, RuntimeWarning) 200 | 201 | spec = None 202 | 203 | if not have_module: 204 | spec = importlib.util.find_spec(fullname) 205 | have_module = spec is not None 206 | 207 | if not have_module: 208 | not_found_message = f"No module named '{fullname}'" 209 | elif require is not None: 210 | try: 211 | have_module = _check_requirement(require) 212 | except ModuleNotFoundError as e: 213 | raise ValueError( 214 | f"Found module '{fullname}' but cannot test " 215 | "requirement '{require}'. " 216 | "Requirements must match distribution name, not module name." 217 | ) from e 218 | 219 | not_found_message = f"No distribution can be found matching '{require}'" 220 | 221 | if not have_module: 222 | if error_on_import: 223 | raise ModuleNotFoundError(not_found_message) 224 | import inspect 225 | 226 | parent = inspect.stack()[1] 227 | frame_data = { 228 | "filename": parent.filename, 229 | "lineno": parent.lineno, 230 | "function": parent.function, 231 | "code_context": parent.code_context, 232 | } 233 | del parent 234 | return DelayedImportErrorModule( 235 | frame_data, 236 | "DelayedImportErrorModule", 237 | message=not_found_message, 238 | ) 239 | 240 | if spec is not None: 241 | module = importlib.util.module_from_spec(spec) 242 | sys.modules[fullname] = module 243 | 244 | loader = importlib.util.LazyLoader(spec.loader) 245 | loader.exec_module(module) 246 | 247 | return module 248 | 249 | 250 | def _check_requirement(require: str) -> bool: 251 | """Verify that a package requirement is satisfied 252 | 253 | If the package is required, a ``ModuleNotFoundError`` is raised 254 | by ``importlib.metadata``. 255 | 256 | Parameters 257 | ---------- 258 | require : str 259 | A dependency requirement as defined in PEP-508 260 | 261 | Returns 262 | ------- 263 | satisfied : bool 264 | True if the installed version of the dependency matches 265 | the specified version, False otherwise. 266 | """ 267 | import importlib.metadata 268 | 269 | import packaging.requirements 270 | 271 | req = packaging.requirements.Requirement(require) 272 | return req.specifier.contains( 273 | importlib.metadata.version(req.name), 274 | prereleases=True, 275 | ) 276 | 277 | 278 | class _StubVisitor(ast.NodeVisitor): 279 | """AST visitor to parse a stub file for submodules and submod_attrs.""" 280 | 281 | def __init__(self): 282 | self._submodules = set() 283 | self._submod_attrs = {} 284 | 285 | def visit_ImportFrom(self, node: ast.ImportFrom): 286 | if node.level != 1: 287 | raise ValueError( 288 | "Only within-module imports are supported (`from .* import`)" 289 | ) 290 | if node.module: 291 | attrs: list = self._submod_attrs.setdefault(node.module, []) 292 | aliases = [alias.name for alias in node.names] 293 | if "*" in aliases: 294 | raise ValueError( 295 | "lazy stub loader does not support star import " 296 | f"`from {node.module} import *`" 297 | ) 298 | attrs.extend(aliases) 299 | else: 300 | self._submodules.update(alias.name for alias in node.names) 301 | 302 | 303 | def attach_stub(package_name: str, filename: str): 304 | """Attach lazily loaded submodules, functions from a type stub. 305 | 306 | This is a variant on ``attach`` that will parse a `.pyi` stub file to 307 | infer ``submodules`` and ``submod_attrs``. This allows static type checkers 308 | to find imports, while still providing lazy loading at runtime. 309 | 310 | Parameters 311 | ---------- 312 | package_name : str 313 | Typically use ``__name__``. 314 | filename : str 315 | Path to `.py` file which has an adjacent `.pyi` file. 316 | Typically use ``__file__``. 317 | 318 | Returns 319 | ------- 320 | __getattr__, __dir__, __all__ 321 | The same output as ``attach``. 322 | 323 | Raises 324 | ------ 325 | ValueError 326 | If a stub file is not found for `filename`, or if the stubfile is formmated 327 | incorrectly (e.g. if it contains an relative import from outside of the module) 328 | """ 329 | stubfile = ( 330 | filename if filename.endswith("i") else f"{os.path.splitext(filename)[0]}.pyi" 331 | ) 332 | 333 | if not os.path.exists(stubfile): 334 | raise ValueError(f"Cannot load imports from non-existent stub {stubfile!r}") 335 | 336 | with open(stubfile) as f: 337 | stub_node = ast.parse(f.read()) 338 | 339 | visitor = _StubVisitor() 340 | visitor.visit(stub_node) 341 | return attach(package_name, visitor._submodules, visitor._submod_attrs) 342 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scientific-python/lazy-loader/5888228dbd47d081c0d789ca48214ca9f86cb8b1/tests/__init__.py -------------------------------------------------------------------------------- /tests/fake_pkg/__init__.py: -------------------------------------------------------------------------------- 1 | import lazy_loader as lazy 2 | 3 | __getattr__, __lazy_dir__, __all__ = lazy.attach( 4 | __name__, submod_attrs={"some_func": ["some_func", "aux_func"]} 5 | ) 6 | -------------------------------------------------------------------------------- /tests/fake_pkg/__init__.pyi: -------------------------------------------------------------------------------- 1 | from .some_func import aux_func, some_func 2 | -------------------------------------------------------------------------------- /tests/fake_pkg/some_func.py: -------------------------------------------------------------------------------- 1 | def some_func(): 2 | """Function with same name as submodule.""" 3 | 4 | 5 | def aux_func(): 6 | """Auxiliary function.""" 7 | -------------------------------------------------------------------------------- /tests/import_np_parallel.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | 4 | import lazy_loader as lazy 5 | 6 | 7 | def import_np(): 8 | time.sleep(0.5) 9 | lazy.load("numpy") 10 | 11 | 12 | for _ in range(10): 13 | threading.Thread(target=import_np).start() 14 | -------------------------------------------------------------------------------- /tests/test_lazy_loader.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | import subprocess 4 | import sys 5 | import types 6 | from unittest import mock 7 | 8 | import pytest 9 | 10 | import lazy_loader as lazy 11 | 12 | 13 | @pytest.fixture 14 | def clean_fake_pkg(): 15 | yield 16 | sys.modules.pop("tests.fake_pkg.some_func", None) 17 | sys.modules.pop("tests.fake_pkg", None) 18 | sys.modules.pop("tests", None) 19 | 20 | 21 | @pytest.mark.parametrize("attempt", [1, 2]) 22 | def test_cleanup_fixture(clean_fake_pkg, attempt): 23 | assert "tests.fake_pkg" not in sys.modules 24 | assert "tests.fake_pkg.some_func" not in sys.modules 25 | from tests import fake_pkg 26 | 27 | assert "tests.fake_pkg" in sys.modules 28 | assert "tests.fake_pkg.some_func" not in sys.modules 29 | assert isinstance(fake_pkg.some_func, types.FunctionType) 30 | assert "tests.fake_pkg.some_func" in sys.modules 31 | 32 | 33 | def test_lazy_import_basics(): 34 | math = lazy.load("math") 35 | anything_not_real = lazy.load("anything_not_real") 36 | 37 | # Now test that accessing attributes does what it should 38 | assert math.sin(math.pi) == pytest.approx(0, 1e-6) 39 | # poor-mans pytest.raises for testing errors on attribute access 40 | with pytest.raises(ModuleNotFoundError): 41 | anything_not_real.pi 42 | assert isinstance(anything_not_real, lazy.DelayedImportErrorModule) 43 | # see if it changes for second access 44 | with pytest.raises(ModuleNotFoundError): 45 | anything_not_real.pi 46 | 47 | 48 | def test_lazy_import_subpackages(): 49 | with pytest.warns(RuntimeWarning): 50 | hp = lazy.load("html.parser") 51 | assert "html" in sys.modules 52 | assert type(sys.modules["html"]) is type(pytest) 53 | assert isinstance(hp, importlib.util._LazyModule) 54 | assert "html.parser" in sys.modules 55 | assert sys.modules["html.parser"] == hp 56 | 57 | 58 | def test_lazy_import_impact_on_sys_modules(): 59 | math = lazy.load("math") 60 | anything_not_real = lazy.load("anything_not_real") 61 | 62 | assert isinstance(math, types.ModuleType) 63 | assert "math" in sys.modules 64 | assert isinstance(anything_not_real, lazy.DelayedImportErrorModule) 65 | assert "anything_not_real" not in sys.modules 66 | 67 | # only do this if numpy is installed 68 | pytest.importorskip("numpy") 69 | np = lazy.load("numpy") 70 | assert isinstance(np, types.ModuleType) 71 | assert "numpy" in sys.modules 72 | 73 | np.pi # trigger load of numpy 74 | 75 | assert isinstance(np, types.ModuleType) 76 | assert "numpy" in sys.modules 77 | 78 | 79 | def test_lazy_import_nonbuiltins(): 80 | np = lazy.load("numpy") 81 | sp = lazy.load("scipy") 82 | if not isinstance(np, lazy.DelayedImportErrorModule): 83 | assert np.sin(np.pi) == pytest.approx(0, 1e-6) 84 | if isinstance(sp, lazy.DelayedImportErrorModule): 85 | with pytest.raises(ModuleNotFoundError): 86 | sp.pi 87 | 88 | 89 | def test_lazy_attach(): 90 | name = "mymod" 91 | submods = ["mysubmodule", "anothersubmodule"] 92 | myall = {"not_real_submod": ["some_var_or_func"]} 93 | 94 | locls = { 95 | "attach": lazy.attach, 96 | "name": name, 97 | "submods": submods, 98 | "myall": myall, 99 | } 100 | s = "__getattr__, __lazy_dir__, __all__ = attach(name, submods, myall)" 101 | 102 | exec(s, {}, locls) 103 | expected = { 104 | "attach": lazy.attach, 105 | "name": name, 106 | "submods": submods, 107 | "myall": myall, 108 | "__getattr__": None, 109 | "__lazy_dir__": None, 110 | "__all__": None, 111 | } 112 | assert locls.keys() == expected.keys() 113 | for k, v in expected.items(): 114 | if v is not None: 115 | assert locls[k] == v 116 | 117 | # Exercise __getattr__, though it will just error 118 | with pytest.raises(ImportError): 119 | locls["__getattr__"]("mysubmodule") 120 | 121 | # Attribute is supposed to be imported, error on submodule load 122 | with pytest.raises(ImportError): 123 | locls["__getattr__"]("some_var_or_func") 124 | 125 | # Attribute is unknown, raise AttributeError 126 | with pytest.raises(AttributeError): 127 | locls["__getattr__"]("unknown_attr") 128 | 129 | 130 | def test_lazy_attach_noattrs(): 131 | name = "mymod" 132 | submods = ["mysubmodule", "anothersubmodule"] 133 | _, _, all_ = lazy.attach(name, submods) 134 | 135 | assert all_ == sorted(submods) 136 | 137 | 138 | def test_lazy_attach_returns_copies(): 139 | _get, _dir, _all = lazy.attach( 140 | __name__, ["my_submodule", "another_submodule"], {"foo": ["some_attr"]} 141 | ) 142 | assert _dir() is not _dir() 143 | assert _dir() == _all 144 | assert _dir() is not _all 145 | 146 | expected = ["another_submodule", "my_submodule", "some_attr"] 147 | assert _dir() == expected 148 | assert _all == expected 149 | assert _dir() is not _all 150 | 151 | _dir().append("modify_returned_list") 152 | assert _dir() == expected 153 | assert _all == expected 154 | assert _dir() is not _all 155 | 156 | _all.append("modify_returned_all") 157 | assert _dir() == expected 158 | assert _all == [*expected, "modify_returned_all"] 159 | 160 | 161 | @pytest.mark.parametrize("eager_import", [False, True]) 162 | def test_attach_same_module_and_attr_name(clean_fake_pkg, eager_import): 163 | env = {} 164 | if eager_import: 165 | env["EAGER_IMPORT"] = "1" 166 | 167 | with mock.patch.dict(os.environ, env): 168 | from tests import fake_pkg 169 | 170 | # Grab attribute twice, to ensure that importing it does not 171 | # override function by module 172 | assert isinstance(fake_pkg.some_func, types.FunctionType) 173 | assert isinstance(fake_pkg.some_func, types.FunctionType) 174 | 175 | # Ensure imports from submodule still work 176 | from tests.fake_pkg.some_func import some_func 177 | 178 | assert isinstance(some_func, types.FunctionType) 179 | 180 | 181 | FAKE_STUB = """ 182 | from . import rank 183 | from ._gaussian import gaussian 184 | from .edges import sobel, scharr, prewitt, roberts 185 | """ 186 | 187 | 188 | def test_stub_loading(tmp_path): 189 | stub = tmp_path / "stub.pyi" 190 | stub.write_text(FAKE_STUB) 191 | _get, _dir, _all = lazy.attach_stub("my_module", str(stub)) 192 | expect = {"gaussian", "sobel", "scharr", "prewitt", "roberts", "rank"} 193 | assert set(_dir()) == set(_all) == expect 194 | 195 | 196 | def test_stub_loading_parity(): 197 | from tests import fake_pkg 198 | 199 | from_stub = lazy.attach_stub(fake_pkg.__name__, fake_pkg.__file__) 200 | stub_getter, stub_dir, stub_all = from_stub 201 | assert stub_all == fake_pkg.__all__ 202 | assert stub_dir() == fake_pkg.__lazy_dir__() 203 | assert stub_getter("some_func") == fake_pkg.some_func 204 | 205 | 206 | def test_stub_loading_errors(tmp_path): 207 | stub = tmp_path / "stub.pyi" 208 | stub.write_text("from ..mod import func\n") 209 | 210 | with pytest.raises(ValueError, match="Only within-module imports are supported"): 211 | lazy.attach_stub("name", str(stub)) 212 | 213 | with pytest.raises(ValueError, match="Cannot load imports from non-existent stub"): 214 | lazy.attach_stub("name", "not a file") 215 | 216 | stub2 = tmp_path / "stub2.pyi" 217 | stub2.write_text("from .mod import *\n") 218 | with pytest.raises(ValueError, match=".*does not support star import"): 219 | lazy.attach_stub("name", str(stub2)) 220 | 221 | 222 | def test_require_kwarg(): 223 | # Test with a module that definitely exists, behavior hinges on requirement 224 | with mock.patch("importlib.metadata.version") as version: 225 | version.return_value = "1.0.0" 226 | math = lazy.load("math", require="somepkg >= 2.0") 227 | assert isinstance(math, lazy.DelayedImportErrorModule) 228 | 229 | math = lazy.load("math", require="somepkg >= 1.0") 230 | assert math.sin(math.pi) == pytest.approx(0, 1e-6) 231 | 232 | # We can fail even after a successful import 233 | math = lazy.load("math", require="somepkg >= 2.0") 234 | assert isinstance(math, lazy.DelayedImportErrorModule) 235 | 236 | # Eager failure 237 | with pytest.raises(ModuleNotFoundError): 238 | lazy.load("math", require="somepkg >= 2.0", error_on_import=True) 239 | 240 | # When a module can be loaded but the version can't be checked, 241 | # raise a ValueError 242 | with pytest.raises(ValueError): 243 | lazy.load("math", require="somepkg >= 1.0") 244 | 245 | 246 | def test_parallel_load(): 247 | pytest.importorskip("numpy") 248 | 249 | subprocess.run( 250 | [ 251 | sys.executable, 252 | os.path.join(os.path.dirname(__file__), "import_np_parallel.py"), 253 | ], 254 | check=True, 255 | ) 256 | --------------------------------------------------------------------------------