1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368
|
#-----------------------------------------------------------------------------
# Copyright (c) 2021-2023, PyInstaller Development Team.
#
# Distributed under the terms of the GNU General Public License (version 2
# or later) with exception for distributing the bootloader.
#
# The full license is in the file COPYING.txt, distributed with this software.
#
# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
#-----------------------------------------------------------------------------
#
# A test script for validation of importlib.resources / importlib_resources resource reader implementation.
#
# The test package has the following structure:
#
# pyi_pkgres_testpkg/
# ├── a.py
# ├── b.py
# ├── __init__.py
# ├── subpkg1
# │ ├── c.py
# │ ├── data
# │ │ ├── entry1.txt
# │ │ ├── entry2.md
# │ │ ├── entry3.rst
# │ │ └── extra
# │ │ └── extra_entry1.json
# │ ├── d.py
# │ └── __init__.py
# ├── subpkg2
# │ ├── __init__.py
# │ ├── mod.py
# │ └── subsubpkg21
# │ ├── __init__.py
# │ └── mod.py
# └── subpkg3
# ├── _datafile.json
# └── __init__.py
#
# When run as unfrozen script, this script can be used to check the behavior of "native" resource readers (or
# compatibility adapters for python-provided loaders) that are provided by importlib.resources (or its
# importlib_resources back-port for python 3.8 and earlier).
#
# When run as a frozen application, this script validates the behavior of the resource reader implemented by
# PyInstaller. Due to transitivity of test results, this script running without errors both as a native script and
# as a frozen application serves as proof of conformance for the PyInstaller's reader.
#
# NOTE: this test is modelled after pkg_resources test, but it does not support zipped eggs. Similarly, it assumes that
# in the frozen version, only access to on-disk resources is provided (either by importlib.resources's adapter, or by
# PyInstaller's custom resource reader, if/when implemented). Without custom resource reader, the frozen version of the
# test seems to work with the importlib.resources (python 3.9 and later) but not with the importlib_resources (python
# 3.8 and earlier), due to implementation differences.
#
# NOTE: functions `contents`, `is_resource`, `path`, `read_binary`, and `read_text` have been deprecated in python 3.11
# stdlib (importlib_resources 5.7) and removed in python 3.13 stdlib (importlib_resources 6.0). We test these based on
# their availability.
#
# NOTE: the afore-mentioned functions were brought back in importlib_resources 6.4.0 (and the change was backported to
# python 3.13b1). Their behavior is changed; if they are given a sub-path as a resource name, they do not raise a
# ValueError.
import sys
import pathlib
# Ensure importlib.resources from python 3.9 stdlib or equivalent importlib_resources >= 1.3.
try:
import importlib.resources as importlib_resources
if not hasattr(importlib_resources, "files"):
raise ImportError("Built-in importlib.resources is too old!")
is_builtin = True
package_name = 'importlib.resources'
print("Using built-in importlib.resources...")
except ImportError:
import importlib_resources
is_builtin = False
package_name = 'importlib_resources'
print("Using backported importlib_resources...")
is_frozen = getattr(sys, 'frozen', False)
# Try to determine the presence of new functional API, which was introduced in importlib_resources 6.4. As per note at
# the top of the file, the new functional API allows sub-paths to be passed as resource names. In importlib_resources
# 6.4, the module was named `functional`, although it will likely be renamed to `_functional` in subsequent versions
# (see https://github.com/python/importlib_resources/pull/306). The functional API was also backported to python 3.13b1
# stdlib, where the module is already called `_functional`.
new_functional_api = hasattr(importlib_resources, 'functional') or hasattr(importlib_resources, '_functional')
########################################################################
# Validate behavior of importlib.resources.contents() #
########################################################################
if hasattr(importlib_resources, "contents"):
print(f"Testing {package_name}.contents()...")
def _contents_test(pkgname, expected):
# For frozen application, remove .py files from expected results.
if is_frozen:
expected = [x for x in expected if not x.endswith('.py')]
# List the content
content = list(importlib_resources.contents(pkgname))
# Ignore pycache
if '__pycache__' in content:
content.remove('__pycache__')
assert sorted(content) == sorted(expected), f"Content mismatch: {sorted(content)} vs. {sorted(expected)}"
# NOTE: contents() seems to be broken in importlib.resources (python 3.9) for zipped eggs, as it also recursively
# lists all subdirectories (but not their files) instead of just directories within the package directory.
# Top-level package
expected = ['__init__.py', 'a.py', 'b.py', 'subpkg1', 'subpkg2', 'subpkg3']
if is_frozen:
expected.remove('subpkg2') # FIXME: frozen reader does not list directories that are not on filesystem.
_contents_test('pyi_pkgres_testpkg', expected)
# Subpackage #1
expected = ['__init__.py', 'c.py', 'd.py', 'data']
_contents_test('pyi_pkgres_testpkg.subpkg1', expected)
# Subpackage #2
if not is_frozen:
# Cannot list non-existing directory.
expected = ['__init__.py', 'mod.py', 'subsubpkg21']
_contents_test('pyi_pkgres_testpkg.subpkg2', expected)
# Sub-subpackage in subpackage #2
if not is_frozen:
# Cannot list non-existing directory.
expected = ['__init__.py', 'mod.py']
_contents_test('pyi_pkgres_testpkg.subpkg2.subsubpkg21', expected)
# Subpackage #3
expected = ['__init__.py', '_datafile.json']
_contents_test('pyi_pkgres_testpkg.subpkg3', expected)
else:
print(f"Skipping {package_name}.contents() test...")
########################################################################
# Validate importlib.resources.is_resource() #
########################################################################
if hasattr(importlib_resources, "is_resource"):
print(f"Testing {package_name}.is_resource()...")
# In general, files (source or data) count as resources, directories do not.
# Querying non-existent resource: return False instead of raising FileNotFoundError
assert not importlib_resources.is_resource('pyi_pkgres_testpkg', 'subpkg_nonexistant')
assert not importlib_resources.is_resource('pyi_pkgres_testpkg.subpkg1', 'nonexistant.txt')
# NOTE: frozen reader does not list .py files (nor equivalent .pyc ones)
assert importlib_resources.is_resource('pyi_pkgres_testpkg', '__init__.py') is not is_frozen
assert importlib_resources.is_resource('pyi_pkgres_testpkg', '__init__.py') is not is_frozen
assert importlib_resources.is_resource('pyi_pkgres_testpkg', 'a.py') is not is_frozen
assert importlib_resources.is_resource('pyi_pkgres_testpkg', 'b.py') is not is_frozen
assert not importlib_resources.is_resource('pyi_pkgres_testpkg', 'subpkg1')
assert not importlib_resources.is_resource('pyi_pkgres_testpkg', 'subpkg2') # Non-existent in frozen variant.
assert not importlib_resources.is_resource('pyi_pkgres_testpkg', 'subpkg3')
assert importlib_resources.is_resource('pyi_pkgres_testpkg.subpkg1', '__init__.py') is not is_frozen
assert not importlib_resources.is_resource('pyi_pkgres_testpkg.subpkg1', 'data')
# Try to specify a sub-path; should raise ValueError, unless importlib_resources >= 6.4.0.
try:
ret = importlib_resources.is_resource('pyi_pkgres_testpkg.subpkg1', 'data/entry1.txt')
except ValueError:
pass
except Exception:
raise
else:
assert new_functional_api, "Expected a ValueError!"
# If we passed above check for importlib_resources >= 6.4.0, check the return value.
assert ret
if not is_frozen:
assert importlib_resources.is_resource('pyi_pkgres_testpkg.subpkg2', 'mod.py')
assert importlib_resources.is_resource('pyi_pkgres_testpkg.subpkg3', '_datafile.json')
else:
print(f"Skipping {package_name}.is_resource() test...")
########################################################################
# Validate importlib.resources.path() #
########################################################################
if hasattr(importlib_resources, "path"):
print(f"Testing {package_name}.path()...")
# The path() function returns on-disk path. If the resource was originally on disk, direct path to it is returned.
# Otherwise, path to a temporary file is returned. This function is probably superseded by files() and as_file(),
# which are more flexible; for example, path() does not allow access to files in sub-directories (only files that
# are directly within a package directory).
def _path_test(pkgname, resource, expected_data):
with importlib_resources.path(pkgname, resource) as pth:
assert isinstance(pth, pathlib.Path)
with open(pth, 'rb') as fp:
data = fp.read()
if expected_data is not None:
# Split to avoid OS-specific newline discrepancies.
assert data.splitlines() == expected_data.splitlines()
if not is_frozen:
expected_data = b"""from . import a, b # noqa: F401\nfrom . import subpkg1, subpkg2, subpkg3 # noqa: F401\n"""
_path_test('pyi_pkgres_testpkg', '__init__.py', expected_data)
if not is_frozen:
expected_data = b"""#\n"""
_path_test('pyi_pkgres_testpkg.subpkg2', 'mod.py', expected_data)
expected_data = b"""{\n "_comment": "Data file in supbkg3."\n}\n"""
_path_test('pyi_pkgres_testpkg.subpkg3', '_datafile.json', expected_data)
# Try with a non-existent file; should raise FileNotFoundError.
# NOTE: importlib.resources in python 3.9 seems to do so, but importlib_resources 5.2.2 does not...
try:
_path_test('pyi_pkgres_testpkg.subpkg1', 'nonexistant.txt', None)
except FileNotFoundError:
pass
except Exception:
raise
else:
assert not is_builtin, "Expected a FileNotFoundError!"
# Try to specify a sub-path; should raise ValueError, unless importlib_resources >= 6.4.0.
try:
_path_test('pyi_pkgres_testpkg.subpkg1', 'data/entry1.txt', None)
except ValueError:
pass
except Exception:
raise
else:
assert new_functional_api, "Expected a ValueError!"
else:
print(f"Skipping {package_name}.path() test...")
########################################################################
# Validate importlib.resources.read_binary() #
########################################################################
if hasattr(importlib_resources, "read_binary"):
print(f"Testing {package_name}.read_binary()...")
# Data file in pyi_pkgres_testpkg.subpkg3
expected_data = b"""{\n "_comment": "Data file in supbkg3."\n}\n"""
data = importlib_resources.read_binary('pyi_pkgres_testpkg.subpkg3', '_datafile.json')
assert data.splitlines() == expected_data.splitlines()
# Source file in pyi_pkgres_testpkg
if not is_frozen:
expected_data = b"""from . import a, b # noqa: F401\nfrom . import subpkg1, subpkg2, subpkg3 # noqa: F401\n"""
data = importlib_resources.read_binary('pyi_pkgres_testpkg', '__init__.py')
assert data.splitlines() == expected_data.splitlines()
# Try with non-existent file; should raise FileNotFoundError
try:
importlib_resources.read_binary('pyi_pkgres_testpkg.subpkg1', 'nonexistant.txt')
except FileNotFoundError:
pass
except Exception:
raise
else:
assert False, "Expected a FileNotFoundError!"
# Try to specify sub-path; should raise ValueError, unless importlib_resources >= 6.4.0.
try:
importlib_resources.read_binary('pyi_pkgres_testpkg.subpkg1', 'data/entry1.txt')
except ValueError:
pass
except Exception:
raise
else:
assert new_functional_api, "Expected a ValueError!"
else:
print(f"Skipping {package_name}.read_binary() test...")
########################################################################
# Validate importlib.resources.read_text() #
########################################################################
if hasattr(importlib_resources, "read_text"):
print(f"Testing {package_name}.read_text()...")
# Data file in pyi_pkgres_testpkg.subpkg3
expected_data = """{\n "_comment": "Data file in supbkg3."\n}\n"""
data = importlib_resources.read_text('pyi_pkgres_testpkg.subpkg3', '_datafile.json', encoding='utf8')
assert data.splitlines() == expected_data.splitlines()
# Source file in pyi_pkgres_testpkg
if not is_frozen:
expected_data = """from . import a, b # noqa: F401\nfrom . import subpkg1, subpkg2, subpkg3 # noqa: F401\n"""
data = importlib_resources.read_text('pyi_pkgres_testpkg', '__init__.py', encoding='utf8')
assert data.splitlines() == expected_data.splitlines()
# Try with non-existent file; should raise FileNotFoundError
try:
importlib_resources.read_text('pyi_pkgres_testpkg.subpkg1', 'nonexistant.txt', encoding='utf8')
except FileNotFoundError:
pass
except Exception:
raise
else:
assert False, "Expected a FileNotFoundError!"
# Try to specify sub-path; should raise ValueError, unless importlib_resources >= 6.4.0.
try:
importlib_resources.read_text('pyi_pkgres_testpkg.subpkg1', 'data/entry1.txt', encoding='utf8')
except ValueError:
pass
except Exception:
raise
else:
assert new_functional_api, "Expected a ValueError!"
else:
print(f"Skipping {package_name}.read_text() test...")
########################################################################
# Validate importlib.resources.files() and as_file() #
########################################################################
print(f"Testing {package_name}.files() and {package_name}.as_file()...")
# files() should return a Traversable (or just a plain pathlib.Path). For on-disk resources, as_file() should not be
# opening a temporary-file copy.
pkg_path = importlib_resources.files('pyi_pkgres_testpkg')
subpkg1_path = importlib_resources.files('pyi_pkgres_testpkg.subpkg1')
# assert subpkg1_path == pkg_path / 'subpkg1' # True only for on-disk resources!
# Try to get data file in a sub-directory with subpath consisting of single string. This simulates the use in
# https://github.com/Unidata/MetPy/blob/a3424de66a44bf3a92b0dcacf4dff82ad7b86712/src/metpy/plots/wx_symbols.py#L24-L25
# that seems to trip up the back-ported importlib_resources when we do not provide our own resource reader. In that
# case, its compatibility adapter ends up triggering the copy-to-temporary-file codepath, which errors out because the
# it ends up generating a temporary file name with separator (that would require creation of intermediate directory).
# It works correctly with built-in importlib.resources in python 3.9 (even if we do not implement a resource reader).
data_path = subpkg1_path / 'data/entry1.txt'
expected_data = b"""Data entry #1 in subpkg1/data.\n"""
with importlib_resources.as_file(data_path) as file_path:
with open(file_path, 'rb') as fp:
data = fp.read()
assert data.splitlines() == expected_data.splitlines()
# Try to get a data file in a sub-directory via two paths. The read contents should be the same.
# If we do not provide our own resource reader, neither way of accessing works in frozen version with back-ported
# importlib_resources (``FileNotFoundError: Can't open orphan path``).
# It works correctly with built-in importlib.resources in python 3.9 (even if we do not implement a resource reader).
expected_data = b"""Data entry #2 in `subpkg1/data`.\n"""
data_path = pkg_path / 'subpkg1' / 'data' / 'entry2.md'
with importlib_resources.as_file(data_path) as file_path:
with open(file_path, 'rb') as fp:
data = fp.read()
assert data.splitlines() == expected_data.splitlines()
data_path = subpkg1_path / 'data' / 'entry2.md'
with importlib_resources.as_file(data_path) as file_path:
with open(file_path, 'rb') as fp:
data = fp.read()
assert data.splitlines() == expected_data.splitlines()
# Test that for submodules, files() returns the path to their parent package.
# See https://github.com/pyinstaller/pyinstaller/issues/8659
#
# NOTE: passing a module name to files() seems to be supported only under python >= 3.12 (stdlib) or equivalent
# importlib_resources >= 5.12. Under earlier versions, it raises `TypeError: '<name>' is not a package`.
try:
assert importlib_resources.files('pyi_pkgres_testpkg.a') == pkg_path
except TypeError:
pass
try:
assert importlib_resources.files('pyi_pkgres_testpkg.subpkg1.c') == subpkg1_path
except TypeError:
pass
|