""" This test script is adopted from: https://github.com/numpy/numpy/blob/main/numpy/tests/test_public_api.py """ import pkgutil import types import importlib import warnings from importlib import import_module import pytest import scipy from scipy.conftest import xp_available_backends def test_dir_testing(): """Assert that output of dir has only one "testing/tester" attribute without duplicate""" assert len(dir(scipy)) == len(set(dir(scipy))) # Historically SciPy has not used leading underscores for private submodules # much. This has resulted in lots of things that look like public modules # (i.e. things that can be imported as `import scipy.somesubmodule.somefile`), # but were never intended to be public. The PUBLIC_MODULES list contains # modules that are either public because they were meant to be, or because they # contain public functions/objects that aren't present in any other namespace # for whatever reason and therefore should be treated as public. PUBLIC_MODULES = ["scipy." + s for s in [ "cluster", "cluster.vq", "cluster.hierarchy", "constants", "datasets", "fft", "fftpack", "integrate", "interpolate", "io", "io.arff", "io.matlab", "io.wavfile", "linalg", "linalg.blas", "linalg.cython_blas", "linalg.lapack", "linalg.cython_lapack", "linalg.interpolative", "misc", "ndimage", "odr", "optimize", "signal", "signal.windows", "sparse", "sparse.linalg", "sparse.csgraph", "spatial", "spatial.distance", "spatial.transform", "special", "stats", "stats.contingency", "stats.distributions", "stats.mstats", "stats.qmc", "stats.sampling" ]] # The PRIVATE_BUT_PRESENT_MODULES list contains modules that lacked underscores # in their name and hence looked public, but weren't meant to be. All these # namespace were deprecated in the 1.8.0 release - see "clear split between # public and private API" in the 1.8.0 release notes. # These private modules support will be removed in SciPy v2.0.0, as the # deprecation messages emitted by each of these modules say. PRIVATE_BUT_PRESENT_MODULES = [ 'scipy.constants.codata', 'scipy.constants.constants', 'scipy.fftpack.basic', 'scipy.fftpack.convolve', 'scipy.fftpack.helper', 'scipy.fftpack.pseudo_diffs', 'scipy.fftpack.realtransforms', 'scipy.integrate.dop', 'scipy.integrate.lsoda', 'scipy.integrate.odepack', 'scipy.integrate.quadpack', 'scipy.integrate.vode', 'scipy.interpolate.dfitpack', 'scipy.interpolate.fitpack', 'scipy.interpolate.fitpack2', 'scipy.interpolate.interpnd', 'scipy.interpolate.interpolate', 'scipy.interpolate.ndgriddata', 'scipy.interpolate.polyint', 'scipy.interpolate.rbf', 'scipy.io.arff.arffread', 'scipy.io.harwell_boeing', 'scipy.io.idl', 'scipy.io.matlab.byteordercodes', 'scipy.io.matlab.mio', 'scipy.io.matlab.mio4', 'scipy.io.matlab.mio5', 'scipy.io.matlab.mio5_params', 'scipy.io.matlab.mio5_utils', 'scipy.io.matlab.mio_utils', 'scipy.io.matlab.miobase', 'scipy.io.matlab.streams', 'scipy.io.mmio', 'scipy.io.netcdf', 'scipy.linalg.basic', 'scipy.linalg.decomp', 'scipy.linalg.decomp_cholesky', 'scipy.linalg.decomp_lu', 'scipy.linalg.decomp_qr', 'scipy.linalg.decomp_schur', 'scipy.linalg.decomp_svd', 'scipy.linalg.matfuncs', 'scipy.linalg.misc', 'scipy.linalg.special_matrices', 'scipy.misc.common', 'scipy.misc.doccer', 'scipy.ndimage.filters', 'scipy.ndimage.fourier', 'scipy.ndimage.interpolation', 'scipy.ndimage.measurements', 'scipy.ndimage.morphology', 'scipy.odr.models', 'scipy.odr.odrpack', 'scipy.optimize.cobyla', 'scipy.optimize.cython_optimize', 'scipy.optimize.lbfgsb', 'scipy.optimize.linesearch', 'scipy.optimize.minpack', 'scipy.optimize.minpack2', 'scipy.optimize.moduleTNC', 'scipy.optimize.nonlin', 'scipy.optimize.optimize', 'scipy.optimize.slsqp', 'scipy.optimize.tnc', 'scipy.optimize.zeros', 'scipy.signal.bsplines', 'scipy.signal.filter_design', 'scipy.signal.fir_filter_design', 'scipy.signal.lti_conversion', 'scipy.signal.ltisys', 'scipy.signal.signaltools', 'scipy.signal.spectral', 'scipy.signal.spline', 'scipy.signal.waveforms', 'scipy.signal.wavelets', 'scipy.signal.windows.windows', 'scipy.sparse.base', 'scipy.sparse.bsr', 'scipy.sparse.compressed', 'scipy.sparse.construct', 'scipy.sparse.coo', 'scipy.sparse.csc', 'scipy.sparse.csr', 'scipy.sparse.data', 'scipy.sparse.dia', 'scipy.sparse.dok', 'scipy.sparse.extract', 'scipy.sparse.lil', 'scipy.sparse.linalg.dsolve', 'scipy.sparse.linalg.eigen', 'scipy.sparse.linalg.interface', 'scipy.sparse.linalg.isolve', 'scipy.sparse.linalg.matfuncs', 'scipy.sparse.sparsetools', 'scipy.sparse.spfuncs', 'scipy.sparse.sputils', 'scipy.spatial.ckdtree', 'scipy.spatial.kdtree', 'scipy.spatial.qhull', 'scipy.spatial.transform.rotation', 'scipy.special.add_newdocs', 'scipy.special.basic', 'scipy.special.cython_special', 'scipy.special.orthogonal', 'scipy.special.sf_error', 'scipy.special.specfun', 'scipy.special.spfun_stats', 'scipy.stats.biasedurn', 'scipy.stats.kde', 'scipy.stats.morestats', 'scipy.stats.mstats_basic', 'scipy.stats.mstats_extras', 'scipy.stats.mvn', 'scipy.stats.stats', ] def is_unexpected(name): """Check if this needs to be considered.""" if '._' in name or '.tests' in name or '.setup' in name: return False if name in PUBLIC_MODULES: return False if name in PRIVATE_BUT_PRESENT_MODULES: return False return True SKIP_LIST = [ 'scipy.conftest', 'scipy.version', ] # XXX: this test does more than it says on the tin - in using `pkgutil.walk_packages`, # it will raise if it encounters any exceptions which are not handled by `ignore_errors` # while attempting to import each discovered package. # For now, `ignore_errors` only ignores what is necessary, but this could be expanded - # for example, to all errors from private modules or git subpackages - if desired. def test_all_modules_are_expected(): """ Test that we don't add anything that looks like a new public module by accident. Check is based on filenames. """ def ignore_errors(name): # if versions of other array libraries are installed which are incompatible # with the installed NumPy version, there can be errors on importing # `array_api_compat`. This should only raise if SciPy is configured with # that library as an available backend. for backend, dir_name in {'cupy': 'cupy', 'pytorch': 'torch'}.items(): path = f'array_api_compat.{dir_name}' if path in name and backend not in xp_available_backends: return raise modnames = [] for _, modname, _ in pkgutil.walk_packages(path=scipy.__path__, prefix=scipy.__name__ + '.', onerror=ignore_errors): if is_unexpected(modname) and modname not in SKIP_LIST: # We have a name that is new. If that's on purpose, add it to # PUBLIC_MODULES. We don't expect to have to add anything to # PRIVATE_BUT_PRESENT_MODULES. Use an underscore in the name! modnames.append(modname) if modnames: raise AssertionError(f'Found unexpected modules: {modnames}') # Stuff that clearly shouldn't be in the API and is detected by the next test # below SKIP_LIST_2 = [ 'scipy.char', 'scipy.rec', 'scipy.emath', 'scipy.math', 'scipy.random', 'scipy.ctypeslib', 'scipy.ma' ] def test_all_modules_are_expected_2(): """ Method checking all objects. The pkgutil-based method in `test_all_modules_are_expected` does not catch imports into a namespace, only filenames. """ def find_unexpected_members(mod_name): members = [] module = importlib.import_module(mod_name) if hasattr(module, '__all__'): objnames = module.__all__ else: objnames = dir(module) for objname in objnames: if not objname.startswith('_'): fullobjname = mod_name + '.' + objname if isinstance(getattr(module, objname), types.ModuleType): if is_unexpected(fullobjname) and fullobjname not in SKIP_LIST_2: members.append(fullobjname) return members unexpected_members = find_unexpected_members("scipy") for modname in PUBLIC_MODULES: unexpected_members.extend(find_unexpected_members(modname)) if unexpected_members: raise AssertionError("Found unexpected object(s) that look like " f"modules: {unexpected_members}") def test_api_importable(): """ Check that all submodules listed higher up in this file can be imported Note that if a PRIVATE_BUT_PRESENT_MODULES entry goes missing, it may simply need to be removed from the list (deprecation may or may not be needed - apply common sense). """ def check_importable(module_name): try: importlib.import_module(module_name) except (ImportError, AttributeError): return False return True module_names = [] for module_name in PUBLIC_MODULES: if not check_importable(module_name): module_names.append(module_name) if module_names: raise AssertionError("Modules in the public API that cannot be " f"imported: {module_names}") with warnings.catch_warnings(record=True): warnings.filterwarnings('always', category=DeprecationWarning) warnings.filterwarnings('always', category=ImportWarning) for module_name in PRIVATE_BUT_PRESENT_MODULES: if not check_importable(module_name): module_names.append(module_name) if module_names: raise AssertionError("Modules that are not really public but looked " "public and can not be imported: " f"{module_names}") @pytest.mark.parametrize(("module_name", "correct_module"), [('scipy.constants.codata', None), ('scipy.constants.constants', None), ('scipy.fftpack.basic', None), ('scipy.fftpack.helper', None), ('scipy.fftpack.pseudo_diffs', None), ('scipy.fftpack.realtransforms', None), ('scipy.integrate.dop', None), ('scipy.integrate.lsoda', None), ('scipy.integrate.odepack', None), ('scipy.integrate.quadpack', None), ('scipy.integrate.vode', None), ('scipy.interpolate.fitpack', None), ('scipy.interpolate.fitpack2', None), ('scipy.interpolate.interpolate', None), ('scipy.interpolate.ndgriddata', None), ('scipy.interpolate.polyint', None), ('scipy.interpolate.rbf', None), ('scipy.io.harwell_boeing', None), ('scipy.io.idl', None), ('scipy.io.mmio', None), ('scipy.io.netcdf', None), ('scipy.io.arff.arffread', 'arff'), ('scipy.io.matlab.byteordercodes', 'matlab'), ('scipy.io.matlab.mio_utils', 'matlab'), ('scipy.io.matlab.mio', 'matlab'), ('scipy.io.matlab.mio4', 'matlab'), ('scipy.io.matlab.mio5_params', 'matlab'), ('scipy.io.matlab.mio5_utils', 'matlab'), ('scipy.io.matlab.mio5', 'matlab'), ('scipy.io.matlab.miobase', 'matlab'), ('scipy.io.matlab.streams', 'matlab'), ('scipy.linalg.basic', None), ('scipy.linalg.decomp', None), ('scipy.linalg.decomp_cholesky', None), ('scipy.linalg.decomp_lu', None), ('scipy.linalg.decomp_qr', None), ('scipy.linalg.decomp_schur', None), ('scipy.linalg.decomp_svd', None), ('scipy.linalg.matfuncs', None), ('scipy.linalg.misc', None), ('scipy.linalg.special_matrices', None), ('scipy.misc.common', None), ('scipy.ndimage.filters', None), ('scipy.ndimage.fourier', None), ('scipy.ndimage.interpolation', None), ('scipy.ndimage.measurements', None), ('scipy.ndimage.morphology', None), ('scipy.odr.models', None), ('scipy.odr.odrpack', None), ('scipy.optimize.cobyla', None), ('scipy.optimize.lbfgsb', None), ('scipy.optimize.linesearch', None), ('scipy.optimize.minpack', None), ('scipy.optimize.minpack2', None), ('scipy.optimize.moduleTNC', None), ('scipy.optimize.nonlin', None), ('scipy.optimize.optimize', None), ('scipy.optimize.slsqp', None), ('scipy.optimize.tnc', None), ('scipy.optimize.zeros', None), ('scipy.signal.bsplines', None), ('scipy.signal.filter_design', None), ('scipy.signal.fir_filter_design', None), ('scipy.signal.lti_conversion', None), ('scipy.signal.ltisys', None), ('scipy.signal.signaltools', None), ('scipy.signal.spectral', None), ('scipy.signal.waveforms', None), ('scipy.signal.wavelets', None), ('scipy.signal.windows.windows', 'windows'), ('scipy.sparse.lil', None), ('scipy.sparse.linalg.dsolve', 'linalg'), ('scipy.sparse.linalg.eigen', 'linalg'), ('scipy.sparse.linalg.interface', 'linalg'), ('scipy.sparse.linalg.isolve', 'linalg'), ('scipy.sparse.linalg.matfuncs', 'linalg'), ('scipy.sparse.sparsetools', None), ('scipy.sparse.spfuncs', None), ('scipy.sparse.sputils', None), ('scipy.spatial.ckdtree', None), ('scipy.spatial.kdtree', None), ('scipy.spatial.qhull', None), ('scipy.spatial.transform.rotation', 'transform'), ('scipy.special.add_newdocs', None), ('scipy.special.basic', None), ('scipy.special.orthogonal', None), ('scipy.special.sf_error', None), ('scipy.special.specfun', None), ('scipy.special.spfun_stats', None), ('scipy.stats.biasedurn', None), ('scipy.stats.kde', None), ('scipy.stats.morestats', None), ('scipy.stats.mstats_basic', 'mstats'), ('scipy.stats.mstats_extras', 'mstats'), ('scipy.stats.mvn', None), ('scipy.stats.stats', None)]) def test_private_but_present_deprecation(module_name, correct_module): # gh-18279, gh-17572, gh-17771 noted that deprecation warnings # for imports from private modules # were misleading. Check that this is resolved. module = import_module(module_name) if correct_module is None: import_name = f'scipy.{module_name.split(".")[1]}' else: import_name = f'scipy.{module_name.split(".")[1]}.{correct_module}' correct_import = import_module(import_name) # Attributes that were formerly in `module_name` can still be imported from # `module_name`, albeit with a deprecation warning. The specific message # depends on whether the attribute is public in `scipy.xxx` or not. for attr_name in module.__all__: attr = getattr(correct_import, attr_name, None) if attr is None: message = f"`{module_name}.{attr_name}` is deprecated..." else: message = f"Please import `{attr_name}` from the `{import_name}`..." with pytest.deprecated_call(match=message): getattr(module, attr_name) # Attributes that were not in `module_name` get an error notifying the user # that the attribute is not in `module_name` and that `module_name` is deprecated. message = f"`{module_name}` is deprecated..." with pytest.raises(AttributeError, match=message): getattr(module, "ekki") def test_misc_doccer_deprecation(): # gh-18279, gh-17572, gh-17771 noted that deprecation warnings # for imports from private modules were misleading. # Check that this is resolved. # `test_private_but_present_deprecation` cannot be used since `correct_import` # is a different subpackage (`_lib` instead of `misc`). module = import_module('scipy.misc.doccer') correct_import = import_module('scipy._lib.doccer') # Attributes that were formerly in `scipy.misc.doccer` can still be imported from # `scipy.misc.doccer`, albeit with a deprecation warning. The specific message # depends on whether the attribute is in `scipy._lib.doccer` or not. for attr_name in module.__all__: attr = getattr(correct_import, attr_name, None) if attr is None: message = f"`scipy.misc.{attr_name}` is deprecated..." else: message = f"Please import `{attr_name}` from the `scipy._lib.doccer`..." with pytest.deprecated_call(match=message): getattr(module, attr_name) # Attributes that were not in `scipy.misc.doccer` get an error # notifying the user that the attribute is not in `scipy.misc.doccer` # and that `scipy.misc.doccer` is deprecated. message = "`scipy.misc.doccer` is deprecated..." with pytest.raises(AttributeError, match=message): getattr(module, "ekki")