setup_cython.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. #
  2. # Cython patcher
  3. # v1.21
  4. #
  5. # Copyright (C) 2012-2020 Michael Buesch <m@bues.ch>
  6. #
  7. # This program is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 2 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  19. #
  20. from __future__ import print_function
  21. import sys
  22. import os
  23. import platform
  24. import errno
  25. import shutil
  26. import hashlib
  27. import re
  28. WORKER_MEM_BYTES = 800 * 1024*1024
  29. WORKER_CPU_OVERCOMMIT = 2
  30. setupFileName = "setup.py"
  31. parallelBuild = False
  32. profileEnabled = False
  33. debugEnabled = False
  34. ext_modules = []
  35. CythonBuildExtension = None
  36. patchDirName = "cython_patched.%s-%s-%d.%d" % (
  37. platform.system().lower(),
  38. platform.machine().lower(),
  39. sys.version_info[0],
  40. sys.version_info[1])
  41. _Cython_Distutils_build_ext = None
  42. _cythonPossible = None
  43. _cythonBuildUnits = []
  44. _isWindows = os.name.lower() in {"nt", "ce"}
  45. _isPosix = os.name.lower() == "posix"
  46. def getSystemMemBytesCount():
  47. try:
  48. with open("/proc/meminfo", "rb") as fd:
  49. for line in fd.read().decode("UTF-8", "ignore").splitlines():
  50. if line.startswith("MemTotal:") and\
  51. line.endswith("kB"):
  52. kB = int(line.split()[1], 10)
  53. return kB * 1024
  54. except (OSError, IndexError, ValueError, UnicodeError) as e:
  55. pass
  56. if hasattr(os, "sysconf"):
  57. try:
  58. return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
  59. except ValueError as e:
  60. pass
  61. return None
  62. def makedirs(path, mode=0o755):
  63. try:
  64. os.makedirs(path, mode)
  65. except OSError as e:
  66. if e.errno == errno.EEXIST:
  67. return
  68. raise e
  69. def hashFile(path):
  70. try:
  71. with open(path, "rb") as fd:
  72. return hashlib.sha1(fd.read()).hexdigest()
  73. except FileNotFoundError as e:
  74. return None
  75. def __fileopIfChanged(fromFile, toFile, fileops):
  76. toFileHash = hashFile(toFile)
  77. if toFileHash is not None:
  78. fromFileHash = hashFile(fromFile)
  79. if toFileHash == fromFileHash:
  80. return False
  81. makedirs(os.path.dirname(toFile))
  82. for fileop in fileops:
  83. fileop(fromFile, toFile)
  84. return True
  85. def removeFile(filename):
  86. try:
  87. os.unlink(filename)
  88. except OSError:
  89. pass
  90. def copyIfChanged(fromFile, toFile):
  91. fileops = []
  92. if _isWindows:
  93. fileops.append(lambda _fromFile, _toFile: removeFile(_toFile))
  94. fileops.append(shutil.copy2)
  95. return __fileopIfChanged(fromFile, toFile, fileops)
  96. def moveIfChanged(fromFile, toFile):
  97. fileops = []
  98. if _isWindows:
  99. fileops.append(lambda _fromFile, _toFile: removeFile(_toFile))
  100. fileops.append(os.rename)
  101. return __fileopIfChanged(fromFile, toFile, fileops)
  102. def makeDummyFile(path):
  103. if os.path.isfile(path):
  104. return
  105. print("creating dummy file '%s'" % path)
  106. makedirs(os.path.dirname(path))
  107. with open(path, "wb") as fd:
  108. fd.write("\n".encode("UTF-8"))
  109. def pyCythonPatchLine(line):
  110. return line
  111. def pyCythonPatch(fromFile, toFile):
  112. print("cython-patch: patching file '%s' to '%s'" %\
  113. (fromFile, toFile))
  114. tmpFile = toFile + ".TMP"
  115. makedirs(os.path.dirname(tmpFile))
  116. with open(fromFile, "rb") as infd,\
  117. open(tmpFile, "wb") as outfd:
  118. for line in infd.read().decode("UTF-8").splitlines(True):
  119. stripLine = line.strip()
  120. if stripLine.endswith("#@no-cython-patch"):
  121. outfd.write(line.encode("UTF-8"))
  122. continue
  123. # Replace import by cimport as requested by #+cimport
  124. if "#+cimport" in stripLine:
  125. line = line.replace("#+cimport", "#")
  126. line = re.sub(r'\bimport\b', "cimport", line)
  127. # Convert None to NULL
  128. if "#+NoneToNULL" in stripLine:
  129. line = line.replace("#+NoneToNULL", "#")
  130. line = re.sub(r'\bNone\b', "NULL", line)
  131. # Uncomment all lines containing #@cy
  132. def uncomment(line, removeStr):
  133. line = line.replace(removeStr, "")
  134. if line.startswith("#"):
  135. line = line[1:]
  136. if not line.endswith("\n"):
  137. line += "\n"
  138. return line
  139. if "#@cy-posix" in stripLine:
  140. if _isPosix:
  141. line = uncomment(line, "#@cy-posix")
  142. elif "#@cy-win" in stripLine:
  143. if _isWindows:
  144. line = uncomment(line, "#@cy-win")
  145. elif "#@cy" in stripLine:
  146. line = uncomment(line, "#@cy")
  147. # Sprinkle magic cdef/cpdef, as requested by #+cdef/#+cpdef
  148. if "#+cdef-" in stripLine:
  149. # +cdef-foo-bar is the extended cdef patching.
  150. # It adds cdef and any additional characters to the
  151. # start of the line. Dashes are replaced with spaces.
  152. # Get the additional text
  153. idx = line.find("#+cdef-")
  154. cdefText = line[idx+2 : ]
  155. cdefText = cdefText.replace("-", " ").rstrip("\r\n")
  156. # Get the initial space length
  157. spaceCnt = 0
  158. while spaceCnt < len(line) and line[spaceCnt].isspace():
  159. spaceCnt += 1
  160. # Construct the new line
  161. line = line[ : spaceCnt] + cdefText + " " + line[spaceCnt : ]
  162. elif "#+cdef" in stripLine:
  163. # Simple cdef patching:
  164. # def -> cdef
  165. # class -> cdef class
  166. if stripLine.startswith("class"):
  167. line = re.sub(r'\bclass\b', "cdef class", line)
  168. else:
  169. line = re.sub(r'\bdef\b', "cdef", line)
  170. if "#+cpdef" in stripLine:
  171. # Simple cpdef patching:
  172. # def -> cpdef
  173. line = re.sub(r'\bdef\b', "cpdef", line)
  174. # Add likely()/unlikely() to if-conditions.
  175. for likely in ("likely", "unlikely"):
  176. if "#+" + likely in stripLine:
  177. line = re.sub(r'\bif\s(.*):', r'if ' + likely + r'(\1):', line)
  178. break
  179. # Add an "u" suffix to decimal and hexadecimal numbers.
  180. if "#+suffix-u" in line or "#+suffix-U" in line:
  181. line = re.sub(r'\b([0-9]+)\b', r'\1u', line)
  182. line = re.sub(r'\b(0x[0-9a-fA-F]+)\b', r'\1u', line)
  183. # Add an "LL" suffix to decimal and hexadecimal numbers.
  184. if "#+suffix-ll" in line or "#+suffix-LL" in line:
  185. line = re.sub(r'\b(\-?[0-9]+)\b', r'\1LL', line)
  186. line = re.sub(r'\b(0x[0-9a-fA-F]+)\b', r'\1LL', line)
  187. # Comment all lines containing #@nocy
  188. if "#@nocy" in stripLine:
  189. line = "#" + line
  190. # Comment all lines containing #@cy-posix/win
  191. # for the not matching platform.
  192. if _isPosix:
  193. if "#@cy-win" in stripLine:
  194. line = "#" + line
  195. elif _isWindows:
  196. if "#@cy-posix" in stripLine:
  197. line = "#" + line
  198. # Remove compat stuff
  199. line = line.replace("absolute_import,", "")
  200. line = pyCythonPatchLine(line)
  201. outfd.write(line.encode("UTF-8"))
  202. outfd.flush()
  203. if moveIfChanged(tmpFile, toFile):
  204. print("(updated)")
  205. else:
  206. os.unlink(tmpFile)
  207. class CythonBuildUnit(object):
  208. def __init__(self, cyModName, baseName, fromPy, fromPxd, toDir, toPyx, toPxd):
  209. self.cyModName = cyModName
  210. self.baseName = baseName
  211. self.fromPy = fromPy
  212. self.fromPxd = fromPxd
  213. self.toDir = toDir
  214. self.toPyx = toPyx
  215. self.toPxd = toPxd
  216. def patchCythonModules(buildDir):
  217. for unit in _cythonBuildUnits:
  218. makedirs(unit.toDir)
  219. makeDummyFile(os.path.join(unit.toDir, "__init__.py"))
  220. if unit.baseName == "__init__":
  221. # Copy and patch the package __init__.py
  222. toPy = os.path.join(buildDir, *unit.cyModName.split(".")) + ".py"
  223. pyCythonPatch(unit.fromPy, toPy)
  224. else:
  225. # Generate the .pyx
  226. pyCythonPatch(unit.fromPy, unit.toPyx)
  227. # Copy and patch the .pxd, if any
  228. if os.path.isfile(unit.fromPxd):
  229. pyCythonPatch(unit.fromPxd, unit.toPxd)
  230. def registerCythonModule(baseDir, sourceModName):
  231. global ext_modules
  232. global _cythonBuildUnits
  233. modDir = os.path.join(baseDir, sourceModName)
  234. # Make path to the cython patch-build-dir
  235. patchDir = os.path.join(baseDir, "build", patchDirName,
  236. ("%s_cython" % sourceModName))
  237. if not os.path.exists(os.path.join(baseDir, setupFileName)) or\
  238. not os.path.exists(modDir) or\
  239. not os.path.isdir(modDir):
  240. raise Exception("Wrong directory. "
  241. "Execute setup.py from within the main directory.")
  242. # Walk the module
  243. for dirpath, dirnames, filenames in os.walk(modDir):
  244. subpath = os.path.relpath(dirpath, modDir)
  245. if subpath == baseDir:
  246. subpath = ""
  247. dirpathList = dirpath.split(os.path.sep)
  248. if any(os.path.exists(os.path.sep.join(dirpathList[:i] + ["no_cython"]))
  249. for i in range(len(dirpathList) + 1)):
  250. # no_cython file exists. -> skip
  251. continue
  252. for filename in filenames:
  253. if filename.endswith(".py"):
  254. fromSuffix = ".py"
  255. elif filename.endswith(".pyx.in"):
  256. fromSuffix = ".pyx.in"
  257. else:
  258. continue
  259. baseName = filename[:-len(fromSuffix)] # Strip .py/.pyx.in
  260. fromPy = os.path.join(dirpath, baseName + fromSuffix)
  261. fromPxd = os.path.join(dirpath, baseName + ".pxd.in")
  262. toDir = os.path.join(patchDir, subpath)
  263. toPyx = os.path.join(toDir, baseName + ".pyx")
  264. toPxd = os.path.join(toDir, baseName + ".pxd")
  265. # Construct the new cython module name
  266. cyModName = [ "%s_cython" % sourceModName ]
  267. if subpath:
  268. cyModName.extend(subpath.split(os.sep))
  269. cyModName.append(baseName)
  270. cyModName = ".".join(cyModName)
  271. # Remember the filenames for the build
  272. unit = CythonBuildUnit(cyModName, baseName, fromPy, fromPxd,
  273. toDir, toPyx, toPxd)
  274. _cythonBuildUnits.append(unit)
  275. if baseName != "__init__":
  276. # Create a distutils Extension for the module
  277. extra_compile_args = []
  278. extra_link_args = []
  279. if not _isWindows:
  280. extra_compile_args.append("-Wall")
  281. extra_compile_args.append("-Wextra")
  282. #extra_compile_args.append("-Wcast-qual")
  283. extra_compile_args.append("-Wlogical-op")
  284. extra_compile_args.append("-Wpointer-arith")
  285. extra_compile_args.append("-Wundef")
  286. extra_compile_args.append("-Wno-cast-function-type")
  287. extra_compile_args.append("-Wno-maybe-uninitialized")
  288. extra_compile_args.append("-Wno-type-limits")
  289. if debugEnabled:
  290. # Enable debugging and UBSAN.
  291. extra_compile_args.append("-g3")
  292. extra_compile_args.append("-fsanitize=undefined")
  293. extra_compile_args.append("-fsanitize=float-divide-by-zero")
  294. extra_compile_args.append("-fsanitize=float-cast-overflow")
  295. extra_compile_args.append("-fno-sanitize-recover")
  296. extra_link_args.append("-lubsan")
  297. else:
  298. # Disable all debugging symbols.
  299. extra_compile_args.append("-g0")
  300. extra_link_args.append("-Wl,--strip-all")
  301. ext_modules.append(
  302. _Cython_Distutils_Extension(
  303. cyModName,
  304. [toPyx],
  305. cython_directives={
  306. # Enable profile hooks?
  307. "profile" : profileEnabled,
  308. "linetrace" : profileEnabled,
  309. # Warn about unused variables?
  310. "warn.unused" : False,
  311. # Set language version
  312. "language_level" : 3,
  313. },
  314. define_macros=[
  315. ("CYTHON_TRACE", str(int(profileEnabled))),
  316. ("CYTHON_TRACE_NOGIL", str(int(profileEnabled))),
  317. ],
  318. include_dirs=[
  319. os.path.join("libs", "cython_headers"),
  320. ],
  321. extra_compile_args=extra_compile_args,
  322. extra_link_args=extra_link_args
  323. )
  324. )
  325. def registerCythonModules(baseDir=None):
  326. if baseDir is None:
  327. baseDir = os.curdir
  328. for filename in os.listdir(baseDir):
  329. if os.path.isdir(os.path.join(baseDir, filename)) and\
  330. os.path.exists(os.path.join(baseDir, filename, "__init__.py")) and\
  331. not os.path.exists(os.path.join(baseDir, filename, "no_cython")):
  332. registerCythonModule(baseDir, filename)
  333. def cythonBuildPossible():
  334. global _cythonPossible
  335. if _cythonPossible is not None:
  336. return _cythonPossible
  337. _cythonPossible = False
  338. if sys.version_info[0] < 3:
  339. print("WARNING: Could not build the CYTHON modules: "
  340. "Cython 2 not supported. Please use Cython 3.")
  341. return False
  342. try:
  343. import Cython.Compiler.Options
  344. # Omit docstrings in cythoned modules.
  345. Cython.Compiler.Options.docstrings = False
  346. # Generate module exit cleanup code.
  347. Cython.Compiler.Options.generate_cleanup_code = True
  348. # Generate HTML outputs.
  349. Cython.Compiler.Options.annotate = True
  350. from Cython.Distutils import build_ext, Extension
  351. global _Cython_Distutils_build_ext
  352. global _Cython_Distutils_Extension
  353. _Cython_Distutils_build_ext = build_ext
  354. _Cython_Distutils_Extension = Extension
  355. except ImportError as e:
  356. print("WARNING: Could not build the CYTHON modules: "
  357. "%s" % str(e))
  358. print("--> Is Cython installed?")
  359. return False
  360. _cythonPossible = True
  361. return True
  362. def cyBuildWrapper(arg):
  363. # This function does the same thing as the for-loop-body
  364. # inside of Cython's build_ext.build_extensions() method.
  365. # It is called via multiprocessing to build extensions
  366. # in parallel.
  367. # Note that this might break, if Cython's build_extensions()
  368. # is changed and stuff is added to its for loop. Meh.
  369. self, ext = arg
  370. ext.sources = self.cython_sources(ext.sources, ext)
  371. self.build_extension(ext)
  372. if cythonBuildPossible():
  373. # Override Cython's build_ext class.
  374. class CythonBuildExtension(_Cython_Distutils_build_ext):
  375. class Error(Exception): pass
  376. def build_extension(self, ext):
  377. assert(not ext.name.endswith("__init__"))
  378. _Cython_Distutils_build_ext.build_extension(self, ext)
  379. def build_extensions(self):
  380. global parallelBuild
  381. # First patch the files, the run the build
  382. patchCythonModules(self.build_lib)
  383. if parallelBuild:
  384. # Run the parallel build, yay.
  385. try:
  386. self.check_extensions_list(self.extensions)
  387. # Calculate the number of worker processes to use.
  388. memBytes = getSystemMemBytesCount()
  389. if memBytes is None:
  390. raise self.Error("Unknown system memory size")
  391. print("System memory detected: %d MB" % (memBytes // (1024*1024)))
  392. memProcsMax = memBytes // WORKER_MEM_BYTES
  393. if memProcsMax < 2:
  394. raise self.Error("Not enough system memory")
  395. import multiprocessing
  396. numProcs = min(multiprocessing.cpu_count() + WORKER_CPU_OVERCOMMIT,
  397. memProcsMax)
  398. # Start the worker pool.
  399. print("Building in parallel with %d workers." % numProcs)
  400. from multiprocessing.pool import Pool
  401. Pool(numProcs).map(cyBuildWrapper,
  402. ((self, ext) for ext in self.extensions))
  403. except (OSError, self.Error) as e:
  404. # OSError might happen in a restricted
  405. # environment like chroot.
  406. print("WARNING: Parallel build "
  407. "disabled due to: %s" % str(e))
  408. parallelBuild = False
  409. if not parallelBuild:
  410. # Run the normal non-parallel build.
  411. _Cython_Distutils_build_ext.build_extensions(self)