pyproject-build-system.scm 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. ;;; GNU Guix --- Functional package management for GNU
  2. ;;; Copyright © 2021 Lars-Dominik Braun <lars@6xq.net>
  3. ;;; Copyright © 2022 Marius Bakke <marius@gnu.org>
  4. ;;;
  5. ;;; This file is part of GNU Guix.
  6. ;;;
  7. ;;; GNU Guix is free software; you can redistribute it and/or modify it
  8. ;;; under the terms of the GNU General Public License as published by
  9. ;;; the Free Software Foundation; either version 3 of the License, or (at
  10. ;;; your option) any later version.
  11. ;;;
  12. ;;; GNU Guix is distributed in the hope that it will be useful, but
  13. ;;; 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 GNU Guix. If not, see <http://www.gnu.org/licenses/>.
  19. (define-module (guix build pyproject-build-system)
  20. #:use-module ((guix build python-build-system) #:prefix python:)
  21. #:use-module (guix build utils)
  22. #:use-module (guix build json)
  23. #:use-module (ice-9 match)
  24. #:use-module (ice-9 ftw)
  25. #:use-module (ice-9 format)
  26. #:use-module (ice-9 rdelim)
  27. #:use-module (ice-9 regex)
  28. #:use-module (srfi srfi-1)
  29. #:use-module (srfi srfi-26)
  30. #:use-module (srfi srfi-34)
  31. #:use-module (srfi srfi-35)
  32. #:export (%standard-phases
  33. add-installed-pythonpath
  34. site-packages
  35. python-version
  36. pyproject-build))
  37. ;;; Commentary:
  38. ;;;
  39. ;;; PEP 517-compatible build system for Python packages.
  40. ;;;
  41. ;;; PEP 517 mandates the use of a TOML file called pyproject.toml at the
  42. ;;; project root, describing build and runtime dependencies, as well as the
  43. ;;; build system, which can be different from setuptools. This module uses
  44. ;;; that file to extract the build system used and call its wheel-building
  45. ;;; entry point build_wheel (see 'build). setuptools’ wheel builder is
  46. ;;; used as a fallback if either no pyproject.toml exists or it does not
  47. ;;; declare a build-system. It supports config_settings through the
  48. ;;; standard #:configure-flags argument.
  49. ;;;
  50. ;;; This wheel, which is just a ZIP file with a file structure defined
  51. ;;; by PEP 427 (https://www.python.org/dev/peps/pep-0427/), is then unpacked
  52. ;;; and its contents are moved to the appropriate locations in 'install.
  53. ;;;
  54. ;;; Then entry points, as defined by the PyPa Entry Point Specification
  55. ;;; (https://packaging.python.org/specifications/entry-points/) are read
  56. ;;; from a file called entry_points.txt in the package’s site-packages
  57. ;;; subdirectory and scripts are written to bin/. These are not part of a
  58. ;;; wheel and expected to be created by the installing utility.
  59. ;;; TODO: Add support for PEP-621 entry points.
  60. ;;;
  61. ;;; Caveats:
  62. ;;; - There is no support for in-tree build backends.
  63. ;;;
  64. ;;; Code:
  65. ;;;
  66. ;; Re-export these variables from python-build-system as many packages
  67. ;; rely on these.
  68. (define python-version python:python-version)
  69. (define site-packages python:site-packages)
  70. (define add-installed-pythonpath python:add-installed-pythonpath)
  71. ;; Base error type.
  72. (define-condition-type &python-build-error &error python-build-error?)
  73. ;; Raised when 'check cannot find a valid test system in the inputs.
  74. (define-condition-type &test-system-not-found &python-build-error
  75. test-system-not-found?)
  76. ;; Raised when multiple wheels are created by 'build.
  77. (define-condition-type &cannot-extract-multiple-wheels &python-build-error
  78. cannot-extract-multiple-wheels?)
  79. ;; Raised, when no wheel has been built by the build system.
  80. (define-condition-type &no-wheels-built &python-build-error no-wheels-built?)
  81. (define* (build #:key outputs build-backend configure-flags #:allow-other-keys)
  82. "Build a given Python package."
  83. (define (pyproject.toml->build-backend file)
  84. "Look up the build backend in a pyproject.toml file."
  85. (call-with-input-file file
  86. (lambda (in)
  87. (let loop
  88. ((line (read-line in 'concat)))
  89. (if (eof-object? line) #f
  90. (let ((m (string-match "build-backend = [\"'](.+)[\"']" line)))
  91. (if m
  92. (match:substring m 1)
  93. (loop (read-line in 'concat)))))))))
  94. (let* ((wheel-output (assoc-ref outputs "wheel"))
  95. (wheel-dir (if wheel-output wheel-output "dist"))
  96. ;; There is no easy way to get data from Guile into Python via
  97. ;; s-expressions, but we have JSON serialization already, which Python
  98. ;; also supports out-of-the-box.
  99. (config-settings (call-with-output-string
  100. (cut write-json configure-flags <>)))
  101. ;; python-setuptools’ default backend supports setup.py *and*
  102. ;; pyproject.toml. Allow overriding this automatic detection via
  103. ;; build-backend.
  104. (auto-build-backend (if (file-exists? "pyproject.toml")
  105. (pyproject.toml->build-backend
  106. "pyproject.toml")
  107. #f))
  108. ;; Use build system detection here and not in importer, because a) we
  109. ;; have alot of legacy packages and b) the importer cannot update arbitrary
  110. ;; fields in case a package switches its build system.
  111. (use-build-backend (or build-backend
  112. auto-build-backend
  113. "setuptools.build_meta")))
  114. (format #t
  115. "Using '~a' to build wheels, auto-detected '~a', override '~a'.~%"
  116. use-build-backend auto-build-backend build-backend)
  117. (mkdir-p wheel-dir)
  118. ;; Call the PEP 517 build function, which drops a .whl into wheel-dir.
  119. (invoke "python" "-c"
  120. "import sys, importlib, json
  121. config_settings = json.loads (sys.argv[3])
  122. builder = importlib.import_module(sys.argv[1])
  123. builder.build_wheel(sys.argv[2], config_settings=config_settings)"
  124. use-build-backend
  125. wheel-dir
  126. config-settings)))
  127. (define* (check #:key tests? test-backend test-flags #:allow-other-keys)
  128. "Run the test suite of a given Python package."
  129. (if tests?
  130. ;; Unfortunately with PEP 517 there is no common method to specify test
  131. ;; systems. Guess test system based on inputs instead.
  132. (let* ((pytest (which "pytest"))
  133. (nosetests (which "nosetests"))
  134. (nose2 (which "nose2"))
  135. (have-setup-py (file-exists? "setup.py"))
  136. (use-test-backend
  137. (or test-backend
  138. ;; Prefer pytest
  139. (if pytest 'pytest #f)
  140. (if nosetests 'nose #f)
  141. (if nose2 'nose2 #f)
  142. ;; But fall back to setup.py, which should work for most
  143. ;; packages. XXX: would be nice not to depend on setup.py here?
  144. ;; fails more often than not to find any tests at all. Maybe
  145. ;; we can run `python -m unittest`?
  146. (if have-setup-py 'setup.py #f))))
  147. (format #t "Using ~a~%" use-test-backend)
  148. (match use-test-backend
  149. ('pytest
  150. (apply invoke pytest "-vv" test-flags))
  151. ('nose
  152. (apply invoke nosetests "-v" test-flags))
  153. ('nose2
  154. (apply invoke nose2 "-v" "--pretty-assert" test-flags))
  155. ('setup.py
  156. (apply invoke "python" "setup.py"
  157. (if (null? test-flags)
  158. '("test" "-v")
  159. test-flags)))
  160. ;; The developer should explicitly disable tests in this case.
  161. (else (raise (condition (&test-system-not-found))))))
  162. (format #t "test suite not run~%")))
  163. (define* (install #:key inputs outputs #:allow-other-keys)
  164. "Install a wheel file according to PEP 427"
  165. ;; See https://www.python.org/dev/peps/pep-0427/#installing-a-wheel-distribution-1-0-py32-none-any-whl
  166. (let ((site-dir (site-packages inputs outputs))
  167. (python (assoc-ref inputs "python"))
  168. (out (assoc-ref outputs "out")))
  169. (define (extract file)
  170. "Extract wheel (ZIP file) into site-packages directory"
  171. ;; Use Python’s zipfile to avoid extra dependency
  172. (invoke "python" "-m" "zipfile" "-e" file site-dir))
  173. (define python-hashbang
  174. (string-append "#!" python "/bin/python"))
  175. (define* (merge-directories source destination
  176. #:optional (post-move #f))
  177. "Move all files in SOURCE into DESTINATION, merging the two directories."
  178. (format #t "Merging directory ~a into ~a~%" source destination)
  179. (for-each (lambda (file)
  180. (format #t "~a/~a -> ~a/~a~%"
  181. source file destination file)
  182. (mkdir-p destination)
  183. (rename-file (string-append source "/" file)
  184. (string-append destination "/" file))
  185. (when post-move
  186. (post-move file)))
  187. (scandir source
  188. (negate (cut member <> '("." "..")))))
  189. (rmdir source))
  190. (define (expand-data-directory directory)
  191. "Move files from all .data subdirectories to their respective\ndestinations."
  192. ;; Python’s distutils.command.install defines this mapping from source to
  193. ;; destination mapping.
  194. (let ((source (string-append directory "/scripts"))
  195. (destination (string-append out "/bin")))
  196. (when (file-exists? source)
  197. (merge-directories source destination
  198. (lambda (f)
  199. (let ((dest-path (string-append destination
  200. "/" f)))
  201. (chmod dest-path #o755)
  202. ;; PEP 427 recommends that installers rewrite
  203. ;; this odd shebang.
  204. (substitute* dest-path
  205. (("#!python")
  206. python-hashbang)))))))
  207. ;; Data can be contained in arbitrary directory structures. Most
  208. ;; commonly it is used for share/.
  209. (let ((source (string-append directory "/data"))
  210. (destination out))
  211. (when (file-exists? source)
  212. (merge-directories source destination)))
  213. (let* ((distribution (car (string-split (basename directory) #\-)))
  214. (source (string-append directory "/headers"))
  215. (destination (string-append out "/include/python"
  216. (python-version python)
  217. "/" distribution)))
  218. (when (file-exists? source)
  219. (merge-directories source destination))))
  220. (define (list-directories base predicate)
  221. ;; Cannot use find-files here, because it’s recursive.
  222. (scandir base
  223. (lambda (name)
  224. (let ((stat (lstat (string-append base "/" name))))
  225. (and (not (member name '("." "..")))
  226. (eq? (stat:type stat) 'directory)
  227. (predicate name stat))))))
  228. (let* ((wheel-output (assoc-ref outputs "wheel"))
  229. (wheel-dir (if wheel-output wheel-output "dist"))
  230. (wheels (map (cut string-append wheel-dir "/" <>)
  231. (scandir wheel-dir
  232. (cut string-suffix? ".whl" <>)))))
  233. (cond
  234. ((> (length wheels) 1)
  235. ;; This code does not support multiple wheels yet, because their
  236. ;; outputs would have to be merged properly.
  237. (raise (condition (&cannot-extract-multiple-wheels))))
  238. ((= (length wheels) 0)
  239. (raise (condition (&no-wheels-built)))))
  240. (for-each extract wheels))
  241. (let ((datadirs (map (cut string-append site-dir "/" <>)
  242. (list-directories site-dir
  243. (file-name-predicate "\\.data$")))))
  244. (for-each (lambda (directory)
  245. (expand-data-directory directory)
  246. (rmdir directory)) datadirs))))
  247. (define* (compile-bytecode #:key inputs outputs #:allow-other-keys)
  248. "Compile installed byte-code in site-packages."
  249. (let* ((site-dir (site-packages inputs outputs))
  250. (python (assoc-ref inputs "python"))
  251. (major-minor (map string->number
  252. (take (string-split (python-version python) #\.) 2)))
  253. (<3.7? (match major-minor
  254. ((major minor)
  255. (or (< major 3)
  256. (and (= major 3)
  257. (< minor 7)))))))
  258. (if <3.7?
  259. ;; These versions don’t have the hash invalidation modes and do
  260. ;; not produce reproducible bytecode files.
  261. (format #t "Skipping bytecode compilation for Python version ~a < 3.7~%"
  262. (python-version python))
  263. (invoke "python" "-m" "compileall"
  264. "--invalidation-mode=unchecked-hash" site-dir))))
  265. (define* (create-entrypoints #:key inputs outputs #:allow-other-keys)
  266. "Implement Entry Points Specification
  267. (https://packaging.python.org/specifications/entry-points/) by PyPa,
  268. which creates runnable scripts in bin/ from entry point specification
  269. file entry_points.txt. This is necessary, because wheels do not contain
  270. these binaries and installers are expected to create them."
  271. (define (entry-points.txt->entry-points file)
  272. "Specialized parser for Python configfile-like files, in particular
  273. entry_points.txt. Returns a list of console_script and gui_scripts
  274. entry points."
  275. (call-with-input-file file
  276. (lambda (in)
  277. (let loop ((line (read-line in))
  278. (inside #f)
  279. (result '()))
  280. (if (eof-object? line)
  281. result
  282. (let* ((group-match (string-match "^\\[(.+)\\]$" line))
  283. (group-name (if group-match
  284. (match:substring group-match 1)
  285. #f))
  286. (next-inside (if (not group-name)
  287. inside
  288. (or (string=? group-name
  289. "console_scripts")
  290. (string=? group-name "gui_scripts"))))
  291. (item-match (string-match
  292. "^([^ =]+)\\s*=\\s*([^:]+):(.+)$" line)))
  293. (if (and inside item-match)
  294. (loop (read-line in)
  295. next-inside
  296. (cons (list (match:substring item-match 1)
  297. (match:substring item-match 2)
  298. (match:substring item-match 3))
  299. result))
  300. (loop (read-line in) next-inside result))))))))
  301. (define (create-script path name module function)
  302. "Create a Python script from an entry point’s NAME, MODULE and FUNCTION
  303. and return write it to PATH/NAME."
  304. (let ((interpreter (which "python"))
  305. (file-path (string-append path "/" name)))
  306. (format #t "Creating entry point for '~a.~a' at '~a'.~%"
  307. module function file-path)
  308. (call-with-output-file file-path
  309. (lambda (port)
  310. ;; Technically the script could also include search-paths,
  311. ;; but having a generic 'wrap phases also handles manually
  312. ;; written entry point scripts.
  313. (format port "#!~a
  314. # Auto-generated entry point script.
  315. import sys
  316. import ~a as mod
  317. sys.exit (mod.~a ())~%" interpreter module function)))
  318. (chmod file-path #o755)))
  319. (let* ((site-dir (site-packages inputs outputs))
  320. (out (assoc-ref outputs "out"))
  321. (bin-dir (string-append out "/bin"))
  322. (entry-point-files (find-files site-dir "^entry_points.txt$")))
  323. (mkdir-p bin-dir)
  324. (for-each (lambda (f)
  325. (for-each (lambda (ep)
  326. (apply create-script
  327. (cons bin-dir ep)))
  328. (entry-points.txt->entry-points f)))
  329. entry-point-files)))
  330. (define* (set-SOURCE-DATE-EPOCH* #:rest _)
  331. "Set the 'SOURCE_DATE_EPOCH' environment variable. This is used by tools
  332. that incorporate timestamps as a way to tell them to use a fixed timestamp.
  333. See https://reproducible-builds.org/specs/source-date-epoch/."
  334. ;; Use a post-1980 timestamp because the Zip format used in wheels do
  335. ;; not support timestamps before 1980.
  336. (setenv "SOURCE_DATE_EPOCH" "315619200"))
  337. (define %standard-phases
  338. ;; The build phase only builds C extensions and copies the Python sources,
  339. ;; while the install phase copies then byte-compiles the sources to the
  340. ;; prefix directory. The check phase is moved after the installation phase
  341. ;; to ease testing the built package.
  342. (modify-phases python:%standard-phases
  343. (replace 'set-SOURCE-DATE-EPOCH set-SOURCE-DATE-EPOCH*)
  344. (replace 'build build)
  345. (replace 'install install)
  346. (delete 'check)
  347. ;; Must be before tests, so they can use installed packages’ entry points.
  348. (add-before 'wrap 'create-entrypoints create-entrypoints)
  349. (add-after 'wrap 'check check)
  350. (add-before 'check 'compile-bytecode compile-bytecode)))
  351. (define* (pyproject-build #:key inputs (phases %standard-phases)
  352. #:allow-other-keys #:rest args)
  353. "Build the given Python package, applying all of PHASES in order."
  354. (apply python:python-build #:inputs inputs #:phases phases args))
  355. ;;; pyproject-build-system.scm ends here