"""
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._lib._public_api import PUBLIC_MODULES
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)))
# 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.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',
'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',
'scipy.special.libsf_error_state',
'scipy.integrate.lsoda'
]
# 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.
@pytest.mark.thread_unsafe(
reason=("crashes in pkgutil.walk_packages, see "
"https://github.com/data-apis/array-api-compat/issues/343"))
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.
backends = {'cupy', 'torch', 'dask.array'}
for backend in backends:
path = f'array_api_compat.{backend}'
if path in name and backend not in xp_available_backends:
return
raise
modnames = []
with warnings.catch_warnings():
warnings.filterwarnings("ignore", "scipy.misc", DeprecationWarning)
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',
'scipy.integrate.lsoda'
]
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
with warnings.catch_warnings():
warnings.filterwarnings("ignore", "scipy.misc", DeprecationWarning)
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.simplefilter('always', category=DeprecationWarning)
warnings.simplefilter('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.odepack', None),
('scipy.integrate.quadpack', None),
('scipy.integrate.vode', None),
('scipy.interpolate.dfitpack', None),
('scipy.interpolate.fitpack', None),
('scipy.interpolate.fitpack2', None),
('scipy.interpolate.interpnd', 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.ndimage.filters', None),
('scipy.ndimage.fourier', None),
('scipy.ndimage.interpolation', None),
('scipy.ndimage.measurements', None),
('scipy.ndimage.morphology', 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.spline', None),
('scipy.signal.waveforms', None),
('scipy.signal.wavelets', None),
('scipy.signal.windows.windows', 'windows'),
('scipy.sparse.base', None),
('scipy.sparse.bsr', None),
('scipy.sparse.compressed', None),
('scipy.sparse.construct', None),
('scipy.sparse.coo', None),
('scipy.sparse.csc', None),
('scipy.sparse.csr', None),
('scipy.sparse.data', None),
('scipy.sparse.dia', None),
('scipy.sparse.dok', None),
('scipy.sparse.extract', None),
('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.
for attr_name in module.__all__:
# ensure attribute is present where the warning is pointing
assert getattr(correct_import, attr_name, None) is not None
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")