setup_cython.py 15 KB

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