import sys import sysconfig import subprocess import pkgutil import types import importlib import inspect import warnings import numpy as np import numpy from numpy.testing import IS_WASM import pytest try: import ctypes except ImportError: ctypes = None def check_dir(module, module_name=None): """Returns a mapping of all objects with the wrong __module__ attribute.""" if module_name is None: module_name = module.__name__ results = {} for name in dir(module): if name == "core": continue item = getattr(module, name) if (hasattr(item, '__module__') and hasattr(item, '__name__') and item.__module__ != module_name): results[name] = item.__module__ + '.' + item.__name__ return results def test_numpy_namespace(): # We override dir to not show these members allowlist = { 'recarray': 'numpy.rec.recarray', 'show_config': 'numpy.__config__.show', } bad_results = check_dir(np) # pytest gives better error messages with the builtin assert than with # assert_equal assert bad_results == allowlist @pytest.mark.skipif(IS_WASM, reason="can't start subprocess") @pytest.mark.parametrize('name', ['testing']) def test_import_lazy_import(name): """Make sure we can actually use the modules we lazy load. While not exported as part of the public API, it was accessible. With the use of __getattr__ and __dir__, this isn't always true It can happen that an infinite recursion may happen. This is the only way I found that would force the failure to appear on the badly implemented code. We also test for the presence of the lazily imported modules in dir """ exe = (sys.executable, '-c', "import numpy; numpy." + name) result = subprocess.check_output(exe) assert not result # Make sure they are still in the __dir__ assert name in dir(np) def test_dir_testing(): """Assert that output of dir has only one "testing/tester" attribute without duplicate""" assert len(dir(np)) == len(set(dir(np))) def test_numpy_linalg(): bad_results = check_dir(np.linalg) assert bad_results == {} def test_numpy_fft(): bad_results = check_dir(np.fft) assert bad_results == {} @pytest.mark.skipif(ctypes is None, reason="ctypes not available in this python") def test_NPY_NO_EXPORT(): cdll = ctypes.CDLL(np._core._multiarray_tests.__file__) # Make sure an arbitrary NPY_NO_EXPORT function is actually hidden f = getattr(cdll, 'test_not_exported', None) assert f is None, ("'test_not_exported' is mistakenly exported, " "NPY_NO_EXPORT does not work") # Historically NumPy 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 numpy.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. # # The PRIVATE_BUT_PRESENT_MODULES list contains modules that look public (lack # of underscores) but should not be used. For many of those modules the # current status is fine. For others it may make sense to work on making them # private, to clean up our public API and avoid confusion. PUBLIC_MODULES = ['numpy.' + s for s in [ "ctypeslib", "dtypes", "exceptions", "f2py", "fft", "lib", "lib.array_utils", "lib.format", "lib.introspect", "lib.mixins", "lib.npyio", "lib.recfunctions", # note: still needs cleaning, was forgotten for 2.0 "lib.scimath", "lib.stride_tricks", "linalg", "ma", "ma.extras", "ma.mrecords", "polynomial", "polynomial.chebyshev", "polynomial.hermite", "polynomial.hermite_e", "polynomial.laguerre", "polynomial.legendre", "polynomial.polynomial", "random", "strings", "testing", "testing.overrides", "typing", "typing.mypy_plugin", "version", ]] if sys.version_info < (3, 12): PUBLIC_MODULES += [ 'numpy.' + s for s in [ "distutils", "distutils.cpuinfo", "distutils.exec_command", "distutils.misc_util", "distutils.log", "distutils.system_info", ] ] PUBLIC_ALIASED_MODULES = [ "numpy.char", "numpy.emath", "numpy.rec", ] PRIVATE_BUT_PRESENT_MODULES = ['numpy.' + s for s in [ "compat", "compat.py3k", "conftest", "core", "core.multiarray", "core.numeric", "core.umath", "core.arrayprint", "core.defchararray", "core.einsumfunc", "core.fromnumeric", "core.function_base", "core.getlimits", "core.numerictypes", "core.overrides", "core.records", "core.shape_base", "f2py.auxfuncs", "f2py.capi_maps", "f2py.cb_rules", "f2py.cfuncs", "f2py.common_rules", "f2py.crackfortran", "f2py.diagnose", "f2py.f2py2e", "f2py.f90mod_rules", "f2py.func2subr", "f2py.rules", "f2py.symbolic", "f2py.use_rules", "fft.helper", "lib.user_array", # note: not in np.lib, but probably should just be deleted "linalg.lapack_lite", "linalg.linalg", "ma.core", "ma.testutils", "ma.timer_comparison", "matlib", "matrixlib", "matrixlib.defmatrix", "polynomial.polyutils", "random.mtrand", "random.bit_generator", "testing.print_coercion_tables", ]] if sys.version_info < (3, 12): PRIVATE_BUT_PRESENT_MODULES += [ 'numpy.' + s for s in [ "distutils.armccompiler", "distutils.fujitsuccompiler", "distutils.ccompiler", 'distutils.ccompiler_opt', "distutils.command", "distutils.command.autodist", "distutils.command.bdist_rpm", "distutils.command.build", "distutils.command.build_clib", "distutils.command.build_ext", "distutils.command.build_py", "distutils.command.build_scripts", "distutils.command.build_src", "distutils.command.config", "distutils.command.config_compiler", "distutils.command.develop", "distutils.command.egg_info", "distutils.command.install", "distutils.command.install_clib", "distutils.command.install_data", "distutils.command.install_headers", "distutils.command.sdist", "distutils.conv_template", "distutils.core", "distutils.extension", "distutils.fcompiler", "distutils.fcompiler.absoft", "distutils.fcompiler.arm", "distutils.fcompiler.compaq", "distutils.fcompiler.environment", "distutils.fcompiler.g95", "distutils.fcompiler.gnu", "distutils.fcompiler.hpux", "distutils.fcompiler.ibm", "distutils.fcompiler.intel", "distutils.fcompiler.lahey", "distutils.fcompiler.mips", "distutils.fcompiler.nag", "distutils.fcompiler.none", "distutils.fcompiler.pathf95", "distutils.fcompiler.pg", "distutils.fcompiler.nv", "distutils.fcompiler.sun", "distutils.fcompiler.vast", "distutils.fcompiler.fujitsu", "distutils.from_template", "distutils.intelccompiler", "distutils.lib2def", "distutils.line_endings", "distutils.mingw32ccompiler", "distutils.msvccompiler", "distutils.npy_pkg_config", "distutils.numpy_distribution", "distutils.pathccompiler", "distutils.unixccompiler", ] ] 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 PUBLIC_ALIASED_MODULES: return False if name in PRIVATE_BUT_PRESENT_MODULES: return False return True if sys.version_info < (3, 12): SKIP_LIST = ["numpy.distutils.msvc9compiler"] else: SKIP_LIST = [] # suppressing warnings from deprecated modules @pytest.mark.filterwarnings("ignore:.*np.compat.*:DeprecationWarning") 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. """ modnames = [] for _, modname, ispkg in pkgutil.walk_packages(path=np.__path__, prefix=np.__name__ + '.', onerror=None): 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 = [ 'numpy.lib.math', 'numpy.matlib.char', 'numpy.matlib.rec', 'numpy.matlib.emath', 'numpy.matlib.exceptions', 'numpy.matlib.math', 'numpy.matlib.linalg', 'numpy.matlib.fft', 'numpy.matlib.random', 'numpy.matlib.ctypeslib', 'numpy.matlib.ma', ] if sys.version_info < (3, 12): SKIP_LIST_2 += [ 'numpy.distutils.log.sys', 'numpy.distutils.log.logging', 'numpy.distutils.log.warnings', ] 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. So this test is more thorough, and checks this like: import .lib.scimath as emath To check if something in a module is (effectively) public, one can check if there's anything in that namespace that's a public function/object but is not exposed in a higher-level namespace. For example for a `numpy.lib` submodule:: mod = np.lib.mixins for obj in mod.__all__: if obj in np.__all__: continue elif obj in np.lib.__all__: continue else: print(obj) """ 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): if fullobjname not in SKIP_LIST_2: members.append(fullobjname) return members unexpected_members = find_unexpected_members("numpy") for modname in PUBLIC_MODULES: unexpected_members.extend(find_unexpected_members(modname)) if unexpected_members: raise AssertionError("Found unexpected object(s) that look like " "modules: {}".format(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 " "imported: {}".format(module_names)) for module_name in PUBLIC_ALIASED_MODULES: try: eval(module_name) except AttributeError: module_names.append(module_name) if module_names: raise AssertionError("Modules in the public API that were not " "found: {}".format(module_names)) with warnings.catch_warnings(record=True) as w: 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: " "{}".format(module_names)) @pytest.mark.xfail( sysconfig.get_config_var("Py_DEBUG") not in (None, 0, "0"), reason=( "NumPy possibly built with `USE_DEBUG=True ./tools/travis-test.sh`, " "which does not expose the `array_api` entry point. " "See https://github.com/numpy/numpy/pull/19800" ), ) def test_array_api_entry_point(): """ Entry point for Array API implementation can be found with importlib and returns the main numpy namespace. """ # For a development install that did not go through meson-python, # the entrypoint will not have been installed. So ensure this test fails # only if numpy is inside site-packages. numpy_in_sitepackages = sysconfig.get_path('platlib') in np.__file__ eps = importlib.metadata.entry_points() try: xp_eps = eps.select(group="array_api") except AttributeError: # The select interface for entry_points was introduced in py3.10, # deprecating its dict interface. We fallback to dict keys for finding # Array API entry points so that running this test in <=3.9 will # still work - see https://github.com/numpy/numpy/pull/19800. xp_eps = eps.get("array_api", []) if len(xp_eps) == 0: if numpy_in_sitepackages: msg = "No entry points for 'array_api' found" raise AssertionError(msg) from None return try: ep = next(ep for ep in xp_eps if ep.name == "numpy") except StopIteration: if numpy_in_sitepackages: msg = "'numpy' not in array_api entry points" raise AssertionError(msg) from None return if ep.value == 'numpy.array_api': # Looks like the entrypoint for the current numpy build isn't # installed, but an older numpy is also installed and hence the # entrypoint is pointing to the old (no longer existing) location. # This isn't a problem except for when running tests with `spin` or an # in-place build. return xp = ep.load() msg = ( f"numpy entry point value '{ep.value}' " "does not point to our Array API implementation" ) assert xp is numpy, msg def test_main_namespace_all_dir_coherence(): """ Checks if `dir(np)` and `np.__all__` are consistent and return the same content, excluding exceptions and private members. """ def _remove_private_members(member_set): return {m for m in member_set if not m.startswith('_')} def _remove_exceptions(member_set): return member_set.difference({ "bool" # included only in __dir__ }) all_members = _remove_private_members(np.__all__) all_members = _remove_exceptions(all_members) dir_members = _remove_private_members(np.__dir__()) dir_members = _remove_exceptions(dir_members) assert all_members == dir_members, ( "Members that break symmetry: " f"{all_members.symmetric_difference(dir_members)}" ) @pytest.mark.filterwarnings( r"ignore:numpy.core(\.\w+)? is deprecated:DeprecationWarning" ) def test_core_shims_coherence(): """ Check that all "semi-public" members of `numpy._core` are also accessible from `numpy.core` shims. """ import numpy.core as core for member_name in dir(np._core): # Skip private and test members. Also if a module is aliased, # no need to add it to np.core if ( member_name.startswith("_") or member_name in ["tests", "strings"] or f"numpy.{member_name}" in PUBLIC_ALIASED_MODULES ): continue member = getattr(np._core, member_name) # np.core is a shim and all submodules of np.core are shims # but we should be able to import everything in those shims # that are available in the "real" modules in np._core if inspect.ismodule(member): submodule = member submodule_name = member_name for submodule_member_name in dir(submodule): # ignore dunder names if submodule_member_name.startswith("__"): continue submodule_member = getattr(submodule, submodule_member_name) core_submodule = __import__( f"numpy.core.{submodule_name}", fromlist=[submodule_member_name] ) assert submodule_member is getattr( core_submodule, submodule_member_name ) else: assert member is getattr(core, member_name) def test_functions_single_location(): """ Check that each public function is available from one location only. Test performs BFS search traversing NumPy's public API. It flags any function-like object that is accessible from more that one place. """ from typing import Any, Callable, Dict, List, Set, Tuple from numpy._core._multiarray_umath import ( _ArrayFunctionDispatcher as dispatched_function ) visited_modules: Set[types.ModuleType] = {np} visited_functions: Set[Callable[..., Any]] = set() # Functions often have `__name__` overridden, therefore we need # to keep track of locations where functions have been found. functions_original_paths: Dict[Callable[..., Any], str] = dict() # Here we aggregate functions with more than one location. # It must be empty for the test to pass. duplicated_functions: List[Tuple] = [] modules_queue = [np] while len(modules_queue) > 0: module = modules_queue.pop() for member_name in dir(module): member = getattr(module, member_name) # first check if we got a module if ( inspect.ismodule(member) and # it's a module "numpy" in member.__name__ and # inside NumPy not member_name.startswith("_") and # not private "numpy._core" not in member.__name__ and # outside _core # not a legacy or testing module member_name not in ["f2py", "ma", "testing", "tests"] and member not in visited_modules # not visited yet ): modules_queue.append(member) visited_modules.add(member) # else check if we got a function-like object elif ( inspect.isfunction(member) or isinstance(member, (dispatched_function, np.ufunc)) ): if member in visited_functions: # skip main namespace functions with aliases if ( member.__name__ in [ "absolute", # np.abs "arccos", # np.acos "arccosh", # np.acosh "arcsin", # np.asin "arcsinh", # np.asinh "arctan", # np.atan "arctan2", # np.atan2 "arctanh", # np.atanh "left_shift", # np.bitwise_left_shift "right_shift", # np.bitwise_right_shift "conjugate", # np.conj "invert", # np.bitwise_not & np.bitwise_invert "remainder", # np.mod "divide", # np.true_divide "concatenate", # np.concat "power", # np.pow "transpose", # np.permute_dims ] and module.__name__ == "numpy" ): continue # skip trimcoef from numpy.polynomial as it is # duplicated by design. if ( member.__name__ == "trimcoef" and module.__name__.startswith("numpy.polynomial") ): continue # skip ufuncs that are exported in np.strings as well if member.__name__ in ( "add", "equal", "not_equal", "greater", "greater_equal", "less", "less_equal", ) and module.__name__ == "numpy.strings": continue # numpy.char reexports all numpy.strings functions for # backwards-compatibility if module.__name__ == "numpy.char": continue # function is present in more than one location! duplicated_functions.append( (member.__name__, module.__name__, functions_original_paths[member]) ) else: visited_functions.add(member) functions_original_paths[member] = module.__name__ del visited_functions, visited_modules, functions_original_paths assert len(duplicated_functions) == 0, duplicated_functions