util.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. """
  2. Utility functions for
  3. - building and importing modules on test time, using a temporary location
  4. - detecting if compilers are present
  5. - determining paths to tests
  6. """
  7. import glob
  8. import os
  9. import sys
  10. import subprocess
  11. import tempfile
  12. import shutil
  13. import atexit
  14. import pytest
  15. import contextlib
  16. import numpy
  17. import concurrent.futures
  18. from pathlib import Path
  19. from numpy._utils import asunicode
  20. from numpy.testing import temppath, IS_WASM
  21. from importlib import import_module
  22. from numpy.f2py._backends._meson import MesonBackend
  23. #
  24. # Check if compilers are available at all...
  25. #
  26. def check_language(lang, code_snippet=None):
  27. if sys.platform == "win32":
  28. pytest.skip("No Fortran tests on Windows (Issue #25134)", allow_module_level=True)
  29. tmpdir = tempfile.mkdtemp()
  30. try:
  31. meson_file = os.path.join(tmpdir, "meson.build")
  32. with open(meson_file, "w") as f:
  33. f.write("project('check_compilers')\n")
  34. f.write(f"add_languages('{lang}')\n")
  35. if code_snippet:
  36. f.write(f"{lang}_compiler = meson.get_compiler('{lang}')\n")
  37. f.write(f"{lang}_code = '''{code_snippet}'''\n")
  38. f.write(
  39. f"_have_{lang}_feature ="
  40. f"{lang}_compiler.compiles({lang}_code,"
  41. f" name: '{lang} feature check')\n"
  42. )
  43. try:
  44. runmeson = subprocess.run(
  45. ["meson", "setup", "btmp"],
  46. check=False,
  47. cwd=tmpdir,
  48. capture_output=True,
  49. )
  50. except subprocess.CalledProcessError:
  51. pytest.skip("meson not present, skipping compiler dependent test", allow_module_level=True)
  52. return runmeson.returncode == 0
  53. finally:
  54. shutil.rmtree(tmpdir)
  55. fortran77_code = '''
  56. C Example Fortran 77 code
  57. PROGRAM HELLO
  58. PRINT *, 'Hello, Fortran 77!'
  59. END
  60. '''
  61. fortran90_code = '''
  62. ! Example Fortran 90 code
  63. program hello90
  64. type :: greeting
  65. character(len=20) :: text
  66. end type greeting
  67. type(greeting) :: greet
  68. greet%text = 'hello, fortran 90!'
  69. print *, greet%text
  70. end program hello90
  71. '''
  72. # Dummy class for caching relevant checks
  73. class CompilerChecker:
  74. def __init__(self):
  75. self.compilers_checked = False
  76. self.has_c = False
  77. self.has_f77 = False
  78. self.has_f90 = False
  79. def check_compilers(self):
  80. if (not self.compilers_checked) and (not sys.platform == "cygwin"):
  81. with concurrent.futures.ThreadPoolExecutor() as executor:
  82. futures = [
  83. executor.submit(check_language, "c"),
  84. executor.submit(check_language, "fortran", fortran77_code),
  85. executor.submit(check_language, "fortran", fortran90_code)
  86. ]
  87. self.has_c = futures[0].result()
  88. self.has_f77 = futures[1].result()
  89. self.has_f90 = futures[2].result()
  90. self.compilers_checked = True
  91. if not IS_WASM:
  92. checker = CompilerChecker()
  93. checker.check_compilers()
  94. def has_c_compiler():
  95. return checker.has_c
  96. def has_f77_compiler():
  97. return checker.has_f77
  98. def has_f90_compiler():
  99. return checker.has_f90
  100. def has_fortran_compiler():
  101. return (checker.has_f90 and checker.has_f77)
  102. #
  103. # Maintaining a temporary module directory
  104. #
  105. _module_dir = None
  106. _module_num = 5403
  107. if sys.platform == "cygwin":
  108. NUMPY_INSTALL_ROOT = Path(__file__).parent.parent.parent
  109. _module_list = list(NUMPY_INSTALL_ROOT.glob("**/*.dll"))
  110. def _cleanup():
  111. global _module_dir
  112. if _module_dir is not None:
  113. try:
  114. sys.path.remove(_module_dir)
  115. except ValueError:
  116. pass
  117. try:
  118. shutil.rmtree(_module_dir)
  119. except OSError:
  120. pass
  121. _module_dir = None
  122. def get_module_dir():
  123. global _module_dir
  124. if _module_dir is None:
  125. _module_dir = tempfile.mkdtemp()
  126. atexit.register(_cleanup)
  127. if _module_dir not in sys.path:
  128. sys.path.insert(0, _module_dir)
  129. return _module_dir
  130. def get_temp_module_name():
  131. # Assume single-threaded, and the module dir usable only by this thread
  132. global _module_num
  133. get_module_dir()
  134. name = "_test_ext_module_%d" % _module_num
  135. _module_num += 1
  136. if name in sys.modules:
  137. # this should not be possible, but check anyway
  138. raise RuntimeError("Temporary module name already in use.")
  139. return name
  140. def _memoize(func):
  141. memo = {}
  142. def wrapper(*a, **kw):
  143. key = repr((a, kw))
  144. if key not in memo:
  145. try:
  146. memo[key] = func(*a, **kw)
  147. except Exception as e:
  148. memo[key] = e
  149. raise
  150. ret = memo[key]
  151. if isinstance(ret, Exception):
  152. raise ret
  153. return ret
  154. wrapper.__name__ = func.__name__
  155. return wrapper
  156. #
  157. # Building modules
  158. #
  159. @_memoize
  160. def build_module(source_files, options=[], skip=[], only=[], module_name=None):
  161. """
  162. Compile and import a f2py module, built from the given files.
  163. """
  164. code = f"import sys; sys.path = {sys.path!r}; import numpy.f2py; numpy.f2py.main()"
  165. d = get_module_dir()
  166. # gh-27045 : Skip if no compilers are found
  167. if not has_fortran_compiler():
  168. pytest.skip("No Fortran compiler available")
  169. # Copy files
  170. dst_sources = []
  171. f2py_sources = []
  172. for fn in source_files:
  173. if not os.path.isfile(fn):
  174. raise RuntimeError("%s is not a file" % fn)
  175. dst = os.path.join(d, os.path.basename(fn))
  176. shutil.copyfile(fn, dst)
  177. dst_sources.append(dst)
  178. base, ext = os.path.splitext(dst)
  179. if ext in (".f90", ".f95", ".f", ".c", ".pyf"):
  180. f2py_sources.append(dst)
  181. assert f2py_sources
  182. # Prepare options
  183. if module_name is None:
  184. module_name = get_temp_module_name()
  185. gil_options = []
  186. if '--freethreading-compatible' not in options and '--no-freethreading-compatible' not in options:
  187. # default to disabling the GIL if unset in options
  188. gil_options = ['--freethreading-compatible']
  189. f2py_opts = ["-c", "-m", module_name] + options + gil_options + f2py_sources
  190. f2py_opts += ["--backend", "meson"]
  191. if skip:
  192. f2py_opts += ["skip:"] + skip
  193. if only:
  194. f2py_opts += ["only:"] + only
  195. # Build
  196. cwd = os.getcwd()
  197. try:
  198. os.chdir(d)
  199. cmd = [sys.executable, "-c", code] + f2py_opts
  200. p = subprocess.Popen(cmd,
  201. stdout=subprocess.PIPE,
  202. stderr=subprocess.STDOUT)
  203. out, err = p.communicate()
  204. if p.returncode != 0:
  205. raise RuntimeError("Running f2py failed: %s\n%s" %
  206. (cmd[4:], asunicode(out)))
  207. finally:
  208. os.chdir(cwd)
  209. # Partial cleanup
  210. for fn in dst_sources:
  211. os.unlink(fn)
  212. # Rebase (Cygwin-only)
  213. if sys.platform == "cygwin":
  214. # If someone starts deleting modules after import, this will
  215. # need to change to record how big each module is, rather than
  216. # relying on rebase being able to find that from the files.
  217. _module_list.extend(
  218. glob.glob(os.path.join(d, "{:s}*".format(module_name)))
  219. )
  220. subprocess.check_call(
  221. ["/usr/bin/rebase", "--database", "--oblivious", "--verbose"]
  222. + _module_list
  223. )
  224. # Import
  225. return import_module(module_name)
  226. @_memoize
  227. def build_code(source_code,
  228. options=[],
  229. skip=[],
  230. only=[],
  231. suffix=None,
  232. module_name=None):
  233. """
  234. Compile and import Fortran code using f2py.
  235. """
  236. if suffix is None:
  237. suffix = ".f"
  238. with temppath(suffix=suffix) as path:
  239. with open(path, "w") as f:
  240. f.write(source_code)
  241. return build_module([path],
  242. options=options,
  243. skip=skip,
  244. only=only,
  245. module_name=module_name)
  246. #
  247. # Building with meson
  248. #
  249. class SimplifiedMesonBackend(MesonBackend):
  250. def __init__(self, *args, **kwargs):
  251. super().__init__(*args, **kwargs)
  252. def compile(self):
  253. self.write_meson_build(self.build_dir)
  254. self.run_meson(self.build_dir)
  255. def build_meson(source_files, module_name=None, **kwargs):
  256. """
  257. Build a module via Meson and import it.
  258. """
  259. # gh-27045 : Skip if no compilers are found
  260. if not has_fortran_compiler():
  261. pytest.skip("No Fortran compiler available")
  262. build_dir = get_module_dir()
  263. if module_name is None:
  264. module_name = get_temp_module_name()
  265. # Initialize the MesonBackend
  266. backend = SimplifiedMesonBackend(
  267. modulename=module_name,
  268. sources=source_files,
  269. extra_objects=kwargs.get("extra_objects", []),
  270. build_dir=build_dir,
  271. include_dirs=kwargs.get("include_dirs", []),
  272. library_dirs=kwargs.get("library_dirs", []),
  273. libraries=kwargs.get("libraries", []),
  274. define_macros=kwargs.get("define_macros", []),
  275. undef_macros=kwargs.get("undef_macros", []),
  276. f2py_flags=kwargs.get("f2py_flags", []),
  277. sysinfo_flags=kwargs.get("sysinfo_flags", []),
  278. fc_flags=kwargs.get("fc_flags", []),
  279. flib_flags=kwargs.get("flib_flags", []),
  280. setup_flags=kwargs.get("setup_flags", []),
  281. remove_build_dir=kwargs.get("remove_build_dir", False),
  282. extra_dat=kwargs.get("extra_dat", {}),
  283. )
  284. backend.compile()
  285. # Import the compiled module
  286. sys.path.insert(0, f"{build_dir}/{backend.meson_build_dir}")
  287. return import_module(module_name)
  288. #
  289. # Unittest convenience
  290. #
  291. class F2PyTest:
  292. code = None
  293. sources = None
  294. options = []
  295. skip = []
  296. only = []
  297. suffix = ".f"
  298. module = None
  299. _has_c_compiler = None
  300. _has_f77_compiler = None
  301. _has_f90_compiler = None
  302. @property
  303. def module_name(self):
  304. cls = type(self)
  305. return f'_{cls.__module__.rsplit(".",1)[-1]}_{cls.__name__}_ext_module'
  306. @classmethod
  307. def setup_class(cls):
  308. if sys.platform == "win32":
  309. pytest.skip("Fails with MinGW64 Gfortran (Issue #9673)")
  310. F2PyTest._has_c_compiler = has_c_compiler()
  311. F2PyTest._has_f77_compiler = has_f77_compiler()
  312. F2PyTest._has_f90_compiler = has_f90_compiler()
  313. F2PyTest._has_fortran_compiler = has_fortran_compiler()
  314. def setup_method(self):
  315. if self.module is not None:
  316. return
  317. codes = self.sources if self.sources else []
  318. if self.code:
  319. codes.append(self.suffix)
  320. needs_f77 = any(str(fn).endswith(".f") for fn in codes)
  321. needs_f90 = any(str(fn).endswith(".f90") for fn in codes)
  322. needs_pyf = any(str(fn).endswith(".pyf") for fn in codes)
  323. if needs_f77 and not self._has_f77_compiler:
  324. pytest.skip("No Fortran 77 compiler available")
  325. if needs_f90 and not self._has_f90_compiler:
  326. pytest.skip("No Fortran 90 compiler available")
  327. if needs_pyf and not self._has_fortran_compiler:
  328. pytest.skip("No Fortran compiler available")
  329. # Build the module
  330. if self.code is not None:
  331. self.module = build_code(
  332. self.code,
  333. options=self.options,
  334. skip=self.skip,
  335. only=self.only,
  336. suffix=self.suffix,
  337. module_name=self.module_name,
  338. )
  339. if self.sources is not None:
  340. self.module = build_module(
  341. self.sources,
  342. options=self.options,
  343. skip=self.skip,
  344. only=self.only,
  345. module_name=self.module_name,
  346. )
  347. #
  348. # Helper functions
  349. #
  350. def getpath(*a):
  351. # Package root
  352. d = Path(numpy.f2py.__file__).parent.resolve()
  353. return d.joinpath(*a)
  354. @contextlib.contextmanager
  355. def switchdir(path):
  356. curpath = Path.cwd()
  357. os.chdir(path)
  358. try:
  359. yield
  360. finally:
  361. os.chdir(curpath)