├── .coveragerc ├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── NEWS.rst ├── README.rst ├── SECURITY.md ├── codecov.yml ├── docs ├── api.rst ├── conf.py ├── history.rst ├── index.rst ├── migration.rst └── using.rst ├── importlib_resources ├── __init__.py ├── _adapters.py ├── _common.py ├── _functional.py ├── _itertools.py ├── abc.py ├── compat │ ├── __init__.py │ └── py39.py ├── future │ ├── __init__.py │ └── adapters.py ├── py.typed ├── readers.py ├── simple.py └── tests │ ├── __init__.py │ ├── _path.py │ ├── compat │ ├── __init__.py │ ├── py312.py │ └── py39.py │ ├── test_compatibilty_files.py │ ├── test_contents.py │ ├── test_custom.py │ ├── test_files.py │ ├── test_functional.py │ ├── test_open.py │ ├── test_path.py │ ├── test_read.py │ ├── test_reader.py │ ├── test_resource.py │ ├── test_util.py │ ├── util.py │ └── zip.py ├── mypy.ini ├── pyproject.toml ├── pytest.ini ├── ruff.toml ├── towncrier.toml └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # leading `*/` for pytest-dev/pytest-cov#456 4 | */.tox/* 5 | */_itertools.py 6 | */_legacy.py 7 | */simple.py 8 | */_path.py 9 | disable_warnings = 10 | couldnt-parse 11 | 12 | [report] 13 | show_missing = True 14 | exclude_also = 15 | # Exclude common false positives per 16 | # https://coverage.readthedocs.io/en/latest/excluding.html#advanced-exclusion 17 | # Ref jaraco/skeleton#97 and jaraco/skeleton#135 18 | class .*\bProtocol\): 19 | if TYPE_CHECKING: 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 4 7 | insert_final_newline = true 8 | end_of_line = lf 9 | 10 | [*.py] 11 | indent_style = space 12 | max_line_length = 88 13 | 14 | [*.{yml,yaml}] 15 | indent_style = space 16 | indent_size = 2 17 | 18 | [*.rst] 19 | indent_style = space 20 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.file binary 2 | *.zip binary 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | tidelift: pypi/importlib-resources 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | merge_group: 5 | push: 6 | branches-ignore: 7 | # temporary GH branches relating to merge queues (jaraco/skeleton#93) 8 | - gh-readonly-queue/** 9 | tags: 10 | # required if branches-ignore is supplied (jaraco/skeleton#103) 11 | - '**' 12 | pull_request: 13 | workflow_dispatch: 14 | 15 | permissions: 16 | contents: read 17 | 18 | env: 19 | # Environment variable to support color support (jaraco/skeleton#66) 20 | FORCE_COLOR: 1 21 | 22 | # Suppress noisy pip warnings 23 | PIP_DISABLE_PIP_VERSION_CHECK: 'true' 24 | PIP_NO_WARN_SCRIPT_LOCATION: 'true' 25 | 26 | # Ensure tests can sense settings about the environment 27 | TOX_OVERRIDE: >- 28 | testenv.pass_env+=GITHUB_*,FORCE_COLOR 29 | 30 | 31 | jobs: 32 | test: 33 | strategy: 34 | # https://blog.jaraco.com/efficient-use-of-ci-resources/ 35 | matrix: 36 | python: 37 | - "3.9" 38 | - "3.13" 39 | platform: 40 | - ubuntu-latest 41 | - macos-latest 42 | - windows-latest 43 | include: 44 | - python: "3.10" 45 | platform: ubuntu-latest 46 | - python: "3.11" 47 | platform: ubuntu-latest 48 | - python: "3.12" 49 | platform: ubuntu-latest 50 | - python: "3.14" 51 | platform: ubuntu-latest 52 | - python: pypy3.10 53 | platform: ubuntu-latest 54 | runs-on: ${{ matrix.platform }} 55 | continue-on-error: ${{ matrix.python == '3.14' }} 56 | steps: 57 | - uses: actions/checkout@v4 58 | - name: Install build dependencies 59 | # Install dependencies for building packages on pre-release Pythons 60 | # jaraco/skeleton#161 61 | if: matrix.python == '3.14' && matrix.platform == 'ubuntu-latest' 62 | run: | 63 | sudo apt update 64 | sudo apt install -y libxml2-dev libxslt-dev 65 | - name: Setup Python 66 | uses: actions/setup-python@v5 67 | with: 68 | python-version: ${{ matrix.python }} 69 | allow-prereleases: true 70 | - name: Install tox 71 | run: python -m pip install tox 72 | - name: Run 73 | run: tox 74 | 75 | collateral: 76 | strategy: 77 | fail-fast: false 78 | matrix: 79 | job: 80 | - diffcov 81 | - docs 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v4 85 | with: 86 | fetch-depth: 0 87 | - name: Setup Python 88 | uses: actions/setup-python@v5 89 | with: 90 | python-version: 3.x 91 | - name: Install tox 92 | run: python -m pip install tox 93 | - name: Eval ${{ matrix.job }} 94 | run: tox -e ${{ matrix.job }} 95 | 96 | check: # This job does nothing and is only used for the branch protection 97 | if: always() 98 | 99 | needs: 100 | - test 101 | - collateral 102 | 103 | runs-on: ubuntu-latest 104 | 105 | steps: 106 | - name: Decide whether the needed jobs succeeded or failed 107 | uses: re-actors/alls-green@release/v1 108 | with: 109 | jobs: ${{ toJSON(needs) }} 110 | 111 | release: 112 | permissions: 113 | contents: write 114 | needs: 115 | - check 116 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 117 | runs-on: ubuntu-latest 118 | 119 | steps: 120 | - uses: actions/checkout@v4 121 | - name: Setup Python 122 | uses: actions/setup-python@v5 123 | with: 124 | python-version: 3.x 125 | - name: Install tox 126 | run: python -m pip install tox 127 | - name: Run 128 | run: tox -e release 129 | env: 130 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 131 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 132 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | /diffcov.html 103 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.9.9 4 | hooks: 5 | - id: ruff 6 | args: [--fix, --unsafe-fixes] 7 | - id: ruff-format 8 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | python: 3 | install: 4 | - path: . 5 | extra_requirements: 6 | - doc 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | # required boilerplate readthedocs/readthedocs.org#10401 12 | build: 13 | os: ubuntu-lts-latest 14 | tools: 15 | python: latest 16 | # post-checkout job to ensure the clone isn't shallow jaraco/skeleton#114 17 | jobs: 18 | post_checkout: 19 | - git fetch --unshallow || true 20 | -------------------------------------------------------------------------------- /NEWS.rst: -------------------------------------------------------------------------------- 1 | v6.5.2 2 | ====== 3 | 4 | Bugfixes 5 | -------- 6 | 7 | - Replaced reference to typing_extensions with stdlib Literal. (#323) 8 | 9 | 10 | v6.5.1 11 | ====== 12 | 13 | Bugfixes 14 | -------- 15 | 16 | - Updated ``Traversable.read_text()`` to reflect the ``errors`` parameter (python/cpython#127012). (#321) 17 | 18 | 19 | v6.5.0 20 | ====== 21 | 22 | Features 23 | -------- 24 | 25 | - Add type annotations for Traversable.open. (#317) 26 | - Require Python 3.9 or later. 27 | 28 | 29 | v6.4.5 30 | ====== 31 | 32 | Bugfixes 33 | -------- 34 | 35 | - Omit sentinel values from a namespace path. (#311) 36 | 37 | 38 | v6.4.4 39 | ====== 40 | 41 | No significant changes. 42 | 43 | 44 | v6.4.3 45 | ====== 46 | 47 | Bugfixes 48 | -------- 49 | 50 | - When inferring the caller in ``files()`` correctly detect one's own module even when the resources package source is not present. (python/cpython#123085) 51 | 52 | 53 | v6.4.2 54 | ====== 55 | 56 | Bugfixes 57 | -------- 58 | 59 | - Merged fix for UTF-16 BOM handling in functional tests. (#312) 60 | 61 | 62 | v6.4.1 63 | ====== 64 | 65 | Bugfixes 66 | -------- 67 | 68 | - When constructing ZipReader, only append the name if the indicated module is a package. (python/cpython#121735) 69 | 70 | 71 | v6.4.0 72 | ====== 73 | 74 | Features 75 | -------- 76 | 77 | - The functions 78 | ``is_resource()``, 79 | ``open_binary()``, 80 | ``open_text()``, 81 | ``path()``, 82 | ``read_binary()``, and 83 | ``read_text()`` are un-deprecated, and support 84 | subdirectories via multiple positional arguments. 85 | The ``contents()`` function also allows subdirectories, 86 | but remains deprecated. (#303) 87 | - Deferred select imports in for a speedup (python/cpython#109829). 88 | 89 | 90 | v6.3.2 91 | ====== 92 | 93 | Bugfixes 94 | -------- 95 | 96 | - Restored expectation that local standard readers are preferred over degenerate readers. (#298) 97 | 98 | 99 | v6.3.1 100 | ====== 101 | 102 | Bugfixes 103 | -------- 104 | 105 | - Restored expectation that stdlib readers are suppressed on Python 3.10. (#257) 106 | 107 | 108 | v6.3.0 109 | ====== 110 | 111 | Features 112 | -------- 113 | 114 | - Add ``Anchor`` to ``importlib.resources`` (in order for the code to comply with the documentation) 115 | 116 | 117 | v6.2.0 118 | ====== 119 | 120 | Features 121 | -------- 122 | 123 | - Future compatibility adapters now ensure that standard library readers are replaced without overriding non-standard readers. (#295) 124 | 125 | 126 | v6.1.3 127 | ====== 128 | 129 | No significant changes. 130 | 131 | 132 | v6.1.2 133 | ====== 134 | 135 | Bugfixes 136 | -------- 137 | 138 | - Fixed NotADirectoryError when calling files on a subdirectory of a namespace package. (#293) 139 | 140 | 141 | v6.1.1 142 | ====== 143 | 144 | Bugfixes 145 | -------- 146 | 147 | - Added missed stream argument in simple.ResourceHandle. Ref python/cpython#111775. 148 | 149 | 150 | v6.1.0 151 | ====== 152 | 153 | Features 154 | -------- 155 | 156 | - MultiplexedPath now expects Traversable paths. String arguments to MultiplexedPath are now deprecated. 157 | 158 | 159 | Bugfixes 160 | -------- 161 | 162 | - Enabled support for resources in namespace packages in zip files. (#287) 163 | 164 | 165 | v6.0.1 166 | ====== 167 | 168 | Bugfixes 169 | -------- 170 | 171 | - Restored Apache license. (#285) 172 | 173 | 174 | v6.0.0 175 | ====== 176 | 177 | Deprecations and Removals 178 | ------------------------- 179 | 180 | - Removed legacy functions deprecated in 5.3. (#80) 181 | 182 | 183 | v5.13.0 184 | ======= 185 | 186 | Features 187 | -------- 188 | 189 | - Require Python 3.8 or later. 190 | 191 | 192 | v5.12.0 193 | ======= 194 | 195 | * #257: ``importlib_resources`` (backport) now gives 196 | precedence to built-in readers (file system, zip, 197 | namespace packages), providing forward-compatibility 198 | of behaviors like ``MultiplexedPath``. 199 | 200 | v5.11.1 201 | ======= 202 | 203 | v5.10.4 204 | ======= 205 | 206 | * #280: Fixed one more ``EncodingWarning`` in test suite. 207 | 208 | v5.11.0 209 | ======= 210 | 211 | * #265: ``MultiplexedPath`` now honors multiple subdirectories 212 | in ``iterdir`` and ``joinpath``. 213 | 214 | v5.10.3 215 | ======= 216 | 217 | * Packaging refresh, including fixing EncodingWarnings 218 | and some tests cleanup. 219 | 220 | v5.10.2 221 | ======= 222 | 223 | * #274: Prefer ``write_bytes`` to context manager as 224 | proposed in gh-100586. 225 | 226 | v5.10.1 227 | ======= 228 | 229 | * #274: Fixed ``ResourceWarning`` in ``_common``. 230 | 231 | v5.10.0 232 | ======= 233 | 234 | * #203: Lifted restriction on modules passed to ``files``. 235 | Now modules need not be a package and if a non-package 236 | module is passed, resources will be resolved adjacent to 237 | those modules, even for modules not found in any package. 238 | For example, ``files(import_module('mod.py'))`` will 239 | resolve resources found at the root. The parameter to 240 | files was renamed from 'package' to 'anchor', with a 241 | compatibility shim for those passing by keyword. 242 | 243 | * #259: ``files`` no longer requires the anchor to be 244 | specified and can infer the anchor from the caller's scope 245 | (defaults to the caller's module). 246 | 247 | v5.9.0 248 | ====== 249 | 250 | * #228: ``as_file`` now also supports a ``Traversable`` 251 | representing a directory and (when needed) renders the 252 | full tree to a temporary directory. 253 | 254 | v5.8.1 255 | ====== 256 | 257 | * #253: In ``MultiplexedPath``, restore expectation that 258 | a compound path with a non-existent directory does not 259 | raise an exception. 260 | 261 | v5.8.0 262 | ====== 263 | 264 | * #250: Now ``Traversable.joinpath`` provides a concrete 265 | implementation, replacing the implementation in ``.simple`` 266 | and converging with the behavior in ``MultiplexedPath``. 267 | 268 | v5.7.1 269 | ====== 270 | 271 | * #249: In ``simple.ResourceContainer.joinpath``, honor 272 | names split by ``posixpath.sep``. 273 | 274 | v5.7.0 275 | ====== 276 | 277 | * #248: ``abc.Traversable.joinpath`` now allows for multiple 278 | arguments and specifies that ``posixpath.sep`` is allowed 279 | in any argument to accept multiple arguments, matching the 280 | behavior found in ``zipfile.Path`` and ``pathlib.Path``. 281 | 282 | ``simple.ResourceContainer`` now honors this behavior. 283 | 284 | v5.6.0 285 | ====== 286 | 287 | * #244: Add type declarations in ABCs. 288 | 289 | v5.5.0 290 | ====== 291 | 292 | * Require Python 3.7 or later. 293 | * #243: Fix error when no ``__pycache__`` directories exist 294 | when testing ``update-zips``. 295 | 296 | v5.4.0 297 | ====== 298 | 299 | * #80: Test suite now relies entirely on the traversable 300 | API. 301 | 302 | v5.3.0 303 | ====== 304 | 305 | * #80: Now raise a ``DeprecationWarning`` for all legacy 306 | functions. Instead, users should rely on the ``files()`` 307 | API introduced in importlib_resources 1.3. See 308 | `Migrating from Legacy `_ 309 | for guidance on avoiding the deprecated functions. 310 | 311 | v5.2.3 312 | ====== 313 | 314 | * Updated readme to reflect current behavior and show 315 | which versions correspond to which behavior in CPython. 316 | 317 | v5.0.7 318 | ====== 319 | 320 | * bpo-45419: Correct ``DegenerateFiles.Path`` ``.name`` 321 | and ``.open()`` interfaces to match ``Traversable``. 322 | 323 | v5.2.2 324 | ====== 325 | 326 | * #234: Fix refleak in ``as_file`` caught by CPython tests. 327 | 328 | v5.2.1 329 | ====== 330 | 331 | * bpo-38291: Avoid DeprecationWarning on ``typing.io``. 332 | 333 | v5.2.0 334 | ====== 335 | 336 | * #80 via #221: Legacy API (``path``, ``contents``, ...) 337 | is now supported entirely by the ``.files()`` API with 338 | a compatibility shim supplied for resource loaders without 339 | that functionality. 340 | 341 | v5.0.6 342 | ====== 343 | 344 | * bpo-38693: Prefer f-strings to ``.format`` calls. 345 | 346 | v5.1.4 347 | ====== 348 | 349 | * #225: Require 350 | `zipp 3.1.0 `_ 351 | or later on Python prior to 3.10 to incorporate those fixes. 352 | 353 | v5.0.5 354 | ====== 355 | 356 | * #216: Make MultiplexedPath.name a property per the 357 | spec. 358 | 359 | v5.1.3 360 | ====== 361 | 362 | * Refresh packaging and improve tests. 363 | * #216: Make MultiplexedPath.name a property per the 364 | spec. 365 | 366 | v5.1.2 367 | ====== 368 | 369 | * Re-release with changes from 5.0.4. 370 | 371 | v5.0.4 372 | ====== 373 | 374 | * Fixed non-hermetic test in test_reader, revealed by 375 | GH-24670. 376 | 377 | v5.1.1 378 | ====== 379 | 380 | * Re-release with changes from 5.0.3. 381 | 382 | v5.0.3 383 | ====== 384 | 385 | * Simplified DegenerateFiles.Path. 386 | 387 | v5.0.2 388 | ====== 389 | 390 | * #214: Added ``_adapters`` module to ensure that degenerate 391 | ``files`` behavior can be made available for legacy loaders 392 | whose resource readers don't implement it. Fixes issue where 393 | backport compatibility module was masking this fallback 394 | behavior only to discover the defect when applying changes to 395 | CPython. 396 | 397 | v5.1.0 398 | ====== 399 | 400 | * Added ``simple`` module implementing adapters from 401 | a low-level resource reader interface to a 402 | ``TraversableResources`` interface. Closes #90. 403 | 404 | v5.0.1 405 | ====== 406 | 407 | * Remove pyinstaller hook for hidden 'trees' module. 408 | 409 | v5.0.0 410 | ====== 411 | 412 | * Removed ``importlib_resources.trees``, deprecated since 1.3.0. 413 | 414 | v4.1.1 415 | ====== 416 | 417 | * Fixed badges in README. 418 | 419 | v4.1.0 420 | ====== 421 | 422 | * #209: Adopt 423 | `jaraco/skeleton `_. 424 | 425 | * Cleaned up some straggling Python 2 compatibility code. 426 | 427 | * Refreshed test zip files without .pyc and .pyo files. 428 | 429 | v4.0.0 430 | ====== 431 | 432 | * #108: Drop support for Python 2.7. Now requires Python 3.6+. 433 | 434 | v3.3.1 435 | ====== 436 | 437 | * Minor cleanup. 438 | 439 | v3.3.0 440 | ====== 441 | 442 | * #107: Drop support for Python 3.5. Now requires Python 2.7 or 3.6+. 443 | 444 | v3.2.1 445 | ====== 446 | 447 | * #200: Minor fixes and improved tests for namespace package support. 448 | 449 | v3.2.0 450 | ====== 451 | 452 | * #68: Resources in PEP 420 Namespace packages are now supported. 453 | 454 | v3.1.1 455 | ====== 456 | 457 | * bpo-41490: ``contents`` is now also more aggressive about 458 | consuming any iterator from the ``Reader``. 459 | 460 | v3.1.0 461 | ====== 462 | 463 | * #110 and bpo-41490: ``path`` method is more aggressive about 464 | releasing handles to zipfile objects early, enabling use-cases 465 | like ``certifi`` to leave the context open but delete the underlying 466 | zip file. 467 | 468 | v3.0.0 469 | ====== 470 | 471 | * Package no longer exposes ``importlib_resources.__version__``. 472 | Users that wish to inspect the version of ``importlib_resources`` 473 | should instead invoke ``.version('importlib_resources')`` from 474 | ``importlib-metadata`` ( 475 | `stdlib `_ 476 | or `backport `_) 477 | directly. This change eliminates the dependency on 478 | ``importlib_metadata``. Closes #100. 479 | * Package now always includes its data. Closes #93. 480 | * Declare hidden imports for PyInstaller. Closes #101. 481 | 482 | v2.0.1 483 | ====== 484 | 485 | * Select pathlib and contextlib imports based on Python version 486 | and avoid pulling in deprecated 487 | [pathlib](https://pypi.org/project/pathlib). Closes #97. 488 | 489 | v2.0.0 490 | ====== 491 | 492 | * Loaders are no longer expected to implement the 493 | ``abc.TraversableResources`` interface, but are instead 494 | expected to return ``TraversableResources`` from their 495 | ``get_resource_reader`` method. 496 | 497 | v1.5.0 498 | ====== 499 | 500 | * Traversable is now a Protocol instead of an Abstract Base 501 | Class (Python 2.7 and Python 3.8+). 502 | 503 | * Traversable objects now require a ``.name`` property. 504 | 505 | v1.4.0 506 | ====== 507 | 508 | * #79: Temporary files created will now reflect the filename of 509 | their origin. 510 | 511 | v1.3.1 512 | ====== 513 | 514 | * For improved compatibility, ``importlib_resources.trees`` is 515 | now imported implicitly. Closes #88. 516 | 517 | v1.3.0 518 | ====== 519 | 520 | * Add extensibility support for non-standard loaders to supply 521 | ``Traversable`` resources. Introduces a new abstract base 522 | class ``abc.TraversableResources`` that supersedes (but 523 | implements for compatibility) ``abc.ResourceReader``. Any 524 | loader that implements (implicitly or explicitly) the 525 | ``TraversableResources.files`` method will be capable of 526 | supplying resources with subdirectory support. Closes #77. 527 | * Preferred way to access ``as_file`` is now from top-level module. 528 | ``importlib_resources.trees.as_file`` is deprecated and discouraged. 529 | Closes #86. 530 | * Moved ``Traversable`` abc to ``abc`` module. Closes #87. 531 | 532 | v1.2.0 533 | ====== 534 | 535 | * Traversable now requires an ``open`` method. Closes #81. 536 | * Fixed error on ``Python 3.5.{0,3}``. Closes #83. 537 | * Updated packaging to resolve version from package metadata. 538 | Closes #82. 539 | 540 | v1.1.0 541 | ====== 542 | 543 | * Add support for retrieving resources from subdirectories of packages 544 | through the new ``files()`` function, which returns a ``Traversable`` 545 | object with ``joinpath`` and ``read_*`` interfaces matching those 546 | of ``pathlib.Path`` objects. This new function supersedes all of the 547 | previous functionality as it provides a more general-purpose access 548 | to a package's resources. 549 | 550 | With this function, subdirectories are supported (Closes #58). 551 | 552 | The 553 | documentation has been updated to reflect that this function is now 554 | the preferred interface for loading package resources. It does not, 555 | however, support resources from arbitrary loaders. It currently only 556 | supports resources from file system path and zipfile packages (a 557 | consequence of the ResourceReader interface only operating on 558 | Python packages). 559 | 560 | 1.0.2 561 | ===== 562 | 563 | * Fix ``setup_requires`` and ``install_requires`` metadata in ``setup.cfg``. 564 | Given by Anthony Sottile. 565 | 566 | 1.0.1 567 | ===== 568 | 569 | * Update Trove classifiers. Closes #63 570 | 571 | 1.0 572 | === 573 | 574 | * Backport fix for test isolation from Python 3.8/3.7. Closes #61 575 | 576 | 0.8 577 | === 578 | 579 | * Strip ``importlib_resources.__version__``. Closes #56 580 | * Fix a metadata problem with older setuptools. Closes #57 581 | * Add an ``__all__`` to ``importlib_resources``. Closes #59 582 | 583 | 0.7 584 | === 585 | 586 | * Fix ``setup.cfg`` metadata bug. Closes #55 587 | 588 | 0.6 589 | === 590 | 591 | * Move everything from ``pyproject.toml`` to ``setup.cfg``, with the added 592 | benefit of fixing the PyPI metadata. Closes #54 593 | * Turn off mypy's ``strict_optional`` setting for now. 594 | 595 | 0.5 596 | === 597 | 598 | * Resynchronize with Python 3.7; changes the return type of ``contents()`` to 599 | be an ``Iterable``. Closes #52 600 | 601 | 0.4 602 | === 603 | 604 | * Correctly find resources in subpackages inside a zip file. Closes #51 605 | 606 | 0.3 607 | === 608 | 609 | * The API, implementation, and documentation is synchronized with the Python 610 | 3.7 standard library. Closes #47 611 | * When run under Python 3.7 this API shadows the stdlib versions. Closes #50 612 | 613 | 0.2 614 | === 615 | 616 | * **Backward incompatible change**. Split the ``open()`` and ``read()`` calls 617 | into separate binary and text versions, i.e. ``open_binary()``, 618 | ``open_text()``, ``read_binary()``, and ``read_text()``. Closes #41 619 | * Fix a bug where unrelated resources could be returned from ``contents()``. 620 | Closes #44 621 | * Correctly prevent namespace packages from containing resources. Closes #20 622 | 623 | 0.1 624 | === 625 | 626 | * Initial release. 627 | 628 | 629 | .. 630 | Local Variables: 631 | mode: change-log-mode 632 | indent-tabs-mode: nil 633 | sentence-end-double-space: t 634 | fill-column: 78 635 | coding: utf-8 636 | End: 637 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/importlib_resources.svg 2 | :target: https://pypi.org/project/importlib_resources 3 | 4 | .. image:: https://img.shields.io/pypi/pyversions/importlib_resources.svg 5 | 6 | .. image:: https://github.com/python/importlib_resources/actions/workflows/main.yml/badge.svg 7 | :target: https://github.com/python/importlib_resources/actions?query=workflow%3A%22tests%22 8 | :alt: tests 9 | 10 | .. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json 11 | :target: https://github.com/astral-sh/ruff 12 | :alt: Ruff 13 | 14 | .. image:: https://readthedocs.org/projects/importlib-resources/badge/?version=latest 15 | :target: https://importlib-resources.readthedocs.io/en/latest/?badge=latest 16 | 17 | .. image:: https://img.shields.io/badge/skeleton-2025-informational 18 | :target: https://blog.jaraco.com/skeleton 19 | 20 | .. image:: https://tidelift.com/badges/package/pypi/importlib-resources 21 | :target: https://tidelift.com/subscription/pkg/pypi-importlib-resources?utm_source=pypi-importlib-resources&utm_medium=readme 22 | 23 | ``importlib_resources`` is a backport of Python standard library 24 | `importlib.resources 25 | `_ 26 | module for older Pythons. 27 | 28 | The key goal of this module is to replace parts of `pkg_resources 29 | `_ with a 30 | solution in Python's stdlib that relies on well-defined APIs. This makes 31 | reading resources included in packages easier, with more stable and consistent 32 | semantics. 33 | 34 | Compatibility 35 | ============= 36 | 37 | New features are introduced in this third-party library and later merged 38 | into CPython. The following table indicates which versions of this library 39 | were contributed to different versions in the standard library: 40 | 41 | .. list-table:: 42 | :header-rows: 1 43 | 44 | * - importlib_resources 45 | - stdlib 46 | * - 6.0 47 | - 3.13 48 | * - 5.12 49 | - 3.12 50 | * - 5.7 51 | - 3.11 52 | * - 5.0 53 | - 3.10 54 | * - 1.3 55 | - 3.9 56 | * - 0.5 (?) 57 | - 3.7 58 | 59 | For Enterprise 60 | ============== 61 | 62 | Available as part of the Tidelift Subscription. 63 | 64 | This project and the maintainers of thousands of other packages are working with Tidelift to deliver one enterprise subscription that covers all of the open source you use. 65 | 66 | `Learn more `_. 67 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Contact 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | token: 5eb1bc45-1b7f-43e6-8bc1-f2b02833dba9 3 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | API Reference 3 | ============= 4 | 5 | ``importlib_resources`` module 6 | ------------------------------ 7 | 8 | .. automodule:: importlib_resources 9 | :members: 10 | :undoc-members: 11 | :show-inheritance: 12 | 13 | .. automodule:: importlib_resources.abc 14 | :members: 15 | :undoc-members: 16 | :show-inheritance: 17 | 18 | .. automodule:: importlib_resources.readers 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | .. automodule:: importlib_resources.simple 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | extensions = [ 4 | 'sphinx.ext.autodoc', 5 | 'jaraco.packaging.sphinx', 6 | ] 7 | 8 | master_doc = "index" 9 | html_theme = "furo" 10 | 11 | # Link dates and other references in the changelog 12 | extensions += ['rst.linker'] 13 | link_files = { 14 | '../NEWS.rst': dict( 15 | using=dict(GH='https://github.com'), 16 | replace=[ 17 | dict( 18 | pattern=r'(Issue #|\B#)(?P\d+)', 19 | url='{package_url}/issues/{issue}', 20 | ), 21 | dict( 22 | pattern=r'(?m:^((?Pv?\d+(\.\d+){1,2}))\n[-=]+\n)', 23 | with_scm='{text}\n{rev[timestamp]:%d %b %Y}\n', 24 | ), 25 | dict( 26 | pattern=r'PEP[- ](?P\d+)', 27 | url='https://peps.python.org/pep-{pep_number:0>4}/', 28 | ), 29 | dict( 30 | pattern=r'(python/cpython#|Python #)(?P\d+)', 31 | url='https://github.com/python/cpython/issues/{python}', 32 | ), 33 | dict( 34 | pattern=r'bpo-(?P\d+)', 35 | url='http://bugs.python.org/issue{bpo}', 36 | ), 37 | ], 38 | ), 39 | } 40 | 41 | # Be strict about any broken references 42 | nitpicky = True 43 | nitpick_ignore: list[tuple[str, str]] = [] 44 | 45 | # Include Python intersphinx mapping to prevent failures 46 | # jaraco/skeleton#51 47 | extensions += ['sphinx.ext.intersphinx'] 48 | intersphinx_mapping = { 49 | 'python': ('https://docs.python.org/3', None), 50 | } 51 | 52 | # Preserve authored syntax for defaults 53 | autodoc_preserve_defaults = True 54 | 55 | # Add support for linking usernames, PyPI projects, Wikipedia pages 56 | github_url = 'https://github.com/' 57 | extlinks = { 58 | 'user': (f'{github_url}%s', '@%s'), 59 | 'pypi': ('https://pypi.org/project/%s', '%s'), 60 | 'wiki': ('https://wikipedia.org/wiki/%s', '%s'), 61 | } 62 | extensions += ['sphinx.ext.extlinks'] 63 | 64 | # local 65 | 66 | extensions += ['jaraco.tidelift'] 67 | 68 | nitpick_ignore.extend([ 69 | ('py:class', 'module'), 70 | ('py:class', '_io.BufferedReader'), 71 | ]) 72 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | :tocdepth: 2 2 | 3 | .. _changes: 4 | 5 | History 6 | ******* 7 | 8 | .. include:: ../NEWS (links).rst 9 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to |project| documentation! 2 | =================================== 3 | 4 | .. sidebar-links:: 5 | :home: 6 | :pypi: 7 | 8 | ``importlib_resources`` is a library which provides for access to *resources* 9 | in Python packages. It provides functionality similar to ``pkg_resources`` 10 | `Basic Resource Access`_ API, but without all of the overhead and performance 11 | problems of ``pkg_resources``. 12 | 13 | In our terminology, a *resource* is a file tree that is located alongside an 14 | importable `Python module`_. Resources can live on the file system or in a 15 | zip file, with support for other loader_ classes that implement the appropriate 16 | API for reading resources. 17 | 18 | ``importlib_resources`` supplies a backport of :mod:`importlib.resources`, 19 | enabling early access to features of future Python versions and making 20 | functionality available for older Python versions. Users are encouraged to 21 | use the Python standard library where suitable and fall back to 22 | this library for future compatibility. Developers looking for detailed API 23 | descriptions should refer to the standard library documentation. 24 | 25 | The documentation here includes a general :ref:`usage ` guide and a 26 | :ref:`migration ` guide for projects that want to adopt 27 | ``importlib_resources`` instead of ``pkg_resources``. 28 | 29 | 30 | .. toctree:: 31 | :maxdepth: 2 32 | :caption: Contents: 33 | 34 | using 35 | api 36 | migration 37 | history 38 | 39 | .. tidelift-referral-banner:: 40 | 41 | 42 | Indices and tables 43 | ================== 44 | 45 | * :ref:`genindex` 46 | * :ref:`modindex` 47 | * :ref:`search` 48 | 49 | 50 | .. _`Basic Resource Access`: http://setuptools.readthedocs.io/en/latest/pkg_resources.html#basic-resource-access 51 | .. _`Python module`: https://docs.python.org/3/glossary.html#term-module 52 | .. _loader: https://docs.python.org/3/reference/import.html#finders-and-loaders 53 | -------------------------------------------------------------------------------- /docs/migration.rst: -------------------------------------------------------------------------------- 1 | .. _migration: 2 | 3 | ================= 4 | Migration guide 5 | ================= 6 | 7 | The following guide will help you migrate common ``pkg_resources`` APIs to 8 | ``importlib_resources``. Only a small number of the most common APIs are 9 | supported by ``importlib_resources``, so projects that use other features 10 | (e.g. entry points) will have to find other solutions. 11 | ``importlib_resources`` primarily supports the following `basic resource 12 | access`_ APIs: 13 | 14 | * ``pkg_resources.resource_filename()`` 15 | * ``pkg_resources.resource_stream()`` 16 | * ``pkg_resources.resource_string()`` 17 | * ``pkg_resources.resource_listdir()`` 18 | * ``pkg_resources.resource_isdir()`` 19 | 20 | Note that although the steps below provide a drop-in replacement for the 21 | above methods, for many use-cases, a better approach is to use the 22 | ``Traversable`` path from ``files()`` directly. 23 | 24 | 25 | pkg_resources.resource_filename() 26 | ================================= 27 | 28 | ``resource_filename()`` is one of the more interesting APIs because it 29 | guarantees that the return value names a file on the file system. This means 30 | that if the resource is in a zip file, ``pkg_resources`` will extract the 31 | file and return the name of the temporary file it created. The problem is 32 | that ``pkg_resources`` also *implicitly* cleans up this temporary file, 33 | without control over its lifetime by the programmer. 34 | 35 | ``importlib_resources`` takes a different approach. Its equivalent API is the 36 | ``files()`` function, which returns a Traversable object implementing a 37 | subset of the 38 | :py:class:`pathlib.Path` interface suitable for reading the contents and 39 | provides a wrapper for creating a temporary file on the system in a 40 | context whose lifetime is managed by the user. Note though 41 | that if the resource is *already* on the file system, ``importlib_resources`` 42 | still returns a context manager, but nothing needs to get cleaned up. 43 | 44 | Here's an example from ``pkg_resources``:: 45 | 46 | path = pkg_resources.resource_filename('my.package', 'resource.dat') 47 | 48 | The best way to convert this is with the following idiom:: 49 | 50 | ref = importlib_resources.files('my.package') / 'resource.dat' 51 | with importlib_resources.as_file(ref) as path: 52 | # Do something with path. After the with-statement exits, any 53 | # temporary file created will be immediately cleaned up. 54 | 55 | That's all fine if you only need the file temporarily, but what if you need it 56 | to stick around for a while? One way of doing this is to use an 57 | :py:class:`contextlib.ExitStack` instance and manage the resource explicitly:: 58 | 59 | from contextlib import ExitStack 60 | file_manager = ExitStack() 61 | ref = importlib_resources.files('my.package') / 'resource.dat' 62 | path = file_manager.enter_context( 63 | importlib_resources.as_file(ref)) 64 | 65 | Now ``path`` will continue to exist until you explicitly call 66 | ``file_manager.close()``. What if you want the file to exist until the 67 | process exits, or you can't pass ``file_manager`` around in your code? Use an 68 | :py:mod:`atexit` handler:: 69 | 70 | import atexit 71 | file_manager = ExitStack() 72 | atexit.register(file_manager.close) 73 | ref = importlib_resources.files('my.package') / 'resource.dat' 74 | path = file_manager.enter_context( 75 | importlib_resources.as_file(ref)) 76 | 77 | Assuming your Python interpreter exits gracefully, the temporary file will be 78 | cleaned up when Python exits. 79 | 80 | 81 | pkg_resources.resource_stream() 82 | =============================== 83 | 84 | ``pkg_resources.resource_stream()`` returns a readable file-like object opened 85 | in binary mode. When you read from the returned file-like object, you get 86 | bytes. E.g.:: 87 | 88 | with pkg_resources.resource_stream('my.package', 'resource.dat') as fp: 89 | my_bytes = fp.read() 90 | 91 | The equivalent code in ``importlib_resources`` is pretty straightforward:: 92 | 93 | ref = importlib_resources.files('my.package').joinpath('resource.dat') 94 | with ref.open('rb') as fp: 95 | my_bytes = fp.read() 96 | 97 | 98 | pkg_resources.resource_string() 99 | =============================== 100 | 101 | In Python 2, ``pkg_resources.resource_string()`` returns the contents of a 102 | resource as a ``str``. In Python 3, this function is a misnomer; it actually 103 | returns the contents of the named resource as ``bytes``. That's why the 104 | following example is often written for clarity as:: 105 | 106 | from pkg_resources import resource_string as resource_bytes 107 | contents = resource_bytes('my.package', 'resource.dat') 108 | 109 | This can be easily rewritten like so:: 110 | 111 | ref = importlib_resources.files('my.package').joinpath('resource.dat') 112 | contents = ref.read_bytes() 113 | 114 | 115 | pkg_resources.resource_listdir() 116 | ================================ 117 | 118 | This function lists the entries in the package, both files and directories, 119 | but it does not recurse into subdirectories, e.g.:: 120 | 121 | for entry in pkg_resources.resource_listdir('my.package', 'subpackage'): 122 | print(entry) 123 | 124 | This is easily rewritten using the following idiom:: 125 | 126 | for entry in importlib_resources.files('my.package.subpackage').iterdir(): 127 | print(entry.name) 128 | 129 | Note: 130 | 131 | * ``Traversable.iterdir()`` returns *all* the entries in the 132 | subpackage, i.e. both resources (files) and non-resources (directories). 133 | * ``Traversable.iterdir()`` returns additional traversable objects, which if 134 | directories can also be iterated over (recursively). 135 | * ``Traversable.iterdir()``, like ``pathlib.Path`` returns an iterator, not a 136 | concrete sequence. 137 | * The order in which the elements are returned is undefined. 138 | 139 | 140 | pkg_resources.resource_isdir() 141 | ============================== 142 | 143 | You can ask ``pkg_resources`` to tell you whether a particular resource inside 144 | a package is a directory or not:: 145 | 146 | if pkg_resources.resource_isdir('my.package', 'resource'): 147 | print('A directory') 148 | 149 | The ``importlib_resources`` equivalent is straightforward:: 150 | 151 | if importlib_resources.files('my.package').joinpath('resource').is_dir(): 152 | print('A directory') 153 | 154 | 155 | .. _`basic resource access`: http://setuptools.readthedocs.io/en/latest/pkg_resources.html#basic-resource-access 156 | -------------------------------------------------------------------------------- /docs/using.rst: -------------------------------------------------------------------------------- 1 | .. _using: 2 | 3 | =========================== 4 | Using importlib_resources 5 | =========================== 6 | 7 | ``importlib_resources`` is a library that leverages Python's import system to 8 | provide access to *resources* within *packages* and alongside *modules*. Given 9 | that this library is built on top of the import system, it is highly efficient 10 | and easy to use. This library's philosophy is that, if one can import a 11 | module, one can access resources associated with that module. Resources can be 12 | opened or read, in either binary or text mode. 13 | 14 | What exactly do we mean by "a resource"? It's easiest to think about the 15 | metaphor of files and directories on the file system, though it's important to 16 | keep in mind that this is just a metaphor. Resources and packages **do not** 17 | have to exist as physical files and directories on the file system. 18 | 19 | If you have a file system layout such as:: 20 | 21 | data/ 22 | __init__.py 23 | one/ 24 | __init__.py 25 | resource1.txt 26 | module1.py 27 | resources1/ 28 | resource1.1.txt 29 | two/ 30 | __init__.py 31 | resource2.txt 32 | standalone.py 33 | resource3.txt 34 | 35 | then the directories are ``data``, ``data/one``, and ``data/two``. Each of 36 | these are also Python packages by virtue of the fact that they all contain 37 | ``__init__.py`` files. That means that in Python, all of these import 38 | statements work:: 39 | 40 | import data 41 | import data.one 42 | from data import two 43 | 44 | Each import statement gives you a Python *module* corresponding to the 45 | ``__init__.py`` file in each of the respective directories. These modules are 46 | packages since packages are just special module instances that have an 47 | additional attribute, namely a ``__path__`` [#fn1]_. 48 | 49 | In this analogy then, resources are just files or directories contained in a 50 | package directory, so 51 | ``data/one/resource1.txt`` and ``data/two/resource2.txt`` are both resources, 52 | as are the ``__init__.py`` files in all the directories. 53 | 54 | Resources in packages are always accessed relative to the package that they 55 | live in. ``resource1.txt`` and ``resources1/resource1.1.txt`` are resources 56 | within the ``data.one`` package, and ``two/resource2.txt`` is a resource 57 | within the ``data`` package. 58 | 59 | Resources may also be referenced relative to another *anchor*, a module in a 60 | package (``data.one.module1``) or a standalone module (``standalone``). In 61 | this case, resources are loaded from the same loader that loaded that module. 62 | 63 | 64 | Example 65 | ======= 66 | 67 | Let's say you are writing an email parsing library and in your test suite you 68 | have a sample email message in a file called ``message.eml``. You would like 69 | to access the contents of this file for your tests, so you put this in your 70 | project under the ``email/tests/data/message.eml`` path. Let's say your unit 71 | tests live in ``email/tests/test_email.py``. 72 | 73 | Your test could read the data file by doing something like:: 74 | 75 | data_dir = os.path.join(os.path.dirname(__file__), 'tests', 'data') 76 | data_path = os.path.join(data_dir, 'message.eml') 77 | with open(data_path, encoding='utf-8') as fp: 78 | eml = fp.read() 79 | 80 | But there's a problem with this! The use of ``__file__`` doesn't work if your 81 | package lives inside a zip file, since in that case this code does not live on 82 | the file system. 83 | 84 | You could use the `pkg_resources API`_ like so:: 85 | 86 | # In Python 3, resource_string() actually returns bytes! 87 | from pkg_resources import resource_string as resource_bytes 88 | eml = resource_bytes('email.tests.data', 'message.eml').decode('utf-8') 89 | 90 | This requires you to make Python packages of both ``email/tests`` and 91 | ``email/tests/data``, by placing an empty ``__init__.py`` files in each of 92 | those directories. 93 | 94 | The problem with the ``pkg_resources`` approach is that, depending on the 95 | packages in your environment, ``pkg_resources`` can be expensive 96 | just to import. This behavior 97 | can have a serious negative impact on things like command line startup time 98 | for Python implement commands. 99 | 100 | ``importlib_resources`` solves this performance challenge by being built 101 | entirely on the back of the 102 | stdlib :py:mod:`importlib`. By taking advantage of all the efficiencies in 103 | Python's import system, and the fact that it's built into Python, using 104 | ``importlib_resources`` can be much more performant. The equivalent code 105 | using ``importlib_resources`` would look like:: 106 | 107 | from importlib_resources import files 108 | # Reads contents with UTF-8 encoding and returns str. 109 | eml = files('email.tests.data').joinpath('message.eml').read_text() 110 | 111 | 112 | Anchors 113 | ======= 114 | 115 | The ``importlib_resources`` ``files`` API takes an *anchor* as its first 116 | parameter, which can either be a package name (as a ``str``) or an actual 117 | module object. If a string is passed in, it must name an importable Python 118 | module, which is imported prior to loading any resources. Thus the above 119 | example could also be written as:: 120 | 121 | import email.tests.data 122 | eml = files(email.tests.data).joinpath('message.eml').read_text() 123 | 124 | 125 | Namespace Packages 126 | ================== 127 | 128 | ``importlib_resources`` supports namespace packages as anchors just like 129 | any other package. Similar to modules in a namespace package, 130 | resources in a namespace package are not allowed to collide by name. 131 | For example, if two packages both expose ``nspkg/data/foo.txt``, those 132 | resources are unsupported by this library. The package will also likely 133 | experience problems due to the collision with installers. 134 | 135 | It's perfectly valid, however, for two packages to present different resources 136 | in the same namespace package, regular package, or subdirectory. 137 | For example, one package could expose ``nspkg/data/foo.txt`` and another 138 | expose ``nspkg/data/bar.txt`` and those two packages could be installed 139 | into separate paths, and the resources should be queryable:: 140 | 141 | data = importlib_resources.files('nspkg').joinpath('data') 142 | data.joinpath('foo.txt').read_text() 143 | data.joinpath('bar.txt').read_text() 144 | 145 | 146 | File system or zip file 147 | ======================= 148 | 149 | A consumer need not worry whether any given package is on the file system 150 | or in a zip file, as the ``importlib_resources`` APIs abstracts those details. 151 | Sometimes though, the user needs a path to an actual file on the file system. 152 | For example, some SSL APIs require a certificate file to be specified by a 153 | real file system path, and C's ``dlopen()`` function also requires a real file 154 | system path. 155 | 156 | To support this need, ``importlib_resources`` provides an API to extract the 157 | resource from a zip file to a temporary file or folder and return the file 158 | system path to this materialized resource as a :py:class:`pathlib.Path` 159 | object. In order to properly clean up this temporary file, what's actually 160 | returned is a context manager for use in a ``with``-statement:: 161 | 162 | from importlib_resources import files, as_file 163 | 164 | source = files(email.tests.data).joinpath('message.eml') 165 | with as_file(source) as eml: 166 | third_party_api_requiring_file_system_path(eml) 167 | 168 | Use all the standard :py:mod:`contextlib` APIs to manage this context manager. 169 | 170 | 171 | Migrating from Legacy 172 | ===================== 173 | 174 | Starting with Python 3.9 and ``importlib_resources`` 1.4, this package 175 | introduced the ``files()`` API, to be preferred over the legacy API, 176 | i.e. the functions ``open_binary``, ``open_text``, ``path``, 177 | ``contents``, ``read_text``, ``read_binary``, and ``is_resource``. 178 | 179 | To port to the ``files()`` API, refer to the 180 | `_legacy module `_ 181 | to see simple wrappers that enable drop-in replacement based on the 182 | preferred API, and either copy those or adapt the usage to utilize the 183 | ``files`` and 184 | `Traversable `_ 185 | interfaces directly. 186 | 187 | 188 | Extending 189 | ========= 190 | 191 | Starting with Python 3.9 and ``importlib_resources`` 2.0, this package 192 | provides an interface for non-standard loaders, such as those used by 193 | executable bundlers, to supply resources. These loaders should supply a 194 | ``get_resource_reader`` method, which is passed a module name and 195 | should return a ``TraversableResources`` instance. 196 | 197 | 198 | .. rubric:: Footnotes 199 | 200 | .. [#fn1] As of `PEP 451 `_ this 201 | information is also available on the module's 202 | ``__spec__.submodule_search_locations`` attribute, which will not be 203 | ``None`` for packages. 204 | 205 | .. _`pkg_resources API`: http://setuptools.readthedocs.io/en/latest/pkg_resources.html#basic-resource-access 206 | .. _`loader`: https://docs.python.org/3/reference/import.html#finders-and-loaders 207 | .. _`ResourceReader`: https://docs.python.org/3.7/library/importlib.html#importlib.abc.ResourceReader 208 | -------------------------------------------------------------------------------- /importlib_resources/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Read resources contained within a package. 3 | 4 | This codebase is shared between importlib.resources in the stdlib 5 | and importlib_resources in PyPI. See 6 | https://github.com/python/importlib_metadata/wiki/Development-Methodology 7 | for more detail. 8 | """ 9 | 10 | from ._common import ( 11 | Anchor, 12 | Package, 13 | as_file, 14 | files, 15 | ) 16 | from ._functional import ( 17 | contents, 18 | is_resource, 19 | open_binary, 20 | open_text, 21 | path, 22 | read_binary, 23 | read_text, 24 | ) 25 | from .abc import ResourceReader 26 | 27 | __all__ = [ 28 | 'Package', 29 | 'Anchor', 30 | 'ResourceReader', 31 | 'as_file', 32 | 'files', 33 | 'contents', 34 | 'is_resource', 35 | 'open_binary', 36 | 'open_text', 37 | 'path', 38 | 'read_binary', 39 | 'read_text', 40 | ] 41 | -------------------------------------------------------------------------------- /importlib_resources/_adapters.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | from io import TextIOWrapper 3 | 4 | from . import abc 5 | 6 | 7 | class SpecLoaderAdapter: 8 | """ 9 | Adapt a package spec to adapt the underlying loader. 10 | """ 11 | 12 | def __init__(self, spec, adapter=lambda spec: spec.loader): 13 | self.spec = spec 14 | self.loader = adapter(spec) 15 | 16 | def __getattr__(self, name): 17 | return getattr(self.spec, name) 18 | 19 | 20 | class TraversableResourcesLoader: 21 | """ 22 | Adapt a loader to provide TraversableResources. 23 | """ 24 | 25 | def __init__(self, spec): 26 | self.spec = spec 27 | 28 | def get_resource_reader(self, name): 29 | return CompatibilityFiles(self.spec)._native() 30 | 31 | 32 | def _io_wrapper(file, mode='r', *args, **kwargs): 33 | if mode == 'r': 34 | return TextIOWrapper(file, *args, **kwargs) 35 | elif mode == 'rb': 36 | return file 37 | raise ValueError(f"Invalid mode value '{mode}', only 'r' and 'rb' are supported") 38 | 39 | 40 | class CompatibilityFiles: 41 | """ 42 | Adapter for an existing or non-existent resource reader 43 | to provide a compatibility .files(). 44 | """ 45 | 46 | class SpecPath(abc.Traversable): 47 | """ 48 | Path tied to a module spec. 49 | Can be read and exposes the resource reader children. 50 | """ 51 | 52 | def __init__(self, spec, reader): 53 | self._spec = spec 54 | self._reader = reader 55 | 56 | def iterdir(self): 57 | if not self._reader: 58 | return iter(()) 59 | return iter( 60 | CompatibilityFiles.ChildPath(self._reader, path) 61 | for path in self._reader.contents() 62 | ) 63 | 64 | def is_file(self): 65 | return False 66 | 67 | is_dir = is_file 68 | 69 | def joinpath(self, other): 70 | if not self._reader: 71 | return CompatibilityFiles.OrphanPath(other) 72 | return CompatibilityFiles.ChildPath(self._reader, other) 73 | 74 | @property 75 | def name(self): 76 | return self._spec.name 77 | 78 | def open(self, mode='r', *args, **kwargs): 79 | return _io_wrapper(self._reader.open_resource(None), mode, *args, **kwargs) 80 | 81 | class ChildPath(abc.Traversable): 82 | """ 83 | Path tied to a resource reader child. 84 | Can be read but doesn't expose any meaningful children. 85 | """ 86 | 87 | def __init__(self, reader, name): 88 | self._reader = reader 89 | self._name = name 90 | 91 | def iterdir(self): 92 | return iter(()) 93 | 94 | def is_file(self): 95 | return self._reader.is_resource(self.name) 96 | 97 | def is_dir(self): 98 | return not self.is_file() 99 | 100 | def joinpath(self, other): 101 | return CompatibilityFiles.OrphanPath(self.name, other) 102 | 103 | @property 104 | def name(self): 105 | return self._name 106 | 107 | def open(self, mode='r', *args, **kwargs): 108 | return _io_wrapper( 109 | self._reader.open_resource(self.name), mode, *args, **kwargs 110 | ) 111 | 112 | class OrphanPath(abc.Traversable): 113 | """ 114 | Orphan path, not tied to a module spec or resource reader. 115 | Can't be read and doesn't expose any meaningful children. 116 | """ 117 | 118 | def __init__(self, *path_parts): 119 | if len(path_parts) < 1: 120 | raise ValueError('Need at least one path part to construct a path') 121 | self._path = path_parts 122 | 123 | def iterdir(self): 124 | return iter(()) 125 | 126 | def is_file(self): 127 | return False 128 | 129 | is_dir = is_file 130 | 131 | def joinpath(self, other): 132 | return CompatibilityFiles.OrphanPath(*self._path, other) 133 | 134 | @property 135 | def name(self): 136 | return self._path[-1] 137 | 138 | def open(self, mode='r', *args, **kwargs): 139 | raise FileNotFoundError("Can't open orphan path") 140 | 141 | def __init__(self, spec): 142 | self.spec = spec 143 | 144 | @property 145 | def _reader(self): 146 | with suppress(AttributeError): 147 | return self.spec.loader.get_resource_reader(self.spec.name) 148 | 149 | def _native(self): 150 | """ 151 | Return the native reader if it supports files(). 152 | """ 153 | reader = self._reader 154 | return reader if hasattr(reader, 'files') else self 155 | 156 | def __getattr__(self, attr): 157 | return getattr(self._reader, attr) 158 | 159 | def files(self): 160 | return CompatibilityFiles.SpecPath(self.spec, self._reader) 161 | 162 | 163 | def wrap_spec(package): 164 | """ 165 | Construct a package spec with traversable compatibility 166 | on the spec/loader/reader. 167 | """ 168 | return SpecLoaderAdapter(package.__spec__, TraversableResourcesLoader) 169 | -------------------------------------------------------------------------------- /importlib_resources/_common.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import functools 3 | import importlib 4 | import inspect 5 | import itertools 6 | import os 7 | import pathlib 8 | import tempfile 9 | import types 10 | import warnings 11 | from typing import Optional, Union, cast 12 | 13 | from .abc import ResourceReader, Traversable 14 | 15 | Package = Union[types.ModuleType, str] 16 | Anchor = Package 17 | 18 | 19 | def package_to_anchor(func): 20 | """ 21 | Replace 'package' parameter as 'anchor' and warn about the change. 22 | 23 | Other errors should fall through. 24 | 25 | >>> files('a', 'b') 26 | Traceback (most recent call last): 27 | TypeError: files() takes from 0 to 1 positional arguments but 2 were given 28 | 29 | Remove this compatibility in Python 3.14. 30 | """ 31 | undefined = object() 32 | 33 | @functools.wraps(func) 34 | def wrapper(anchor=undefined, package=undefined): 35 | if package is not undefined: 36 | if anchor is not undefined: 37 | return func(anchor, package) 38 | warnings.warn( 39 | "First parameter to files is renamed to 'anchor'", 40 | DeprecationWarning, 41 | stacklevel=2, 42 | ) 43 | return func(package) 44 | elif anchor is undefined: 45 | return func() 46 | return func(anchor) 47 | 48 | return wrapper 49 | 50 | 51 | @package_to_anchor 52 | def files(anchor: Optional[Anchor] = None) -> Traversable: 53 | """ 54 | Get a Traversable resource for an anchor. 55 | """ 56 | return from_package(resolve(anchor)) 57 | 58 | 59 | def get_resource_reader(package: types.ModuleType) -> Optional[ResourceReader]: 60 | """ 61 | Return the package's loader if it's a ResourceReader. 62 | """ 63 | # We can't use 64 | # a issubclass() check here because apparently abc.'s __subclasscheck__() 65 | # hook wants to create a weak reference to the object, but 66 | # zipimport.zipimporter does not support weak references, resulting in a 67 | # TypeError. That seems terrible. 68 | spec = package.__spec__ 69 | reader = getattr(spec.loader, 'get_resource_reader', None) # type: ignore[union-attr] 70 | if reader is None: 71 | return None 72 | return reader(spec.name) # type: ignore[union-attr] 73 | 74 | 75 | @functools.singledispatch 76 | def resolve(cand: Optional[Anchor]) -> types.ModuleType: 77 | return cast(types.ModuleType, cand) 78 | 79 | 80 | @resolve.register 81 | def _(cand: str) -> types.ModuleType: 82 | return importlib.import_module(cand) 83 | 84 | 85 | @resolve.register 86 | def _(cand: None) -> types.ModuleType: 87 | return resolve(_infer_caller().f_globals['__name__']) 88 | 89 | 90 | def _infer_caller(): 91 | """ 92 | Walk the stack and find the frame of the first caller not in this module. 93 | """ 94 | 95 | def is_this_file(frame_info): 96 | return frame_info.filename == stack[0].filename 97 | 98 | def is_wrapper(frame_info): 99 | return frame_info.function == 'wrapper' 100 | 101 | stack = inspect.stack() 102 | not_this_file = itertools.filterfalse(is_this_file, stack) 103 | # also exclude 'wrapper' due to singledispatch in the call stack 104 | callers = itertools.filterfalse(is_wrapper, not_this_file) 105 | return next(callers).frame 106 | 107 | 108 | def from_package(package: types.ModuleType): 109 | """ 110 | Return a Traversable object for the given package. 111 | 112 | """ 113 | # deferred for performance (python/cpython#109829) 114 | from .future.adapters import wrap_spec 115 | 116 | spec = wrap_spec(package) 117 | reader = spec.loader.get_resource_reader(spec.name) 118 | return reader.files() 119 | 120 | 121 | @contextlib.contextmanager 122 | def _tempfile( 123 | reader, 124 | suffix='', 125 | # gh-93353: Keep a reference to call os.remove() in late Python 126 | # finalization. 127 | *, 128 | _os_remove=os.remove, 129 | ): 130 | # Not using tempfile.NamedTemporaryFile as it leads to deeper 'try' 131 | # blocks due to the need to close the temporary file to work on Windows 132 | # properly. 133 | fd, raw_path = tempfile.mkstemp(suffix=suffix) 134 | try: 135 | try: 136 | os.write(fd, reader()) 137 | finally: 138 | os.close(fd) 139 | del reader 140 | yield pathlib.Path(raw_path) 141 | finally: 142 | try: 143 | _os_remove(raw_path) 144 | except FileNotFoundError: 145 | pass 146 | 147 | 148 | def _temp_file(path): 149 | return _tempfile(path.read_bytes, suffix=path.name) 150 | 151 | 152 | def _is_present_dir(path: Traversable) -> bool: 153 | """ 154 | Some Traversables implement ``is_dir()`` to raise an 155 | exception (i.e. ``FileNotFoundError``) when the 156 | directory doesn't exist. This function wraps that call 157 | to always return a boolean and only return True 158 | if there's a dir and it exists. 159 | """ 160 | with contextlib.suppress(FileNotFoundError): 161 | return path.is_dir() 162 | return False 163 | 164 | 165 | @functools.singledispatch 166 | def as_file(path): 167 | """ 168 | Given a Traversable object, return that object as a 169 | path on the local file system in a context manager. 170 | """ 171 | return _temp_dir(path) if _is_present_dir(path) else _temp_file(path) 172 | 173 | 174 | @as_file.register(pathlib.Path) 175 | @contextlib.contextmanager 176 | def _(path): 177 | """ 178 | Degenerate behavior for pathlib.Path objects. 179 | """ 180 | yield path 181 | 182 | 183 | @contextlib.contextmanager 184 | def _temp_path(dir: tempfile.TemporaryDirectory): 185 | """ 186 | Wrap tempfile.TemporaryDirectory to return a pathlib object. 187 | """ 188 | with dir as result: 189 | yield pathlib.Path(result) 190 | 191 | 192 | @contextlib.contextmanager 193 | def _temp_dir(path): 194 | """ 195 | Given a traversable dir, recursively replicate the whole tree 196 | to the file system in a context manager. 197 | """ 198 | assert path.is_dir() 199 | with _temp_path(tempfile.TemporaryDirectory()) as temp_dir: 200 | yield _write_contents(temp_dir, path) 201 | 202 | 203 | def _write_contents(target, source): 204 | child = target.joinpath(source.name) 205 | if source.is_dir(): 206 | child.mkdir() 207 | for item in source.iterdir(): 208 | _write_contents(child, item) 209 | else: 210 | child.write_bytes(source.read_bytes()) 211 | return child 212 | -------------------------------------------------------------------------------- /importlib_resources/_functional.py: -------------------------------------------------------------------------------- 1 | """Simplified function-based API for importlib.resources""" 2 | 3 | import warnings 4 | 5 | from ._common import as_file, files 6 | from .abc import TraversalError 7 | 8 | _MISSING = object() 9 | 10 | 11 | def open_binary(anchor, *path_names): 12 | """Open for binary reading the *resource* within *package*.""" 13 | return _get_resource(anchor, path_names).open('rb') 14 | 15 | 16 | def open_text(anchor, *path_names, encoding=_MISSING, errors='strict'): 17 | """Open for text reading the *resource* within *package*.""" 18 | encoding = _get_encoding_arg(path_names, encoding) 19 | resource = _get_resource(anchor, path_names) 20 | return resource.open('r', encoding=encoding, errors=errors) 21 | 22 | 23 | def read_binary(anchor, *path_names): 24 | """Read and return contents of *resource* within *package* as bytes.""" 25 | return _get_resource(anchor, path_names).read_bytes() 26 | 27 | 28 | def read_text(anchor, *path_names, encoding=_MISSING, errors='strict'): 29 | """Read and return contents of *resource* within *package* as str.""" 30 | encoding = _get_encoding_arg(path_names, encoding) 31 | resource = _get_resource(anchor, path_names) 32 | return resource.read_text(encoding=encoding, errors=errors) 33 | 34 | 35 | def path(anchor, *path_names): 36 | """Return the path to the *resource* as an actual file system path.""" 37 | return as_file(_get_resource(anchor, path_names)) 38 | 39 | 40 | def is_resource(anchor, *path_names): 41 | """Return ``True`` if there is a resource named *name* in the package, 42 | 43 | Otherwise returns ``False``. 44 | """ 45 | try: 46 | return _get_resource(anchor, path_names).is_file() 47 | except TraversalError: 48 | return False 49 | 50 | 51 | def contents(anchor, *path_names): 52 | """Return an iterable over the named resources within the package. 53 | 54 | The iterable returns :class:`str` resources (e.g. files). 55 | The iterable does not recurse into subdirectories. 56 | """ 57 | warnings.warn( 58 | "importlib.resources.contents is deprecated. " 59 | "Use files(anchor).iterdir() instead.", 60 | DeprecationWarning, 61 | stacklevel=1, 62 | ) 63 | return (resource.name for resource in _get_resource(anchor, path_names).iterdir()) 64 | 65 | 66 | def _get_encoding_arg(path_names, encoding): 67 | # For compatibility with versions where *encoding* was a positional 68 | # argument, it needs to be given explicitly when there are multiple 69 | # *path_names*. 70 | # This limitation can be removed in Python 3.15. 71 | if encoding is _MISSING: 72 | if len(path_names) > 1: 73 | raise TypeError( 74 | "'encoding' argument required with multiple path names", 75 | ) 76 | else: 77 | return 'utf-8' 78 | return encoding 79 | 80 | 81 | def _get_resource(anchor, path_names): 82 | if anchor is None: 83 | raise TypeError("anchor must be module or string, got None") 84 | return files(anchor).joinpath(*path_names) 85 | -------------------------------------------------------------------------------- /importlib_resources/_itertools.py: -------------------------------------------------------------------------------- 1 | # from more_itertools 9.0 2 | def only(iterable, default=None, too_long=None): 3 | """If *iterable* has only one item, return it. 4 | If it has zero items, return *default*. 5 | If it has more than one item, raise the exception given by *too_long*, 6 | which is ``ValueError`` by default. 7 | >>> only([], default='missing') 8 | 'missing' 9 | >>> only([1]) 10 | 1 11 | >>> only([1, 2]) # doctest: +IGNORE_EXCEPTION_DETAIL 12 | Traceback (most recent call last): 13 | ... 14 | ValueError: Expected exactly one item in iterable, but got 1, 2, 15 | and perhaps more.' 16 | >>> only([1, 2], too_long=TypeError) # doctest: +IGNORE_EXCEPTION_DETAIL 17 | Traceback (most recent call last): 18 | ... 19 | TypeError 20 | Note that :func:`only` attempts to advance *iterable* twice to ensure there 21 | is only one item. See :func:`spy` or :func:`peekable` to check 22 | iterable contents less destructively. 23 | """ 24 | it = iter(iterable) 25 | first_value = next(it, default) 26 | 27 | try: 28 | second_value = next(it) 29 | except StopIteration: 30 | pass 31 | else: 32 | msg = ( 33 | 'Expected exactly one item in iterable, but got {!r}, {!r}, ' 34 | 'and perhaps more.'.format(first_value, second_value) 35 | ) 36 | raise too_long or ValueError(msg) 37 | 38 | return first_value 39 | -------------------------------------------------------------------------------- /importlib_resources/abc.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import itertools 3 | import os 4 | import pathlib 5 | from typing import ( 6 | Any, 7 | BinaryIO, 8 | Iterable, 9 | Iterator, 10 | NoReturn, 11 | Literal, 12 | Optional, 13 | Protocol, 14 | Text, 15 | TextIO, 16 | Union, 17 | overload, 18 | runtime_checkable, 19 | ) 20 | 21 | StrPath = Union[str, os.PathLike[str]] 22 | 23 | __all__ = ["ResourceReader", "Traversable", "TraversableResources"] 24 | 25 | 26 | class ResourceReader(metaclass=abc.ABCMeta): 27 | """Abstract base class for loaders to provide resource reading support.""" 28 | 29 | @abc.abstractmethod 30 | def open_resource(self, resource: Text) -> BinaryIO: 31 | """Return an opened, file-like object for binary reading. 32 | 33 | The 'resource' argument is expected to represent only a file name. 34 | If the resource cannot be found, FileNotFoundError is raised. 35 | """ 36 | # This deliberately raises FileNotFoundError instead of 37 | # NotImplementedError so that if this method is accidentally called, 38 | # it'll still do the right thing. 39 | raise FileNotFoundError 40 | 41 | @abc.abstractmethod 42 | def resource_path(self, resource: Text) -> Text: 43 | """Return the file system path to the specified resource. 44 | 45 | The 'resource' argument is expected to represent only a file name. 46 | If the resource does not exist on the file system, raise 47 | FileNotFoundError. 48 | """ 49 | # This deliberately raises FileNotFoundError instead of 50 | # NotImplementedError so that if this method is accidentally called, 51 | # it'll still do the right thing. 52 | raise FileNotFoundError 53 | 54 | @abc.abstractmethod 55 | def is_resource(self, path: Text) -> bool: 56 | """Return True if the named 'path' is a resource. 57 | 58 | Files are resources, directories are not. 59 | """ 60 | raise FileNotFoundError 61 | 62 | @abc.abstractmethod 63 | def contents(self) -> Iterable[str]: 64 | """Return an iterable of entries in `package`.""" 65 | raise FileNotFoundError 66 | 67 | 68 | class TraversalError(Exception): 69 | pass 70 | 71 | 72 | @runtime_checkable 73 | class Traversable(Protocol): 74 | """ 75 | An object with a subset of pathlib.Path methods suitable for 76 | traversing directories and opening files. 77 | 78 | Any exceptions that occur when accessing the backing resource 79 | may propagate unaltered. 80 | """ 81 | 82 | @abc.abstractmethod 83 | def iterdir(self) -> Iterator["Traversable"]: 84 | """ 85 | Yield Traversable objects in self 86 | """ 87 | 88 | def read_bytes(self) -> bytes: 89 | """ 90 | Read contents of self as bytes 91 | """ 92 | with self.open('rb') as strm: 93 | return strm.read() 94 | 95 | def read_text( 96 | self, encoding: Optional[str] = None, errors: Optional[str] = None 97 | ) -> str: 98 | """ 99 | Read contents of self as text 100 | """ 101 | with self.open(encoding=encoding, errors=errors) as strm: 102 | return strm.read() 103 | 104 | @abc.abstractmethod 105 | def is_dir(self) -> bool: 106 | """ 107 | Return True if self is a directory 108 | """ 109 | 110 | @abc.abstractmethod 111 | def is_file(self) -> bool: 112 | """ 113 | Return True if self is a file 114 | """ 115 | 116 | def joinpath(self, *descendants: StrPath) -> "Traversable": 117 | """ 118 | Return Traversable resolved with any descendants applied. 119 | 120 | Each descendant should be a path segment relative to self 121 | and each may contain multiple levels separated by 122 | ``posixpath.sep`` (``/``). 123 | """ 124 | if not descendants: 125 | return self 126 | names = itertools.chain.from_iterable( 127 | path.parts for path in map(pathlib.PurePosixPath, descendants) 128 | ) 129 | target = next(names) 130 | matches = ( 131 | traversable for traversable in self.iterdir() if traversable.name == target 132 | ) 133 | try: 134 | match = next(matches) 135 | except StopIteration: 136 | raise TraversalError( 137 | "Target not found during traversal.", target, list(names) 138 | ) 139 | return match.joinpath(*names) 140 | 141 | def __truediv__(self, child: StrPath) -> "Traversable": 142 | """ 143 | Return Traversable child in self 144 | """ 145 | return self.joinpath(child) 146 | 147 | @overload 148 | def open(self, mode: Literal['r'] = 'r', *args: Any, **kwargs: Any) -> TextIO: ... 149 | 150 | @overload 151 | def open(self, mode: Literal['rb'], *args: Any, **kwargs: Any) -> BinaryIO: ... 152 | 153 | @abc.abstractmethod 154 | def open( 155 | self, mode: str = 'r', *args: Any, **kwargs: Any 156 | ) -> Union[TextIO, BinaryIO]: 157 | """ 158 | mode may be 'r' or 'rb' to open as text or binary. Return a handle 159 | suitable for reading (same as pathlib.Path.open). 160 | 161 | When opening as text, accepts encoding parameters such as those 162 | accepted by io.TextIOWrapper. 163 | """ 164 | 165 | @property 166 | @abc.abstractmethod 167 | def name(self) -> str: 168 | """ 169 | The base name of this object without any parent references. 170 | """ 171 | 172 | 173 | class TraversableResources(ResourceReader): 174 | """ 175 | The required interface for providing traversable 176 | resources. 177 | """ 178 | 179 | @abc.abstractmethod 180 | def files(self) -> "Traversable": 181 | """Return a Traversable object for the loaded package.""" 182 | 183 | def open_resource(self, resource: StrPath) -> BinaryIO: 184 | return self.files().joinpath(resource).open('rb') 185 | 186 | def resource_path(self, resource: Any) -> NoReturn: 187 | raise FileNotFoundError(resource) 188 | 189 | def is_resource(self, path: StrPath) -> bool: 190 | return self.files().joinpath(path).is_file() 191 | 192 | def contents(self) -> Iterator[str]: 193 | return (item.name for item in self.files().iterdir()) 194 | -------------------------------------------------------------------------------- /importlib_resources/compat/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/importlib_resources/89fcc4bed85b0717dba54cd67e0f71e84b4230fe/importlib_resources/compat/__init__.py -------------------------------------------------------------------------------- /importlib_resources/compat/py39.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | __all__ = ['ZipPath'] 4 | 5 | 6 | if sys.version_info >= (3, 10): 7 | from zipfile import Path as ZipPath 8 | else: 9 | from zipp import Path as ZipPath 10 | -------------------------------------------------------------------------------- /importlib_resources/future/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/importlib_resources/89fcc4bed85b0717dba54cd67e0f71e84b4230fe/importlib_resources/future/__init__.py -------------------------------------------------------------------------------- /importlib_resources/future/adapters.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import pathlib 3 | from contextlib import suppress 4 | from types import SimpleNamespace 5 | 6 | from .. import _adapters, readers 7 | 8 | 9 | def _block_standard(reader_getter): 10 | """ 11 | Wrap _adapters.TraversableResourcesLoader.get_resource_reader 12 | and intercept any standard library readers. 13 | """ 14 | 15 | @functools.wraps(reader_getter) 16 | def wrapper(*args, **kwargs): 17 | """ 18 | If the reader is from the standard library, return None to allow 19 | allow likely newer implementations in this library to take precedence. 20 | """ 21 | try: 22 | reader = reader_getter(*args, **kwargs) 23 | except NotADirectoryError: 24 | # MultiplexedPath may fail on zip subdirectory 25 | return 26 | except ValueError as exc: 27 | # NamespaceReader in stdlib may fail for editable installs 28 | # (python/importlib_resources#311, python/importlib_resources#318) 29 | # Remove after bugfix applied to Python 3.13. 30 | if "not enough values to unpack" not in str(exc): 31 | raise 32 | return 33 | # Python 3.10+ 34 | mod_name = reader.__class__.__module__ 35 | if mod_name.startswith('importlib.') and mod_name.endswith('readers'): 36 | return 37 | # Python 3.8, 3.9 38 | if isinstance(reader, _adapters.CompatibilityFiles) and ( 39 | reader.spec.loader.__class__.__module__.startswith('zipimport') 40 | or reader.spec.loader.__class__.__module__.startswith( 41 | '_frozen_importlib_external' 42 | ) 43 | ): 44 | return 45 | return reader 46 | 47 | return wrapper 48 | 49 | 50 | def _skip_degenerate(reader): 51 | """ 52 | Mask any degenerate reader. Ref #298. 53 | """ 54 | is_degenerate = ( 55 | isinstance(reader, _adapters.CompatibilityFiles) and not reader._reader 56 | ) 57 | return reader if not is_degenerate else None 58 | 59 | 60 | class TraversableResourcesLoader(_adapters.TraversableResourcesLoader): 61 | """ 62 | Adapt loaders to provide TraversableResources and other 63 | compatibility. 64 | 65 | Ensures the readers from importlib_resources are preferred 66 | over stdlib readers. 67 | """ 68 | 69 | def get_resource_reader(self, name): 70 | return ( 71 | _skip_degenerate(_block_standard(super().get_resource_reader)(name)) 72 | or self._standard_reader() 73 | or super().get_resource_reader(name) 74 | ) 75 | 76 | def _standard_reader(self): 77 | return self._zip_reader() or self._namespace_reader() or self._file_reader() 78 | 79 | def _zip_reader(self): 80 | with suppress(AttributeError): 81 | return readers.ZipReader(self.spec.loader, self.spec.name) 82 | 83 | def _namespace_reader(self): 84 | with suppress(AttributeError, ValueError): 85 | return readers.NamespaceReader(self.spec.submodule_search_locations) 86 | 87 | def _file_reader(self): 88 | try: 89 | path = pathlib.Path(self.spec.origin) 90 | except TypeError: 91 | return None 92 | if path.exists(): 93 | return readers.FileReader(SimpleNamespace(path=path)) 94 | 95 | 96 | def wrap_spec(package): 97 | """ 98 | Override _adapters.wrap_spec to use TraversableResourcesLoader 99 | from above. Ensures that future behavior is always available on older 100 | Pythons. 101 | """ 102 | return _adapters.SpecLoaderAdapter(package.__spec__, TraversableResourcesLoader) 103 | -------------------------------------------------------------------------------- /importlib_resources/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/importlib_resources/89fcc4bed85b0717dba54cd67e0f71e84b4230fe/importlib_resources/py.typed -------------------------------------------------------------------------------- /importlib_resources/readers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections 4 | import contextlib 5 | import itertools 6 | import operator 7 | import pathlib 8 | import re 9 | import warnings 10 | from collections.abc import Iterator 11 | 12 | from . import abc 13 | from ._itertools import only 14 | from .compat.py39 import ZipPath 15 | 16 | 17 | def remove_duplicates(items): 18 | return iter(collections.OrderedDict.fromkeys(items)) 19 | 20 | 21 | class FileReader(abc.TraversableResources): 22 | def __init__(self, loader): 23 | self.path = pathlib.Path(loader.path).parent 24 | 25 | def resource_path(self, resource): 26 | """ 27 | Return the file system path to prevent 28 | `resources.path()` from creating a temporary 29 | copy. 30 | """ 31 | return str(self.path.joinpath(resource)) 32 | 33 | def files(self): 34 | return self.path 35 | 36 | 37 | class ZipReader(abc.TraversableResources): 38 | def __init__(self, loader, module): 39 | self.prefix = loader.prefix.replace('\\', '/') 40 | if loader.is_package(module): 41 | _, _, name = module.rpartition('.') 42 | self.prefix += name + '/' 43 | self.archive = loader.archive 44 | 45 | def open_resource(self, resource): 46 | try: 47 | return super().open_resource(resource) 48 | except KeyError as exc: 49 | raise FileNotFoundError(exc.args[0]) 50 | 51 | def is_resource(self, path): 52 | """ 53 | Workaround for `zipfile.Path.is_file` returning true 54 | for non-existent paths. 55 | """ 56 | target = self.files().joinpath(path) 57 | return target.is_file() and target.exists() 58 | 59 | def files(self): 60 | return ZipPath(self.archive, self.prefix) 61 | 62 | 63 | class MultiplexedPath(abc.Traversable): 64 | """ 65 | Given a series of Traversable objects, implement a merged 66 | version of the interface across all objects. Useful for 67 | namespace packages which may be multihomed at a single 68 | name. 69 | """ 70 | 71 | def __init__(self, *paths): 72 | self._paths = list(map(_ensure_traversable, remove_duplicates(paths))) 73 | if not self._paths: 74 | message = 'MultiplexedPath must contain at least one path' 75 | raise FileNotFoundError(message) 76 | if not all(path.is_dir() for path in self._paths): 77 | raise NotADirectoryError('MultiplexedPath only supports directories') 78 | 79 | def iterdir(self): 80 | children = (child for path in self._paths for child in path.iterdir()) 81 | by_name = operator.attrgetter('name') 82 | groups = itertools.groupby(sorted(children, key=by_name), key=by_name) 83 | return map(self._follow, (locs for name, locs in groups)) 84 | 85 | def read_bytes(self): 86 | raise FileNotFoundError(f'{self} is not a file') 87 | 88 | def read_text(self, *args, **kwargs): 89 | raise FileNotFoundError(f'{self} is not a file') 90 | 91 | def is_dir(self): 92 | return True 93 | 94 | def is_file(self): 95 | return False 96 | 97 | def joinpath(self, *descendants): 98 | try: 99 | return super().joinpath(*descendants) 100 | except abc.TraversalError: 101 | # One of the paths did not resolve (a directory does not exist). 102 | # Just return something that will not exist. 103 | return self._paths[0].joinpath(*descendants) 104 | 105 | @classmethod 106 | def _follow(cls, children): 107 | """ 108 | Construct a MultiplexedPath if needed. 109 | 110 | If children contains a sole element, return it. 111 | Otherwise, return a MultiplexedPath of the items. 112 | Unless one of the items is not a Directory, then return the first. 113 | """ 114 | subdirs, one_dir, one_file = itertools.tee(children, 3) 115 | 116 | try: 117 | return only(one_dir) 118 | except ValueError: 119 | try: 120 | return cls(*subdirs) 121 | except NotADirectoryError: 122 | return next(one_file) 123 | 124 | def open(self, *args, **kwargs): 125 | raise FileNotFoundError(f'{self} is not a file') 126 | 127 | @property 128 | def name(self): 129 | return self._paths[0].name 130 | 131 | def __repr__(self): 132 | paths = ', '.join(f"'{path}'" for path in self._paths) 133 | return f'MultiplexedPath({paths})' 134 | 135 | 136 | class NamespaceReader(abc.TraversableResources): 137 | def __init__(self, namespace_path): 138 | if 'NamespacePath' not in str(namespace_path): 139 | raise ValueError('Invalid path') 140 | self.path = MultiplexedPath(*filter(bool, map(self._resolve, namespace_path))) 141 | 142 | @classmethod 143 | def _resolve(cls, path_str) -> abc.Traversable | None: 144 | r""" 145 | Given an item from a namespace path, resolve it to a Traversable. 146 | 147 | path_str might be a directory on the filesystem or a path to a 148 | zipfile plus the path within the zipfile, e.g. ``/foo/bar`` or 149 | ``/foo/baz.zip/inner_dir`` or ``foo\baz.zip\inner_dir\sub``. 150 | 151 | path_str might also be a sentinel used by editable packages to 152 | trigger other behaviors (see python/importlib_resources#311). 153 | In that case, return None. 154 | """ 155 | dirs = (cand for cand in cls._candidate_paths(path_str) if cand.is_dir()) 156 | return next(dirs, None) 157 | 158 | @classmethod 159 | def _candidate_paths(cls, path_str: str) -> Iterator[abc.Traversable]: 160 | yield pathlib.Path(path_str) 161 | yield from cls._resolve_zip_path(path_str) 162 | 163 | @staticmethod 164 | def _resolve_zip_path(path_str: str): 165 | for match in reversed(list(re.finditer(r'[\\/]', path_str))): 166 | with contextlib.suppress( 167 | FileNotFoundError, 168 | IsADirectoryError, 169 | NotADirectoryError, 170 | PermissionError, 171 | ): 172 | inner = path_str[match.end() :].replace('\\', '/') + '/' 173 | yield ZipPath(path_str[: match.start()], inner.lstrip('/')) 174 | 175 | def resource_path(self, resource): 176 | """ 177 | Return the file system path to prevent 178 | `resources.path()` from creating a temporary 179 | copy. 180 | """ 181 | return str(self.path.joinpath(resource)) 182 | 183 | def files(self): 184 | return self.path 185 | 186 | 187 | def _ensure_traversable(path): 188 | """ 189 | Convert deprecated string arguments to traversables (pathlib.Path). 190 | 191 | Remove with Python 3.15. 192 | """ 193 | if not isinstance(path, str): 194 | return path 195 | 196 | warnings.warn( 197 | "String arguments are deprecated. Pass a Traversable instead.", 198 | DeprecationWarning, 199 | stacklevel=3, 200 | ) 201 | 202 | return pathlib.Path(path) 203 | -------------------------------------------------------------------------------- /importlib_resources/simple.py: -------------------------------------------------------------------------------- 1 | """ 2 | Interface adapters for low-level readers. 3 | """ 4 | 5 | import abc 6 | import io 7 | import itertools 8 | from typing import BinaryIO, List 9 | 10 | from .abc import Traversable, TraversableResources 11 | 12 | 13 | class SimpleReader(abc.ABC): 14 | """ 15 | The minimum, low-level interface required from a resource 16 | provider. 17 | """ 18 | 19 | @property 20 | @abc.abstractmethod 21 | def package(self) -> str: 22 | """ 23 | The name of the package for which this reader loads resources. 24 | """ 25 | 26 | @abc.abstractmethod 27 | def children(self) -> List['SimpleReader']: 28 | """ 29 | Obtain an iterable of SimpleReader for available 30 | child containers (e.g. directories). 31 | """ 32 | 33 | @abc.abstractmethod 34 | def resources(self) -> List[str]: 35 | """ 36 | Obtain available named resources for this virtual package. 37 | """ 38 | 39 | @abc.abstractmethod 40 | def open_binary(self, resource: str) -> BinaryIO: 41 | """ 42 | Obtain a File-like for a named resource. 43 | """ 44 | 45 | @property 46 | def name(self): 47 | return self.package.split('.')[-1] 48 | 49 | 50 | class ResourceContainer(Traversable): 51 | """ 52 | Traversable container for a package's resources via its reader. 53 | """ 54 | 55 | def __init__(self, reader: SimpleReader): 56 | self.reader = reader 57 | 58 | def is_dir(self): 59 | return True 60 | 61 | def is_file(self): 62 | return False 63 | 64 | def iterdir(self): 65 | files = (ResourceHandle(self, name) for name in self.reader.resources) 66 | dirs = map(ResourceContainer, self.reader.children()) 67 | return itertools.chain(files, dirs) 68 | 69 | def open(self, *args, **kwargs): 70 | raise IsADirectoryError() 71 | 72 | 73 | class ResourceHandle(Traversable): 74 | """ 75 | Handle to a named resource in a ResourceReader. 76 | """ 77 | 78 | def __init__(self, parent: ResourceContainer, name: str): 79 | self.parent = parent 80 | self.name = name # type: ignore[misc] 81 | 82 | def is_file(self): 83 | return True 84 | 85 | def is_dir(self): 86 | return False 87 | 88 | def open(self, mode='r', *args, **kwargs): 89 | stream = self.parent.reader.open_binary(self.name) 90 | if 'b' not in mode: 91 | stream = io.TextIOWrapper(stream, *args, **kwargs) 92 | return stream 93 | 94 | def joinpath(self, name): 95 | raise RuntimeError("Cannot traverse into a resource") 96 | 97 | 98 | class TraversableReader(TraversableResources, SimpleReader): 99 | """ 100 | A TraversableResources based on SimpleReader. Resource providers 101 | may derive from this class to provide the TraversableResources 102 | interface by supplying the SimpleReader interface. 103 | """ 104 | 105 | def files(self): 106 | return ResourceContainer(self) 107 | -------------------------------------------------------------------------------- /importlib_resources/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/importlib_resources/89fcc4bed85b0717dba54cd67e0f71e84b4230fe/importlib_resources/tests/__init__.py -------------------------------------------------------------------------------- /importlib_resources/tests/_path.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import pathlib 3 | from typing import Dict, Protocol, Union, runtime_checkable 4 | 5 | #### 6 | # from jaraco.path 3.7.1 7 | 8 | 9 | class Symlink(str): 10 | """ 11 | A string indicating the target of a symlink. 12 | """ 13 | 14 | 15 | FilesSpec = Dict[str, Union[str, bytes, Symlink, 'FilesSpec']] 16 | 17 | 18 | @runtime_checkable 19 | class TreeMaker(Protocol): 20 | def __truediv__(self, *args, **kwargs): ... # pragma: no cover 21 | 22 | def mkdir(self, **kwargs): ... # pragma: no cover 23 | 24 | def write_text(self, content, **kwargs): ... # pragma: no cover 25 | 26 | def write_bytes(self, content): ... # pragma: no cover 27 | 28 | def symlink_to(self, target): ... # pragma: no cover 29 | 30 | 31 | def _ensure_tree_maker(obj: Union[str, TreeMaker]) -> TreeMaker: 32 | return obj if isinstance(obj, TreeMaker) else pathlib.Path(obj) # type: ignore[return-value] 33 | 34 | 35 | def build( 36 | spec: FilesSpec, 37 | prefix: Union[str, TreeMaker] = pathlib.Path(), # type: ignore[assignment] 38 | ): 39 | """ 40 | Build a set of files/directories, as described by the spec. 41 | 42 | Each key represents a pathname, and the value represents 43 | the content. Content may be a nested directory. 44 | 45 | >>> spec = { 46 | ... 'README.txt': "A README file", 47 | ... "foo": { 48 | ... "__init__.py": "", 49 | ... "bar": { 50 | ... "__init__.py": "", 51 | ... }, 52 | ... "baz.py": "# Some code", 53 | ... "bar.py": Symlink("baz.py"), 54 | ... }, 55 | ... "bing": Symlink("foo"), 56 | ... } 57 | >>> target = getfixture('tmp_path') 58 | >>> build(spec, target) 59 | >>> target.joinpath('foo/baz.py').read_text(encoding='utf-8') 60 | '# Some code' 61 | >>> target.joinpath('bing/bar.py').read_text(encoding='utf-8') 62 | '# Some code' 63 | """ 64 | for name, contents in spec.items(): 65 | create(contents, _ensure_tree_maker(prefix) / name) 66 | 67 | 68 | @functools.singledispatch 69 | def create(content: Union[str, bytes, FilesSpec], path): 70 | path.mkdir(exist_ok=True) 71 | build(content, prefix=path) # type: ignore[arg-type] 72 | 73 | 74 | @create.register 75 | def _(content: bytes, path): 76 | path.write_bytes(content) 77 | 78 | 79 | @create.register 80 | def _(content: str, path): 81 | path.write_text(content, encoding='utf-8') 82 | 83 | 84 | @create.register 85 | def _(content: Symlink, path): 86 | path.symlink_to(content) 87 | 88 | 89 | # end from jaraco.path 90 | #### 91 | -------------------------------------------------------------------------------- /importlib_resources/tests/compat/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python/importlib_resources/89fcc4bed85b0717dba54cd67e0f71e84b4230fe/importlib_resources/tests/compat/__init__.py -------------------------------------------------------------------------------- /importlib_resources/tests/compat/py312.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | from .py39 import import_helper 4 | 5 | 6 | @contextlib.contextmanager 7 | def isolated_modules(): 8 | """ 9 | Save modules on entry and cleanup on exit. 10 | """ 11 | (saved,) = import_helper.modules_setup() 12 | try: 13 | yield 14 | finally: 15 | import_helper.modules_cleanup(saved) 16 | 17 | 18 | vars(import_helper).setdefault('isolated_modules', isolated_modules) 19 | -------------------------------------------------------------------------------- /importlib_resources/tests/compat/py39.py: -------------------------------------------------------------------------------- 1 | """ 2 | Backward-compatability shims to support Python 3.9 and earlier. 3 | """ 4 | 5 | from jaraco.test.cpython import from_test_support, try_import 6 | 7 | import_helper = try_import('import_helper') or from_test_support( 8 | 'modules_setup', 'modules_cleanup', 'DirsOnSysPath' 9 | ) 10 | os_helper = try_import('os_helper') or from_test_support('temp_dir') 11 | warnings_helper = try_import('warnings_helper') or from_test_support( 12 | 'ignore_warnings', 'check_warnings' 13 | ) 14 | -------------------------------------------------------------------------------- /importlib_resources/tests/test_compatibilty_files.py: -------------------------------------------------------------------------------- 1 | import io 2 | import unittest 3 | 4 | import importlib_resources as resources 5 | from importlib_resources._adapters import ( 6 | CompatibilityFiles, 7 | wrap_spec, 8 | ) 9 | 10 | from . import util 11 | 12 | 13 | class CompatibilityFilesTests(unittest.TestCase): 14 | @property 15 | def package(self): 16 | bytes_data = io.BytesIO(b'Hello, world!') 17 | return util.create_package( 18 | file=bytes_data, 19 | path='some_path', 20 | contents=('a', 'b', 'c'), 21 | ) 22 | 23 | @property 24 | def files(self): 25 | return resources.files(self.package) 26 | 27 | def test_spec_path_iter(self): 28 | self.assertEqual( 29 | sorted(path.name for path in self.files.iterdir()), 30 | ['a', 'b', 'c'], 31 | ) 32 | 33 | def test_child_path_iter(self): 34 | self.assertEqual(list((self.files / 'a').iterdir()), []) 35 | 36 | def test_orphan_path_iter(self): 37 | self.assertEqual(list((self.files / 'a' / 'a').iterdir()), []) 38 | self.assertEqual(list((self.files / 'a' / 'a' / 'a').iterdir()), []) 39 | 40 | def test_spec_path_is(self): 41 | self.assertFalse(self.files.is_file()) 42 | self.assertFalse(self.files.is_dir()) 43 | 44 | def test_child_path_is(self): 45 | self.assertTrue((self.files / 'a').is_file()) 46 | self.assertFalse((self.files / 'a').is_dir()) 47 | 48 | def test_orphan_path_is(self): 49 | self.assertFalse((self.files / 'a' / 'a').is_file()) 50 | self.assertFalse((self.files / 'a' / 'a').is_dir()) 51 | self.assertFalse((self.files / 'a' / 'a' / 'a').is_file()) 52 | self.assertFalse((self.files / 'a' / 'a' / 'a').is_dir()) 53 | 54 | def test_spec_path_name(self): 55 | self.assertEqual(self.files.name, 'testingpackage') 56 | 57 | def test_child_path_name(self): 58 | self.assertEqual((self.files / 'a').name, 'a') 59 | 60 | def test_orphan_path_name(self): 61 | self.assertEqual((self.files / 'a' / 'b').name, 'b') 62 | self.assertEqual((self.files / 'a' / 'b' / 'c').name, 'c') 63 | 64 | def test_spec_path_open(self): 65 | self.assertEqual(self.files.read_bytes(), b'Hello, world!') 66 | self.assertEqual(self.files.read_text(encoding='utf-8'), 'Hello, world!') 67 | 68 | def test_child_path_open(self): 69 | self.assertEqual((self.files / 'a').read_bytes(), b'Hello, world!') 70 | self.assertEqual( 71 | (self.files / 'a').read_text(encoding='utf-8'), 'Hello, world!' 72 | ) 73 | 74 | def test_orphan_path_open(self): 75 | with self.assertRaises(FileNotFoundError): 76 | (self.files / 'a' / 'b').read_bytes() 77 | with self.assertRaises(FileNotFoundError): 78 | (self.files / 'a' / 'b' / 'c').read_bytes() 79 | 80 | def test_open_invalid_mode(self): 81 | with self.assertRaises(ValueError): 82 | self.files.open('0') 83 | 84 | def test_orphan_path_invalid(self): 85 | with self.assertRaises(ValueError): 86 | CompatibilityFiles.OrphanPath() 87 | 88 | def test_wrap_spec(self): 89 | spec = wrap_spec(self.package) 90 | self.assertIsInstance(spec.loader.get_resource_reader(None), CompatibilityFiles) 91 | 92 | 93 | class CompatibilityFilesNoReaderTests(unittest.TestCase): 94 | @property 95 | def package(self): 96 | return util.create_package_from_loader(None) 97 | 98 | @property 99 | def files(self): 100 | return resources.files(self.package) 101 | 102 | def test_spec_path_joinpath(self): 103 | self.assertIsInstance(self.files / 'a', CompatibilityFiles.OrphanPath) 104 | -------------------------------------------------------------------------------- /importlib_resources/tests/test_contents.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import importlib_resources as resources 4 | 5 | from . import util 6 | 7 | 8 | class ContentsTests: 9 | expected = { 10 | '__init__.py', 11 | 'binary.file', 12 | 'subdirectory', 13 | 'utf-16.file', 14 | 'utf-8.file', 15 | } 16 | 17 | def test_contents(self): 18 | contents = {path.name for path in resources.files(self.data).iterdir()} 19 | assert self.expected <= contents 20 | 21 | 22 | class ContentsDiskTests(ContentsTests, util.DiskSetup, unittest.TestCase): 23 | pass 24 | 25 | 26 | class ContentsZipTests(ContentsTests, util.ZipSetup, unittest.TestCase): 27 | pass 28 | 29 | 30 | class ContentsNamespaceTests(ContentsTests, util.DiskSetup, unittest.TestCase): 31 | MODULE = 'namespacedata01' 32 | 33 | expected = { 34 | # no __init__ because of namespace design 35 | 'binary.file', 36 | 'subdirectory', 37 | 'utf-16.file', 38 | 'utf-8.file', 39 | } 40 | -------------------------------------------------------------------------------- /importlib_resources/tests/test_custom.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import pathlib 3 | import unittest 4 | 5 | import importlib_resources as resources 6 | 7 | from .. import abc 8 | from ..abc import ResourceReader, TraversableResources 9 | from . import util 10 | from .compat.py39 import os_helper 11 | 12 | 13 | class SimpleLoader: 14 | """ 15 | A simple loader that only implements a resource reader. 16 | """ 17 | 18 | def __init__(self, reader: ResourceReader): 19 | self.reader = reader 20 | 21 | def get_resource_reader(self, package): 22 | return self.reader 23 | 24 | 25 | class MagicResources(TraversableResources): 26 | """ 27 | Magically returns the resources at path. 28 | """ 29 | 30 | def __init__(self, path: pathlib.Path): 31 | self.path = path 32 | 33 | def files(self): 34 | return self.path 35 | 36 | 37 | class CustomTraversableResourcesTests(unittest.TestCase): 38 | def setUp(self): 39 | self.fixtures = contextlib.ExitStack() 40 | self.addCleanup(self.fixtures.close) 41 | 42 | def test_custom_loader(self): 43 | temp_dir = pathlib.Path(self.fixtures.enter_context(os_helper.temp_dir())) 44 | loader = SimpleLoader(MagicResources(temp_dir)) 45 | pkg = util.create_package_from_loader(loader) 46 | files = resources.files(pkg) 47 | assert isinstance(files, abc.Traversable) 48 | assert list(files.iterdir()) == [] 49 | -------------------------------------------------------------------------------- /importlib_resources/tests/test_files.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import importlib 3 | import pathlib 4 | import py_compile 5 | import textwrap 6 | import unittest 7 | import warnings 8 | 9 | import importlib_resources as resources 10 | 11 | from ..abc import Traversable 12 | from . import util 13 | from .compat.py39 import import_helper, os_helper 14 | 15 | 16 | @contextlib.contextmanager 17 | def suppress_known_deprecation(): 18 | with warnings.catch_warnings(record=True) as ctx: 19 | warnings.simplefilter('default', category=DeprecationWarning) 20 | yield ctx 21 | 22 | 23 | class FilesTests: 24 | def test_read_bytes(self): 25 | files = resources.files(self.data) 26 | actual = files.joinpath('utf-8.file').read_bytes() 27 | assert actual == b'Hello, UTF-8 world!\n' 28 | 29 | def test_read_text(self): 30 | files = resources.files(self.data) 31 | actual = files.joinpath('utf-8.file').read_text(encoding='utf-8') 32 | assert actual == 'Hello, UTF-8 world!\n' 33 | 34 | def test_traversable(self): 35 | assert isinstance(resources.files(self.data), Traversable) 36 | 37 | def test_joinpath_with_multiple_args(self): 38 | files = resources.files(self.data) 39 | binfile = files.joinpath('subdirectory', 'binary.file') 40 | self.assertTrue(binfile.is_file()) 41 | 42 | def test_old_parameter(self): 43 | """ 44 | Files used to take a 'package' parameter. Make sure anyone 45 | passing by name is still supported. 46 | """ 47 | with suppress_known_deprecation(): 48 | resources.files(package=self.data) 49 | 50 | 51 | class OpenDiskTests(FilesTests, util.DiskSetup, unittest.TestCase): 52 | pass 53 | 54 | 55 | class OpenZipTests(FilesTests, util.ZipSetup, unittest.TestCase): 56 | pass 57 | 58 | 59 | class OpenNamespaceTests(FilesTests, util.DiskSetup, unittest.TestCase): 60 | MODULE = 'namespacedata01' 61 | 62 | def test_non_paths_in_dunder_path(self): 63 | """ 64 | Non-path items in a namespace package's ``__path__`` are ignored. 65 | 66 | As reported in python/importlib_resources#311, some tools 67 | like Setuptools, when creating editable packages, will inject 68 | non-paths into a namespace package's ``__path__``, a 69 | sentinel like 70 | ``__editable__.sample_namespace-1.0.finder.__path_hook__`` 71 | to cause the ``PathEntryFinder`` to be called when searching 72 | for packages. In that case, resources should still be loadable. 73 | """ 74 | import namespacedata01 # type: ignore[import-not-found] 75 | 76 | namespacedata01.__path__.append( 77 | '__editable__.sample_namespace-1.0.finder.__path_hook__' 78 | ) 79 | 80 | resources.files(namespacedata01) 81 | 82 | 83 | class OpenNamespaceZipTests(FilesTests, util.ZipSetup, unittest.TestCase): 84 | ZIP_MODULE = 'namespacedata01' 85 | 86 | 87 | class DirectSpec: 88 | """ 89 | Override behavior of ModuleSetup to write a full spec directly. 90 | """ 91 | 92 | MODULE = 'unused' 93 | 94 | def load_fixture(self, name): 95 | self.tree_on_path(self.spec) 96 | 97 | 98 | class ModulesFiles: 99 | spec = { 100 | 'mod.py': '', 101 | 'res.txt': 'resources are the best', 102 | } 103 | 104 | def test_module_resources(self): 105 | """ 106 | A module can have resources found adjacent to the module. 107 | """ 108 | import mod # type: ignore[import-not-found] 109 | 110 | actual = resources.files(mod).joinpath('res.txt').read_text(encoding='utf-8') 111 | assert actual == self.spec['res.txt'] 112 | 113 | 114 | class ModuleFilesDiskTests(DirectSpec, util.DiskSetup, ModulesFiles, unittest.TestCase): 115 | pass 116 | 117 | 118 | class ModuleFilesZipTests(DirectSpec, util.ZipSetup, ModulesFiles, unittest.TestCase): 119 | pass 120 | 121 | 122 | class ImplicitContextFiles: 123 | set_val = textwrap.dedent( 124 | f""" 125 | import {resources.__name__} as res 126 | val = res.files().joinpath('res.txt').read_text(encoding='utf-8') 127 | """ 128 | ) 129 | spec = { 130 | 'somepkg': { 131 | '__init__.py': set_val, 132 | 'submod.py': set_val, 133 | 'res.txt': 'resources are the best', 134 | }, 135 | 'frozenpkg': { 136 | '__init__.py': set_val.replace(resources.__name__, 'c_resources'), 137 | 'res.txt': 'resources are the best', 138 | }, 139 | } 140 | 141 | def test_implicit_files_package(self): 142 | """ 143 | Without any parameter, files() will infer the location as the caller. 144 | """ 145 | assert importlib.import_module('somepkg').val == 'resources are the best' 146 | 147 | def test_implicit_files_submodule(self): 148 | """ 149 | Without any parameter, files() will infer the location as the caller. 150 | """ 151 | assert importlib.import_module('somepkg.submod').val == 'resources are the best' 152 | 153 | def _compile_importlib(self): 154 | """ 155 | Make a compiled-only copy of the importlib resources package. 156 | 157 | Currently only code is copied, as importlib resources doesn't itself 158 | have any resources. 159 | """ 160 | bin_site = self.fixtures.enter_context(os_helper.temp_dir()) 161 | c_resources = pathlib.Path(bin_site, 'c_resources') 162 | sources = pathlib.Path(resources.__file__).parent 163 | 164 | for source_path in sources.glob('**/*.py'): 165 | c_path = c_resources.joinpath(source_path.relative_to(sources)).with_suffix( 166 | '.pyc' 167 | ) 168 | py_compile.compile(source_path, c_path) 169 | self.fixtures.enter_context(import_helper.DirsOnSysPath(bin_site)) 170 | 171 | def test_implicit_files_with_compiled_importlib(self): 172 | """ 173 | Caller detection works for compiled-only resources module. 174 | 175 | python/cpython#123085 176 | """ 177 | self._compile_importlib() 178 | assert importlib.import_module('frozenpkg').val == 'resources are the best' 179 | 180 | 181 | class ImplicitContextFilesDiskTests( 182 | DirectSpec, util.DiskSetup, ImplicitContextFiles, unittest.TestCase 183 | ): 184 | pass 185 | 186 | 187 | class ImplicitContextFilesZipTests( 188 | DirectSpec, util.ZipSetup, ImplicitContextFiles, unittest.TestCase 189 | ): 190 | pass 191 | 192 | 193 | if __name__ == '__main__': 194 | unittest.main() 195 | -------------------------------------------------------------------------------- /importlib_resources/tests/test_functional.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | import unittest 4 | 5 | import importlib_resources as resources 6 | 7 | from . import util 8 | from .compat.py39 import warnings_helper 9 | 10 | 11 | class StringAnchorMixin: 12 | anchor01 = 'data01' 13 | anchor02 = 'data02' 14 | 15 | 16 | class ModuleAnchorMixin: 17 | @property 18 | def anchor01(self): 19 | return importlib.import_module('data01') 20 | 21 | @property 22 | def anchor02(self): 23 | return importlib.import_module('data02') 24 | 25 | 26 | class FunctionalAPIBase: 27 | def setUp(self): 28 | super().setUp() 29 | self.load_fixture('data02') 30 | 31 | def _gen_resourcetxt_path_parts(self): 32 | """Yield various names of a text file in anchor02, each in a subTest""" 33 | for path_parts in ( 34 | ('subdirectory', 'subsubdir', 'resource.txt'), 35 | ('subdirectory/subsubdir/resource.txt',), 36 | ('subdirectory/subsubdir', 'resource.txt'), 37 | ): 38 | with self.subTest(path_parts=path_parts): 39 | yield path_parts 40 | 41 | def assertEndsWith(self, string, suffix): 42 | """Assert that `string` ends with `suffix`. 43 | 44 | Used to ignore an architecture-specific UTF-16 byte-order mark.""" 45 | self.assertEqual(string[-len(suffix) :], suffix) 46 | 47 | def test_read_text(self): 48 | self.assertEqual( 49 | resources.read_text(self.anchor01, 'utf-8.file'), 50 | 'Hello, UTF-8 world!\n', 51 | ) 52 | self.assertEqual( 53 | resources.read_text( 54 | self.anchor02, 55 | 'subdirectory', 56 | 'subsubdir', 57 | 'resource.txt', 58 | encoding='utf-8', 59 | ), 60 | 'a resource', 61 | ) 62 | for path_parts in self._gen_resourcetxt_path_parts(): 63 | self.assertEqual( 64 | resources.read_text( 65 | self.anchor02, 66 | *path_parts, 67 | encoding='utf-8', 68 | ), 69 | 'a resource', 70 | ) 71 | # Use generic OSError, since e.g. attempting to read a directory can 72 | # fail with PermissionError rather than IsADirectoryError 73 | with self.assertRaises(OSError): 74 | resources.read_text(self.anchor01) 75 | with self.assertRaises((OSError, resources.abc.TraversalError)): 76 | resources.read_text(self.anchor01, 'no-such-file') 77 | with self.assertRaises(UnicodeDecodeError): 78 | resources.read_text(self.anchor01, 'utf-16.file') 79 | self.assertEqual( 80 | resources.read_text( 81 | self.anchor01, 82 | 'binary.file', 83 | encoding='latin1', 84 | ), 85 | '\x00\x01\x02\x03', 86 | ) 87 | self.assertEndsWith( # ignore the BOM 88 | resources.read_text( 89 | self.anchor01, 90 | 'utf-16.file', 91 | errors='backslashreplace', 92 | ), 93 | 'Hello, UTF-16 world!\n'.encode('utf-16-le').decode( 94 | errors='backslashreplace', 95 | ), 96 | ) 97 | 98 | def test_read_binary(self): 99 | self.assertEqual( 100 | resources.read_binary(self.anchor01, 'utf-8.file'), 101 | b'Hello, UTF-8 world!\n', 102 | ) 103 | for path_parts in self._gen_resourcetxt_path_parts(): 104 | self.assertEqual( 105 | resources.read_binary(self.anchor02, *path_parts), 106 | b'a resource', 107 | ) 108 | 109 | def test_open_text(self): 110 | with resources.open_text(self.anchor01, 'utf-8.file') as f: 111 | self.assertEqual(f.read(), 'Hello, UTF-8 world!\n') 112 | for path_parts in self._gen_resourcetxt_path_parts(): 113 | with resources.open_text( 114 | self.anchor02, 115 | *path_parts, 116 | encoding='utf-8', 117 | ) as f: 118 | self.assertEqual(f.read(), 'a resource') 119 | # Use generic OSError, since e.g. attempting to read a directory can 120 | # fail with PermissionError rather than IsADirectoryError 121 | with self.assertRaises(OSError): 122 | resources.open_text(self.anchor01) 123 | with self.assertRaises((OSError, resources.abc.TraversalError)): 124 | resources.open_text(self.anchor01, 'no-such-file') 125 | with resources.open_text(self.anchor01, 'utf-16.file') as f: 126 | with self.assertRaises(UnicodeDecodeError): 127 | f.read() 128 | with resources.open_text( 129 | self.anchor01, 130 | 'binary.file', 131 | encoding='latin1', 132 | ) as f: 133 | self.assertEqual(f.read(), '\x00\x01\x02\x03') 134 | with resources.open_text( 135 | self.anchor01, 136 | 'utf-16.file', 137 | errors='backslashreplace', 138 | ) as f: 139 | self.assertEndsWith( # ignore the BOM 140 | f.read(), 141 | 'Hello, UTF-16 world!\n'.encode('utf-16-le').decode( 142 | errors='backslashreplace', 143 | ), 144 | ) 145 | 146 | def test_open_binary(self): 147 | with resources.open_binary(self.anchor01, 'utf-8.file') as f: 148 | self.assertEqual(f.read(), b'Hello, UTF-8 world!\n') 149 | for path_parts in self._gen_resourcetxt_path_parts(): 150 | with resources.open_binary( 151 | self.anchor02, 152 | *path_parts, 153 | ) as f: 154 | self.assertEqual(f.read(), b'a resource') 155 | 156 | def test_path(self): 157 | with resources.path(self.anchor01, 'utf-8.file') as path: 158 | with open(str(path), encoding='utf-8') as f: 159 | self.assertEqual(f.read(), 'Hello, UTF-8 world!\n') 160 | with resources.path(self.anchor01) as path: 161 | with open(os.path.join(path, 'utf-8.file'), encoding='utf-8') as f: 162 | self.assertEqual(f.read(), 'Hello, UTF-8 world!\n') 163 | 164 | def test_is_resource(self): 165 | is_resource = resources.is_resource 166 | self.assertTrue(is_resource(self.anchor01, 'utf-8.file')) 167 | self.assertFalse(is_resource(self.anchor01, 'no_such_file')) 168 | self.assertFalse(is_resource(self.anchor01)) 169 | self.assertFalse(is_resource(self.anchor01, 'subdirectory')) 170 | for path_parts in self._gen_resourcetxt_path_parts(): 171 | self.assertTrue(is_resource(self.anchor02, *path_parts)) 172 | 173 | def test_contents(self): 174 | with warnings_helper.check_warnings((".*contents.*", DeprecationWarning)): 175 | c = resources.contents(self.anchor01) 176 | self.assertGreaterEqual( 177 | set(c), 178 | {'utf-8.file', 'utf-16.file', 'binary.file', 'subdirectory'}, 179 | ) 180 | with ( 181 | self.assertRaises(OSError), 182 | warnings_helper.check_warnings(( 183 | ".*contents.*", 184 | DeprecationWarning, 185 | )), 186 | ): 187 | list(resources.contents(self.anchor01, 'utf-8.file')) 188 | 189 | for path_parts in self._gen_resourcetxt_path_parts(): 190 | with ( 191 | self.assertRaises((OSError, resources.abc.TraversalError)), 192 | warnings_helper.check_warnings(( 193 | ".*contents.*", 194 | DeprecationWarning, 195 | )), 196 | ): 197 | list(resources.contents(self.anchor01, *path_parts)) 198 | with warnings_helper.check_warnings((".*contents.*", DeprecationWarning)): 199 | c = resources.contents(self.anchor01, 'subdirectory') 200 | self.assertGreaterEqual( 201 | set(c), 202 | {'binary.file'}, 203 | ) 204 | 205 | @warnings_helper.ignore_warnings(category=DeprecationWarning) 206 | def test_common_errors(self): 207 | for func in ( 208 | resources.read_text, 209 | resources.read_binary, 210 | resources.open_text, 211 | resources.open_binary, 212 | resources.path, 213 | resources.is_resource, 214 | resources.contents, 215 | ): 216 | with self.subTest(func=func): 217 | # Rejecting None anchor 218 | with self.assertRaises(TypeError): 219 | func(None) 220 | # Rejecting invalid anchor type 221 | with self.assertRaises((TypeError, AttributeError)): 222 | func(1234) 223 | # Unknown module 224 | with self.assertRaises(ModuleNotFoundError): 225 | func('$missing module$') 226 | 227 | def test_text_errors(self): 228 | for func in ( 229 | resources.read_text, 230 | resources.open_text, 231 | ): 232 | with self.subTest(func=func): 233 | # Multiple path arguments need explicit encoding argument. 234 | with self.assertRaises(TypeError): 235 | func( 236 | self.anchor02, 237 | 'subdirectory', 238 | 'subsubdir', 239 | 'resource.txt', 240 | ) 241 | 242 | 243 | class FunctionalAPITest_StringAnchor_Disk( 244 | StringAnchorMixin, 245 | FunctionalAPIBase, 246 | util.DiskSetup, 247 | unittest.TestCase, 248 | ): 249 | pass 250 | 251 | 252 | class FunctionalAPITest_ModuleAnchor_Disk( 253 | ModuleAnchorMixin, 254 | FunctionalAPIBase, 255 | util.DiskSetup, 256 | unittest.TestCase, 257 | ): 258 | pass 259 | 260 | 261 | class FunctionalAPITest_StringAnchor_Memory( 262 | StringAnchorMixin, 263 | FunctionalAPIBase, 264 | util.MemorySetup, 265 | unittest.TestCase, 266 | ): 267 | pass 268 | -------------------------------------------------------------------------------- /importlib_resources/tests/test_open.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import importlib_resources as resources 4 | 5 | from . import util 6 | 7 | 8 | class CommonBinaryTests(util.CommonTests, unittest.TestCase): 9 | def execute(self, package, path): 10 | target = resources.files(package).joinpath(path) 11 | with target.open('rb'): 12 | pass 13 | 14 | 15 | class CommonTextTests(util.CommonTests, unittest.TestCase): 16 | def execute(self, package, path): 17 | target = resources.files(package).joinpath(path) 18 | with target.open(encoding='utf-8'): 19 | pass 20 | 21 | 22 | class OpenTests: 23 | def test_open_binary(self): 24 | target = resources.files(self.data) / 'binary.file' 25 | with target.open('rb') as fp: 26 | result = fp.read() 27 | self.assertEqual(result, bytes(range(4))) 28 | 29 | def test_open_text_default_encoding(self): 30 | target = resources.files(self.data) / 'utf-8.file' 31 | with target.open(encoding='utf-8') as fp: 32 | result = fp.read() 33 | self.assertEqual(result, 'Hello, UTF-8 world!\n') 34 | 35 | def test_open_text_given_encoding(self): 36 | target = resources.files(self.data) / 'utf-16.file' 37 | with target.open(encoding='utf-16', errors='strict') as fp: 38 | result = fp.read() 39 | self.assertEqual(result, 'Hello, UTF-16 world!\n') 40 | 41 | def test_open_text_with_errors(self): 42 | """ 43 | Raises UnicodeError without the 'errors' argument. 44 | """ 45 | target = resources.files(self.data) / 'utf-16.file' 46 | with target.open(encoding='utf-8', errors='strict') as fp: 47 | self.assertRaises(UnicodeError, fp.read) 48 | with target.open(encoding='utf-8', errors='ignore') as fp: 49 | result = fp.read() 50 | self.assertEqual( 51 | result, 52 | 'H\x00e\x00l\x00l\x00o\x00,\x00 ' 53 | '\x00U\x00T\x00F\x00-\x001\x006\x00 ' 54 | '\x00w\x00o\x00r\x00l\x00d\x00!\x00\n\x00', 55 | ) 56 | 57 | def test_open_binary_FileNotFoundError(self): 58 | target = resources.files(self.data) / 'does-not-exist' 59 | with self.assertRaises(FileNotFoundError): 60 | target.open('rb') 61 | 62 | def test_open_text_FileNotFoundError(self): 63 | target = resources.files(self.data) / 'does-not-exist' 64 | with self.assertRaises(FileNotFoundError): 65 | target.open(encoding='utf-8') 66 | 67 | 68 | class OpenDiskTests(OpenTests, util.DiskSetup, unittest.TestCase): 69 | pass 70 | 71 | 72 | class OpenDiskNamespaceTests(OpenTests, util.DiskSetup, unittest.TestCase): 73 | MODULE = 'namespacedata01' 74 | 75 | 76 | class OpenZipTests(OpenTests, util.ZipSetup, unittest.TestCase): 77 | pass 78 | 79 | 80 | class OpenNamespaceZipTests(OpenTests, util.ZipSetup, unittest.TestCase): 81 | MODULE = 'namespacedata01' 82 | 83 | 84 | if __name__ == '__main__': 85 | unittest.main() 86 | -------------------------------------------------------------------------------- /importlib_resources/tests/test_path.py: -------------------------------------------------------------------------------- 1 | import io 2 | import pathlib 3 | import unittest 4 | 5 | import importlib_resources as resources 6 | 7 | from . import util 8 | 9 | 10 | class CommonTests(util.CommonTests, unittest.TestCase): 11 | def execute(self, package, path): 12 | with resources.as_file(resources.files(package).joinpath(path)): 13 | pass 14 | 15 | 16 | class PathTests: 17 | def test_reading(self): 18 | """ 19 | Path should be readable and a pathlib.Path instance. 20 | """ 21 | target = resources.files(self.data) / 'utf-8.file' 22 | with resources.as_file(target) as path: 23 | self.assertIsInstance(path, pathlib.Path) 24 | self.assertTrue(path.name.endswith("utf-8.file"), repr(path)) 25 | self.assertEqual('Hello, UTF-8 world!\n', path.read_text(encoding='utf-8')) 26 | 27 | 28 | class PathDiskTests(PathTests, util.DiskSetup, unittest.TestCase): 29 | def test_natural_path(self): 30 | """ 31 | Guarantee the internal implementation detail that 32 | file-system-backed resources do not get the tempdir 33 | treatment. 34 | """ 35 | target = resources.files(self.data) / 'utf-8.file' 36 | with resources.as_file(target) as path: 37 | assert 'data' in str(path) 38 | 39 | 40 | class PathMemoryTests(PathTests, unittest.TestCase): 41 | def setUp(self): 42 | file = io.BytesIO(b'Hello, UTF-8 world!\n') 43 | self.addCleanup(file.close) 44 | self.data = util.create_package( 45 | file=file, path=FileNotFoundError("package exists only in memory") 46 | ) 47 | self.data.__spec__.origin = None 48 | self.data.__spec__.has_location = False 49 | 50 | 51 | class PathZipTests(PathTests, util.ZipSetup, unittest.TestCase): 52 | def test_remove_in_context_manager(self): 53 | """ 54 | It is not an error if the file that was temporarily stashed on the 55 | file system is removed inside the `with` stanza. 56 | """ 57 | target = resources.files(self.data) / 'utf-8.file' 58 | with resources.as_file(target) as path: 59 | path.unlink() 60 | 61 | 62 | if __name__ == '__main__': 63 | unittest.main() 64 | -------------------------------------------------------------------------------- /importlib_resources/tests/test_read.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from importlib import import_module 3 | 4 | import importlib_resources as resources 5 | 6 | from . import util 7 | 8 | 9 | class CommonBinaryTests(util.CommonTests, unittest.TestCase): 10 | def execute(self, package, path): 11 | resources.files(package).joinpath(path).read_bytes() 12 | 13 | 14 | class CommonTextTests(util.CommonTests, unittest.TestCase): 15 | def execute(self, package, path): 16 | resources.files(package).joinpath(path).read_text(encoding='utf-8') 17 | 18 | 19 | class ReadTests: 20 | def test_read_bytes(self): 21 | result = resources.files(self.data).joinpath('binary.file').read_bytes() 22 | self.assertEqual(result, bytes(range(4))) 23 | 24 | def test_read_text_default_encoding(self): 25 | result = ( 26 | resources.files(self.data) 27 | .joinpath('utf-8.file') 28 | .read_text(encoding='utf-8') 29 | ) 30 | self.assertEqual(result, 'Hello, UTF-8 world!\n') 31 | 32 | def test_read_text_given_encoding(self): 33 | result = ( 34 | resources.files(self.data) 35 | .joinpath('utf-16.file') 36 | .read_text(encoding='utf-16') 37 | ) 38 | self.assertEqual(result, 'Hello, UTF-16 world!\n') 39 | 40 | def test_read_text_with_errors(self): 41 | """ 42 | Raises UnicodeError without the 'errors' argument. 43 | """ 44 | target = resources.files(self.data) / 'utf-16.file' 45 | self.assertRaises(UnicodeError, target.read_text, encoding='utf-8') 46 | result = target.read_text(encoding='utf-8', errors='ignore') 47 | self.assertEqual( 48 | result, 49 | 'H\x00e\x00l\x00l\x00o\x00,\x00 ' 50 | '\x00U\x00T\x00F\x00-\x001\x006\x00 ' 51 | '\x00w\x00o\x00r\x00l\x00d\x00!\x00\n\x00', 52 | ) 53 | 54 | 55 | class ReadDiskTests(ReadTests, util.DiskSetup, unittest.TestCase): 56 | pass 57 | 58 | 59 | class ReadZipTests(ReadTests, util.ZipSetup, unittest.TestCase): 60 | def test_read_submodule_resource(self): 61 | submodule = import_module('data01.subdirectory') 62 | result = resources.files(submodule).joinpath('binary.file').read_bytes() 63 | self.assertEqual(result, bytes(range(4, 8))) 64 | 65 | def test_read_submodule_resource_by_name(self): 66 | result = ( 67 | resources.files('data01.subdirectory').joinpath('binary.file').read_bytes() 68 | ) 69 | self.assertEqual(result, bytes(range(4, 8))) 70 | 71 | 72 | class ReadNamespaceTests(ReadTests, util.DiskSetup, unittest.TestCase): 73 | MODULE = 'namespacedata01' 74 | 75 | 76 | class ReadNamespaceZipTests(ReadTests, util.ZipSetup, unittest.TestCase): 77 | MODULE = 'namespacedata01' 78 | 79 | def test_read_submodule_resource(self): 80 | submodule = import_module('namespacedata01.subdirectory') 81 | result = resources.files(submodule).joinpath('binary.file').read_bytes() 82 | self.assertEqual(result, bytes(range(12, 16))) 83 | 84 | def test_read_submodule_resource_by_name(self): 85 | result = ( 86 | resources.files('namespacedata01.subdirectory') 87 | .joinpath('binary.file') 88 | .read_bytes() 89 | ) 90 | self.assertEqual(result, bytes(range(12, 16))) 91 | 92 | 93 | if __name__ == '__main__': 94 | unittest.main() 95 | -------------------------------------------------------------------------------- /importlib_resources/tests/test_reader.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import pathlib 3 | import unittest 4 | from importlib import import_module 5 | 6 | from importlib_resources.readers import MultiplexedPath, NamespaceReader 7 | 8 | from . import util 9 | 10 | 11 | class MultiplexedPathTest(util.DiskSetup, unittest.TestCase): 12 | MODULE = 'namespacedata01' 13 | 14 | def setUp(self): 15 | super().setUp() 16 | self.folder = pathlib.Path(self.data.__path__[0]) 17 | self.data01 = pathlib.Path(self.load_fixture('data01').__file__).parent 18 | self.data02 = pathlib.Path(self.load_fixture('data02').__file__).parent 19 | 20 | def test_init_no_paths(self): 21 | with self.assertRaises(FileNotFoundError): 22 | MultiplexedPath() 23 | 24 | def test_init_file(self): 25 | with self.assertRaises(NotADirectoryError): 26 | MultiplexedPath(self.folder / 'binary.file') 27 | 28 | def test_iterdir(self): 29 | contents = {path.name for path in MultiplexedPath(self.folder).iterdir()} 30 | try: 31 | contents.remove('__pycache__') 32 | except (KeyError, ValueError): 33 | pass 34 | self.assertEqual( 35 | contents, {'subdirectory', 'binary.file', 'utf-16.file', 'utf-8.file'} 36 | ) 37 | 38 | def test_iterdir_duplicate(self): 39 | contents = { 40 | path.name for path in MultiplexedPath(self.folder, self.data01).iterdir() 41 | } 42 | for remove in ('__pycache__', '__init__.pyc'): 43 | try: 44 | contents.remove(remove) 45 | except (KeyError, ValueError): 46 | pass 47 | self.assertEqual( 48 | contents, 49 | {'__init__.py', 'binary.file', 'subdirectory', 'utf-16.file', 'utf-8.file'}, 50 | ) 51 | 52 | def test_is_dir(self): 53 | self.assertEqual(MultiplexedPath(self.folder).is_dir(), True) 54 | 55 | def test_is_file(self): 56 | self.assertEqual(MultiplexedPath(self.folder).is_file(), False) 57 | 58 | def test_open_file(self): 59 | path = MultiplexedPath(self.folder) 60 | with self.assertRaises(FileNotFoundError): 61 | path.read_bytes() 62 | with self.assertRaises(FileNotFoundError): 63 | path.read_text() 64 | with self.assertRaises(FileNotFoundError): 65 | path.open() 66 | 67 | def test_join_path(self): 68 | prefix = str(self.folder.parent) 69 | path = MultiplexedPath(self.folder, self.data01) 70 | self.assertEqual( 71 | str(path.joinpath('binary.file'))[len(prefix) + 1 :], 72 | os.path.join('namespacedata01', 'binary.file'), 73 | ) 74 | sub = path.joinpath('subdirectory') 75 | assert isinstance(sub, MultiplexedPath) 76 | assert 'namespacedata01' in str(sub) 77 | assert 'data01' in str(sub) 78 | self.assertEqual( 79 | str(path.joinpath('imaginary'))[len(prefix) + 1 :], 80 | os.path.join('namespacedata01', 'imaginary'), 81 | ) 82 | self.assertEqual(path.joinpath(), path) 83 | 84 | def test_join_path_compound(self): 85 | path = MultiplexedPath(self.folder) 86 | assert not path.joinpath('imaginary/foo.py').exists() 87 | 88 | def test_join_path_common_subdir(self): 89 | prefix = str(self.data02.parent) 90 | path = MultiplexedPath(self.data01, self.data02) 91 | self.assertIsInstance(path.joinpath('subdirectory'), MultiplexedPath) 92 | self.assertEqual( 93 | str(path.joinpath('subdirectory', 'subsubdir'))[len(prefix) + 1 :], 94 | os.path.join('data02', 'subdirectory', 'subsubdir'), 95 | ) 96 | 97 | def test_repr(self): 98 | self.assertEqual( 99 | repr(MultiplexedPath(self.folder)), 100 | f"MultiplexedPath('{self.folder}')", 101 | ) 102 | 103 | def test_name(self): 104 | self.assertEqual( 105 | MultiplexedPath(self.folder).name, 106 | os.path.basename(self.folder), 107 | ) 108 | 109 | 110 | class NamespaceReaderTest(util.DiskSetup, unittest.TestCase): 111 | MODULE = 'namespacedata01' 112 | 113 | def test_init_error(self): 114 | with self.assertRaises(ValueError): 115 | NamespaceReader(['path1', 'path2']) 116 | 117 | def test_resource_path(self): 118 | namespacedata01 = import_module('namespacedata01') 119 | reader = NamespaceReader(namespacedata01.__spec__.submodule_search_locations) 120 | 121 | root = self.data.__path__[0] 122 | self.assertEqual( 123 | reader.resource_path('binary.file'), os.path.join(root, 'binary.file') 124 | ) 125 | self.assertEqual( 126 | reader.resource_path('imaginary'), os.path.join(root, 'imaginary') 127 | ) 128 | 129 | def test_files(self): 130 | reader = NamespaceReader(self.data.__spec__.submodule_search_locations) 131 | root = self.data.__path__[0] 132 | self.assertIsInstance(reader.files(), MultiplexedPath) 133 | self.assertEqual(repr(reader.files()), f"MultiplexedPath('{root}')") 134 | 135 | 136 | if __name__ == '__main__': 137 | unittest.main() 138 | -------------------------------------------------------------------------------- /importlib_resources/tests/test_resource.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from importlib import import_module 3 | 4 | import importlib_resources as resources 5 | 6 | from . import util 7 | 8 | 9 | class ResourceTests: 10 | # Subclasses are expected to set the `data` attribute. 11 | 12 | def test_is_file_exists(self): 13 | target = resources.files(self.data) / 'binary.file' 14 | self.assertTrue(target.is_file()) 15 | 16 | def test_is_file_missing(self): 17 | target = resources.files(self.data) / 'not-a-file' 18 | self.assertFalse(target.is_file()) 19 | 20 | def test_is_dir(self): 21 | target = resources.files(self.data) / 'subdirectory' 22 | self.assertFalse(target.is_file()) 23 | self.assertTrue(target.is_dir()) 24 | 25 | 26 | class ResourceDiskTests(ResourceTests, util.DiskSetup, unittest.TestCase): 27 | pass 28 | 29 | 30 | class ResourceZipTests(ResourceTests, util.ZipSetup, unittest.TestCase): 31 | pass 32 | 33 | 34 | def names(traversable): 35 | return {item.name for item in traversable.iterdir()} 36 | 37 | 38 | class ResourceLoaderTests(util.DiskSetup, unittest.TestCase): 39 | def test_resource_contents(self): 40 | package = util.create_package( 41 | file=self.data, path=self.data.__file__, contents=['A', 'B', 'C'] 42 | ) 43 | self.assertEqual(names(resources.files(package)), {'A', 'B', 'C'}) 44 | 45 | def test_is_file(self): 46 | package = util.create_package( 47 | file=self.data, 48 | path=self.data.__file__, 49 | contents=['A', 'B', 'C', 'D/E', 'D/F'], 50 | ) 51 | self.assertTrue(resources.files(package).joinpath('B').is_file()) 52 | 53 | def test_is_dir(self): 54 | package = util.create_package( 55 | file=self.data, 56 | path=self.data.__file__, 57 | contents=['A', 'B', 'C', 'D/E', 'D/F'], 58 | ) 59 | self.assertTrue(resources.files(package).joinpath('D').is_dir()) 60 | 61 | def test_resource_missing(self): 62 | package = util.create_package( 63 | file=self.data, 64 | path=self.data.__file__, 65 | contents=['A', 'B', 'C', 'D/E', 'D/F'], 66 | ) 67 | self.assertFalse(resources.files(package).joinpath('Z').is_file()) 68 | 69 | 70 | class ResourceCornerCaseTests(util.DiskSetup, unittest.TestCase): 71 | def test_package_has_no_reader_fallback(self): 72 | """ 73 | Test odd ball packages which: 74 | # 1. Do not have a ResourceReader as a loader 75 | # 2. Are not on the file system 76 | # 3. Are not in a zip file 77 | """ 78 | module = util.create_package( 79 | file=self.data, path=self.data.__file__, contents=['A', 'B', 'C'] 80 | ) 81 | # Give the module a dummy loader. 82 | module.__loader__ = object() 83 | # Give the module a dummy origin. 84 | module.__file__ = '/path/which/shall/not/be/named' 85 | module.__spec__.loader = module.__loader__ 86 | module.__spec__.origin = module.__file__ 87 | self.assertFalse(resources.files(module).joinpath('A').is_file()) 88 | 89 | 90 | class ResourceFromZipsTest01(util.ZipSetup, unittest.TestCase): 91 | def test_is_submodule_resource(self): 92 | submodule = import_module('data01.subdirectory') 93 | self.assertTrue(resources.files(submodule).joinpath('binary.file').is_file()) 94 | 95 | def test_read_submodule_resource_by_name(self): 96 | self.assertTrue( 97 | resources.files('data01.subdirectory').joinpath('binary.file').is_file() 98 | ) 99 | 100 | def test_submodule_contents(self): 101 | submodule = import_module('data01.subdirectory') 102 | self.assertEqual( 103 | names(resources.files(submodule)), {'__init__.py', 'binary.file'} 104 | ) 105 | 106 | def test_submodule_contents_by_name(self): 107 | self.assertEqual( 108 | names(resources.files('data01.subdirectory')), 109 | {'__init__.py', 'binary.file'}, 110 | ) 111 | 112 | def test_as_file_directory(self): 113 | with resources.as_file(resources.files('data01')) as data: 114 | assert data.name == 'data01' 115 | assert data.is_dir() 116 | assert data.joinpath('subdirectory').is_dir() 117 | assert len(list(data.iterdir())) 118 | assert not data.parent.exists() 119 | 120 | 121 | class ResourceFromZipsTest02(util.ZipSetup, unittest.TestCase): 122 | MODULE = 'data02' 123 | 124 | def test_unrelated_contents(self): 125 | """ 126 | Test thata zip with two unrelated subpackages return 127 | distinct resources. Ref python/importlib_resources#44. 128 | """ 129 | self.assertEqual( 130 | names(resources.files('data02.one')), 131 | {'__init__.py', 'resource1.txt'}, 132 | ) 133 | self.assertEqual( 134 | names(resources.files('data02.two')), 135 | {'__init__.py', 'resource2.txt'}, 136 | ) 137 | 138 | 139 | class DeletingZipsTest(util.ZipSetup, unittest.TestCase): 140 | """Having accessed resources in a zip file should not keep an open 141 | reference to the zip. 142 | """ 143 | 144 | def test_iterdir_does_not_keep_open(self): 145 | [item.name for item in resources.files('data01').iterdir()] 146 | 147 | def test_is_file_does_not_keep_open(self): 148 | resources.files('data01').joinpath('binary.file').is_file() 149 | 150 | def test_is_file_failure_does_not_keep_open(self): 151 | resources.files('data01').joinpath('not-present').is_file() 152 | 153 | @unittest.skip("Desired but not supported.") 154 | def test_as_file_does_not_keep_open(self): # pragma: no cover 155 | resources.as_file(resources.files('data01') / 'binary.file') 156 | 157 | def test_entered_path_does_not_keep_open(self): 158 | """ 159 | Mimic what certifi does on import to make its bundle 160 | available for the process duration. 161 | """ 162 | resources.as_file(resources.files('data01') / 'binary.file').__enter__() 163 | 164 | def test_read_binary_does_not_keep_open(self): 165 | resources.files('data01').joinpath('binary.file').read_bytes() 166 | 167 | def test_read_text_does_not_keep_open(self): 168 | resources.files('data01').joinpath('utf-8.file').read_text(encoding='utf-8') 169 | 170 | 171 | class ResourceFromNamespaceTests: 172 | def test_is_submodule_resource(self): 173 | self.assertTrue( 174 | resources.files(import_module('namespacedata01')) 175 | .joinpath('binary.file') 176 | .is_file() 177 | ) 178 | 179 | def test_read_submodule_resource_by_name(self): 180 | self.assertTrue( 181 | resources.files('namespacedata01').joinpath('binary.file').is_file() 182 | ) 183 | 184 | def test_submodule_contents(self): 185 | contents = names(resources.files(import_module('namespacedata01'))) 186 | try: 187 | contents.remove('__pycache__') 188 | except KeyError: 189 | pass 190 | self.assertEqual( 191 | contents, {'subdirectory', 'binary.file', 'utf-8.file', 'utf-16.file'} 192 | ) 193 | 194 | def test_submodule_contents_by_name(self): 195 | contents = names(resources.files('namespacedata01')) 196 | try: 197 | contents.remove('__pycache__') 198 | except KeyError: 199 | pass 200 | self.assertEqual( 201 | contents, {'subdirectory', 'binary.file', 'utf-8.file', 'utf-16.file'} 202 | ) 203 | 204 | def test_submodule_sub_contents(self): 205 | contents = names(resources.files(import_module('namespacedata01.subdirectory'))) 206 | try: 207 | contents.remove('__pycache__') 208 | except KeyError: 209 | pass 210 | self.assertEqual(contents, {'binary.file'}) 211 | 212 | def test_submodule_sub_contents_by_name(self): 213 | contents = names(resources.files('namespacedata01.subdirectory')) 214 | try: 215 | contents.remove('__pycache__') 216 | except KeyError: 217 | pass 218 | self.assertEqual(contents, {'binary.file'}) 219 | 220 | 221 | class ResourceFromNamespaceDiskTests( 222 | util.DiskSetup, 223 | ResourceFromNamespaceTests, 224 | unittest.TestCase, 225 | ): 226 | MODULE = 'namespacedata01' 227 | 228 | 229 | class ResourceFromNamespaceZipTests( 230 | util.ZipSetup, 231 | ResourceFromNamespaceTests, 232 | unittest.TestCase, 233 | ): 234 | MODULE = 'namespacedata01' 235 | 236 | 237 | if __name__ == '__main__': 238 | unittest.main() 239 | -------------------------------------------------------------------------------- /importlib_resources/tests/test_util.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from .util import MemorySetup, Traversable 4 | 5 | 6 | class TestMemoryTraversableImplementation(unittest.TestCase): 7 | def test_concrete_methods_are_not_overridden(self): 8 | """`MemoryTraversable` must not override `Traversable` concrete methods. 9 | 10 | This test is not an attempt to enforce a particular `Traversable` protocol; 11 | it merely catches changes in the `Traversable` abstract/concrete methods 12 | that have not been mirrored in the `MemoryTraversable` subclass. 13 | """ 14 | 15 | traversable_concrete_methods = { 16 | method 17 | for method, value in Traversable.__dict__.items() 18 | if callable(value) and method not in Traversable.__abstractmethods__ 19 | } 20 | memory_traversable_concrete_methods = { 21 | method 22 | for method, value in MemorySetup.MemoryTraversable.__dict__.items() 23 | if callable(value) and not method.startswith("__") 24 | } 25 | overridden_methods = ( 26 | memory_traversable_concrete_methods & traversable_concrete_methods 27 | ) 28 | 29 | assert not overridden_methods 30 | -------------------------------------------------------------------------------- /importlib_resources/tests/util.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import contextlib 3 | import functools 4 | import importlib 5 | import io 6 | import pathlib 7 | import sys 8 | import types 9 | from importlib.machinery import ModuleSpec 10 | 11 | from ..abc import ResourceReader, Traversable, TraversableResources 12 | from . import _path 13 | from . import zip as zip_ 14 | from .compat.py39 import import_helper, os_helper 15 | 16 | 17 | class Reader(ResourceReader): 18 | def __init__(self, **kwargs): 19 | vars(self).update(kwargs) 20 | 21 | def get_resource_reader(self, package): 22 | return self 23 | 24 | def open_resource(self, path): 25 | self._path = path 26 | if isinstance(self.file, Exception): 27 | raise self.file 28 | return self.file 29 | 30 | def resource_path(self, path_): 31 | self._path = path_ 32 | if isinstance(self.path, Exception): 33 | raise self.path 34 | return self.path 35 | 36 | def is_resource(self, path_): 37 | self._path = path_ 38 | if isinstance(self.path, Exception): 39 | raise self.path 40 | 41 | def part(entry): 42 | return entry.split('/') 43 | 44 | return any( 45 | len(parts) == 1 and parts[0] == path_ for parts in map(part, self._contents) 46 | ) 47 | 48 | def contents(self): 49 | if isinstance(self.path, Exception): 50 | raise self.path 51 | yield from self._contents 52 | 53 | 54 | def create_package_from_loader(loader, is_package=True): 55 | name = 'testingpackage' 56 | module = types.ModuleType(name) 57 | spec = ModuleSpec(name, loader, origin='does-not-exist', is_package=is_package) 58 | module.__spec__ = spec 59 | module.__loader__ = loader 60 | return module 61 | 62 | 63 | def create_package(file=None, path=None, is_package=True, contents=()): 64 | return create_package_from_loader( 65 | Reader(file=file, path=path, _contents=contents), 66 | is_package, 67 | ) 68 | 69 | 70 | class CommonTestsBase(metaclass=abc.ABCMeta): 71 | """ 72 | Tests shared by test_open, test_path, and test_read. 73 | """ 74 | 75 | @abc.abstractmethod 76 | def execute(self, package, path): 77 | """ 78 | Call the pertinent legacy API function (e.g. open_text, path) 79 | on package and path. 80 | """ 81 | 82 | def test_package_name(self): 83 | """ 84 | Passing in the package name should succeed. 85 | """ 86 | self.execute(self.data.__name__, 'utf-8.file') 87 | 88 | def test_package_object(self): 89 | """ 90 | Passing in the package itself should succeed. 91 | """ 92 | self.execute(self.data, 'utf-8.file') 93 | 94 | def test_string_path(self): 95 | """ 96 | Passing in a string for the path should succeed. 97 | """ 98 | path = 'utf-8.file' 99 | self.execute(self.data, path) 100 | 101 | def test_pathlib_path(self): 102 | """ 103 | Passing in a pathlib.PurePath object for the path should succeed. 104 | """ 105 | path = pathlib.PurePath('utf-8.file') 106 | self.execute(self.data, path) 107 | 108 | def test_importing_module_as_side_effect(self): 109 | """ 110 | The anchor package can already be imported. 111 | """ 112 | del sys.modules[self.data.__name__] 113 | self.execute(self.data.__name__, 'utf-8.file') 114 | 115 | def test_missing_path(self): 116 | """ 117 | Attempting to open or read or request the path for a 118 | non-existent path should succeed if open_resource 119 | can return a viable data stream. 120 | """ 121 | bytes_data = io.BytesIO(b'Hello, world!') 122 | package = create_package(file=bytes_data, path=FileNotFoundError()) 123 | self.execute(package, 'utf-8.file') 124 | self.assertEqual(package.__loader__._path, 'utf-8.file') 125 | 126 | def test_extant_path(self): 127 | # Attempting to open or read or request the path when the 128 | # path does exist should still succeed. Does not assert 129 | # anything about the result. 130 | bytes_data = io.BytesIO(b'Hello, world!') 131 | # any path that exists 132 | path = __file__ 133 | package = create_package(file=bytes_data, path=path) 134 | self.execute(package, 'utf-8.file') 135 | self.assertEqual(package.__loader__._path, 'utf-8.file') 136 | 137 | def test_useless_loader(self): 138 | package = create_package(file=FileNotFoundError(), path=FileNotFoundError()) 139 | with self.assertRaises(FileNotFoundError): 140 | self.execute(package, 'utf-8.file') 141 | 142 | 143 | fixtures = dict( 144 | data01={ 145 | '__init__.py': '', 146 | 'binary.file': bytes(range(4)), 147 | 'utf-16.file': '\ufeffHello, UTF-16 world!\n'.encode('utf-16-le'), 148 | 'utf-8.file': 'Hello, UTF-8 world!\n'.encode('utf-8'), 149 | 'subdirectory': { 150 | '__init__.py': '', 151 | 'binary.file': bytes(range(4, 8)), 152 | }, 153 | }, 154 | data02={ 155 | '__init__.py': '', 156 | 'one': {'__init__.py': '', 'resource1.txt': 'one resource'}, 157 | 'two': {'__init__.py': '', 'resource2.txt': 'two resource'}, 158 | 'subdirectory': {'subsubdir': {'resource.txt': 'a resource'}}, 159 | }, 160 | namespacedata01={ 161 | 'binary.file': bytes(range(4)), 162 | 'utf-16.file': '\ufeffHello, UTF-16 world!\n'.encode('utf-16-le'), 163 | 'utf-8.file': 'Hello, UTF-8 world!\n'.encode('utf-8'), 164 | 'subdirectory': { 165 | 'binary.file': bytes(range(12, 16)), 166 | }, 167 | }, 168 | ) 169 | 170 | 171 | class ModuleSetup: 172 | def setUp(self): 173 | self.fixtures = contextlib.ExitStack() 174 | self.addCleanup(self.fixtures.close) 175 | 176 | self.fixtures.enter_context(import_helper.isolated_modules()) 177 | self.data = self.load_fixture(self.MODULE) 178 | 179 | def load_fixture(self, module): 180 | self.tree_on_path({module: fixtures[module]}) 181 | return importlib.import_module(module) 182 | 183 | 184 | class ZipSetup(ModuleSetup): 185 | MODULE = 'data01' 186 | 187 | def tree_on_path(self, spec): 188 | temp_dir = self.fixtures.enter_context(os_helper.temp_dir()) 189 | modules = pathlib.Path(temp_dir) / 'zipped modules.zip' 190 | self.fixtures.enter_context( 191 | import_helper.DirsOnSysPath(str(zip_.make_zip_file(spec, modules))) 192 | ) 193 | 194 | 195 | class DiskSetup(ModuleSetup): 196 | MODULE = 'data01' 197 | 198 | def tree_on_path(self, spec): 199 | temp_dir = self.fixtures.enter_context(os_helper.temp_dir()) 200 | _path.build(spec, pathlib.Path(temp_dir)) 201 | self.fixtures.enter_context(import_helper.DirsOnSysPath(temp_dir)) 202 | 203 | 204 | class MemorySetup(ModuleSetup): 205 | """Support loading a module in memory.""" 206 | 207 | MODULE = 'data01' 208 | 209 | def load_fixture(self, module): 210 | self.fixtures.enter_context(self.augment_sys_metapath(module)) 211 | return importlib.import_module(module) 212 | 213 | @contextlib.contextmanager 214 | def augment_sys_metapath(self, module): 215 | finder_instance = self.MemoryFinder(module) 216 | sys.meta_path.append(finder_instance) 217 | yield 218 | sys.meta_path.remove(finder_instance) 219 | 220 | class MemoryFinder(importlib.abc.MetaPathFinder): 221 | def __init__(self, module): 222 | self._module = module 223 | 224 | def find_spec(self, fullname, path, target=None): 225 | if fullname != self._module: 226 | return None 227 | 228 | return importlib.machinery.ModuleSpec( 229 | name=fullname, 230 | loader=MemorySetup.MemoryLoader(self._module), 231 | is_package=True, 232 | ) 233 | 234 | class MemoryLoader(importlib.abc.Loader): 235 | def __init__(self, module): 236 | self._module = module 237 | 238 | def exec_module(self, module): 239 | pass 240 | 241 | def get_resource_reader(self, fullname): 242 | return MemorySetup.MemoryTraversableResources(self._module, fullname) 243 | 244 | class MemoryTraversableResources(TraversableResources): 245 | def __init__(self, module, fullname): 246 | self._module = module 247 | self._fullname = fullname 248 | 249 | def files(self): 250 | return MemorySetup.MemoryTraversable(self._module, self._fullname) 251 | 252 | class MemoryTraversable(Traversable): 253 | """Implement only the abstract methods of `Traversable`. 254 | 255 | Besides `.__init__()`, no other methods may be implemented or overridden. 256 | This is critical for validating the concrete `Traversable` implementations. 257 | """ 258 | 259 | def __init__(self, module, fullname): 260 | self._module = module 261 | self._fullname = fullname 262 | 263 | def _resolve(self): 264 | """ 265 | Fully traverse the `fixtures` dictionary. 266 | 267 | This should be wrapped in a `try/except KeyError` 268 | but it is not currently needed and lowers the code coverage numbers. 269 | """ 270 | path = pathlib.PurePosixPath(self._fullname) 271 | return functools.reduce(lambda d, p: d[p], path.parts, fixtures) 272 | 273 | def iterdir(self): 274 | directory = self._resolve() 275 | if not isinstance(directory, dict): 276 | # Filesystem openers raise OSError, and that exception is mirrored here. 277 | raise OSError(f"{self._fullname} is not a directory") 278 | for path in directory: 279 | yield MemorySetup.MemoryTraversable( 280 | self._module, f"{self._fullname}/{path}" 281 | ) 282 | 283 | def is_dir(self) -> bool: 284 | return isinstance(self._resolve(), dict) 285 | 286 | def is_file(self) -> bool: 287 | return not self.is_dir() 288 | 289 | def open(self, mode='r', encoding=None, errors=None, *_, **__): 290 | contents = self._resolve() 291 | if isinstance(contents, dict): 292 | # Filesystem openers raise OSError when attempting to open a directory, 293 | # and that exception is mirrored here. 294 | raise OSError(f"{self._fullname} is a directory") 295 | if isinstance(contents, str): 296 | contents = contents.encode("utf-8") 297 | result = io.BytesIO(contents) 298 | if "b" in mode: 299 | return result 300 | return io.TextIOWrapper(result, encoding=encoding, errors=errors) 301 | 302 | @property 303 | def name(self): 304 | return pathlib.PurePosixPath(self._fullname).name 305 | 306 | 307 | class CommonTests(DiskSetup, CommonTestsBase): 308 | pass 309 | -------------------------------------------------------------------------------- /importlib_resources/tests/zip.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generate zip test data files. 3 | """ 4 | 5 | import zipfile 6 | 7 | import zipp 8 | 9 | 10 | def make_zip_file(tree, dst): 11 | """ 12 | Zip the files in tree into a new zipfile at dst. 13 | """ 14 | with zipfile.ZipFile(dst, 'w') as zf: 15 | for name, contents in walk(tree): 16 | zf.writestr(name, contents) 17 | zipp.CompleteDirs.inject(zf) 18 | return dst 19 | 20 | 21 | def walk(tree, prefix=''): 22 | for name, contents in tree.items(): 23 | if isinstance(contents, dict): 24 | yield from walk(contents, prefix=f'{prefix}{name}/') 25 | else: 26 | yield f'{prefix}{name}', contents 27 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | # Is the project well-typed? 3 | strict = False 4 | 5 | # Early opt-in even when strict = False 6 | warn_unused_ignores = True 7 | warn_redundant_casts = True 8 | enable_error_code = ignore-without-code 9 | 10 | # Support namespace packages per https://github.com/python/mypy/issues/14057 11 | explicit_package_bases = True 12 | 13 | disable_error_code = 14 | # Disable due to many false positives 15 | overload-overlap, 16 | 17 | # jaraco/zipp#123 18 | [mypy-zipp] 19 | ignore_missing_imports = True 20 | 21 | # jaraco/jaraco.test#7 22 | [mypy-jaraco.test.*] 23 | ignore_missing_imports = True 24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=77", 4 | "setuptools_scm[toml]>=3.4.1", 5 | # jaraco/skeleton#174 6 | "coherent.licensed", 7 | ] 8 | build-backend = "setuptools.build_meta" 9 | 10 | [project] 11 | name = "importlib_resources" 12 | authors = [ 13 | { name = "Barry Warsaw", email = "barry@python.org" }, 14 | ] 15 | maintainers = [ 16 | { name = "Jason R. Coombs", email = "jaraco@jaraco.com" }, 17 | ] 18 | description = "Read resources from Python packages" 19 | readme = "README.rst" 20 | classifiers = [ 21 | "Development Status :: 5 - Production/Stable", 22 | "Intended Audience :: Developers", 23 | "Programming Language :: Python :: 3", 24 | "Programming Language :: Python :: 3 :: Only", 25 | ] 26 | requires-python = ">=3.9" 27 | license = "Apache-2.0" 28 | dependencies = [ 29 | "zipp >= 3.1.0; python_version < '3.10'", 30 | ] 31 | dynamic = ["version"] 32 | 33 | [project.urls] 34 | Source = "https://github.com/python/importlib_resources" 35 | 36 | [project.optional-dependencies] 37 | test = [ 38 | # upstream 39 | "pytest >= 6, != 8.1.*", 40 | 41 | # local 42 | "zipp >= 3.17", 43 | "jaraco.test >= 5.4", 44 | ] 45 | 46 | doc = [ 47 | # upstream 48 | "sphinx >= 3.5", 49 | "jaraco.packaging >= 9.3", 50 | "rst.linker >= 1.9", 51 | "furo", 52 | "sphinx-lint", 53 | 54 | # tidelift 55 | "jaraco.tidelift >= 1.4", 56 | 57 | # local 58 | ] 59 | 60 | check = [ 61 | "pytest-checkdocs >= 2.4", 62 | "pytest-ruff >= 0.2.1; sys_platform != 'cygwin'", 63 | ] 64 | 65 | cover = [ 66 | "pytest-cov", 67 | ] 68 | 69 | enabler = [ 70 | "pytest-enabler >= 2.2", 71 | ] 72 | 73 | type = [ 74 | # upstream 75 | "pytest-mypy", 76 | 77 | # local 78 | ] 79 | 80 | 81 | [tool.setuptools_scm] 82 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs=dist build .tox .eggs 3 | addopts= 4 | --doctest-modules 5 | --import-mode importlib 6 | consider_namespace_packages=true 7 | filterwarnings= 8 | ## upstream 9 | 10 | # Ensure ResourceWarnings are emitted 11 | default::ResourceWarning 12 | 13 | # realpython/pytest-mypy#152 14 | ignore:'encoding' argument not specified::pytest_mypy 15 | 16 | # python/cpython#100750 17 | ignore:'encoding' argument not specified::platform 18 | 19 | # pypa/build#615 20 | ignore:'encoding' argument not specified::build.env 21 | 22 | # dateutil/dateutil#1284 23 | ignore:datetime.datetime.utcfromtimestamp:DeprecationWarning:dateutil.tz.tz 24 | 25 | ## end upstream 26 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | [lint] 2 | extend-select = [ 3 | # upstream 4 | 5 | "C901", # complex-structure 6 | "I", # isort 7 | "PERF401", # manual-list-comprehension 8 | 9 | # Ensure modern type annotation syntax and best practices 10 | # Not including those covered by type-checkers or exclusive to Python 3.11+ 11 | "FA", # flake8-future-annotations 12 | "F404", # late-future-import 13 | "PYI", # flake8-pyi 14 | "UP006", # non-pep585-annotation 15 | "UP007", # non-pep604-annotation 16 | "UP010", # unnecessary-future-import 17 | "UP035", # deprecated-import 18 | "UP037", # quoted-annotation 19 | "UP043", # unnecessary-default-type-args 20 | 21 | # local 22 | ] 23 | ignore = [ 24 | # upstream 25 | 26 | # Typeshed rejects complex or non-literal defaults for maintenance and testing reasons, 27 | # irrelevant to this project. 28 | "PYI011", # typed-argument-default-in-stub 29 | # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules 30 | "W191", 31 | "E111", 32 | "E114", 33 | "E117", 34 | "D206", 35 | "D300", 36 | "Q000", 37 | "Q001", 38 | "Q002", 39 | "Q003", 40 | "COM812", 41 | "COM819", 42 | 43 | # local 44 | ] 45 | 46 | [format] 47 | # Enable preview to get hugged parenthesis unwrapping and other nice surprises 48 | # See https://github.com/jaraco/skeleton/pull/133#issuecomment-2239538373 49 | preview = true 50 | # https://docs.astral.sh/ruff/settings/#format_quote-style 51 | quote-style = "preserve" 52 | -------------------------------------------------------------------------------- /towncrier.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | title_format = "{version}" 3 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv] 2 | description = perform primary checks (tests, style, types, coverage) 3 | deps = 4 | setenv = 5 | PYTHONWARNDEFAULTENCODING = 1 6 | commands = 7 | pytest {posargs} 8 | usedevelop = True 9 | extras = 10 | test 11 | check 12 | cover 13 | enabler 14 | type 15 | 16 | [testenv:diffcov] 17 | description = run tests and check that diff from main is covered 18 | deps = 19 | {[testenv]deps} 20 | diff-cover 21 | commands = 22 | pytest {posargs} --cov-report xml 23 | diff-cover coverage.xml --compare-branch=origin/main --html-report diffcov.html 24 | diff-cover coverage.xml --compare-branch=origin/main --fail-under=100 25 | 26 | [testenv:docs] 27 | description = build the documentation 28 | extras = 29 | doc 30 | test 31 | changedir = docs 32 | commands = 33 | python -m sphinx -W --keep-going . {toxinidir}/build/html 34 | python -m sphinxlint 35 | 36 | [testenv:finalize] 37 | description = assemble changelog and tag a release 38 | skip_install = True 39 | deps = 40 | towncrier 41 | jaraco.develop >= 7.23 42 | pass_env = * 43 | commands = 44 | python -m jaraco.develop.finalize 45 | 46 | 47 | [testenv:release] 48 | description = publish the package to PyPI and GitHub 49 | skip_install = True 50 | deps = 51 | build 52 | twine>=3 53 | jaraco.develop>=7.1 54 | pass_env = 55 | TWINE_PASSWORD 56 | GITHUB_TOKEN 57 | setenv = 58 | TWINE_USERNAME = {env:TWINE_USERNAME:__token__} 59 | commands = 60 | python -c "import shutil; shutil.rmtree('dist', ignore_errors=True)" 61 | python -m build 62 | python -m twine upload dist/* 63 | python -m jaraco.develop.create-github-release 64 | --------------------------------------------------------------------------------