~ruther/guix-local

09448c0994390697e876db235a3b773311795238 — Lars-Dominik Braun 5 years ago 8a15ecf
build/python: Add a sanity check phase.

Add a new phase validating the usability of installed Python packages.

* gnu/packages/aux-files/python/sanity-check.py: New file.
* Makefile.am (AUX_FILES): Register it.
* guix/build-system/python.scm (sanity-check.py): New variable.
(lower): Add the script as an implicit input.
* guix/build/python-build-system.scm: Remove trailing #t.
(sanity-check): New phase.
(%standard-phases): Use it.
* tests/builders.scm: (make-python-dummy)
(dummy-ok, dummy-dummy-nosetuptools, dummy-fail-requirements)
(dummy-fail-import, dummy-fail-console-script): New variables.
("python-build-system: dummy-ok")
("python-build-system: dummy-dummy-nosetuptools")
("python-build-system: dummy-fail-requirements")
("python-build-system: dummy-fail-import")
("python-build-system: dummy-fail-console-script"): Add tests.
M Makefile.am => Makefile.am +1 -0
@@ 380,6 380,7 @@ AUX_FILES =						\
  gnu/packages/aux-files/linux-libre/4.4-i686.conf	\
  gnu/packages/aux-files/linux-libre/4.4-x86_64.conf	\
  gnu/packages/aux-files/pack-audit.c			\
  gnu/packages/aux-files/python/sanity-check.py		\
  gnu/packages/aux-files/python/sitecustomize.py	\
  gnu/packages/aux-files/run-in-namespace.c


A gnu/packages/aux-files/python/sanity-check.py => gnu/packages/aux-files/python/sanity-check.py +91 -0
@@ 0,0 1,91 @@
# -*- coding: utf-8 -*-
# GNU Guix --- Functional package management for GNU
# Copyright © 2021 Lars-Dominik Braun <lars@6xq.net>
#
# This file is part of GNU Guix.
#
# GNU Guix is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or (at
# your option) any later version.
#
# GNU Guix is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with GNU Guix.  If not, see <http://www.gnu.org/licenses/>.

from __future__ import print_function  # Python 2 support.
import importlib
import pkg_resources
import sys
import traceback

try:
    from importlib.machinery import PathFinder
except ImportError:
    PathFinder = None

ret = 0

# Only check site-packages installed by this package, but not dependencies
# (which pkg_resources.working_set would include). Path supplied via argv.
ws = pkg_resources.find_distributions(sys.argv[1])

for dist in ws:
    print('validating', repr(dist.project_name), dist.location)
    try:
        print('...checking requirements: ', end='')
        req = str(dist.as_requirement())
        # dist.activate() is not enough to actually check requirements, we
        # have to .require() it.
        pkg_resources.require(req)
        print('OK')
    except Exception as e:
        print('ERROR:', req, e)
        ret = 1
        continue

    # Try to load top level modules. This should not have any side-effects.
    try:
        metalines = dist.get_metadata_lines('top_level.txt')
    except KeyError:
        # distutils (i.e. #:use-setuptools? #f) will not install any metadata.
        print('WARNING: cannot determine top-level modules')
        continue
    for name in metalines:
        # Only available on Python 3.
        if PathFinder and PathFinder.find_spec(name) is None:
            # Ignore unavailable modules, often C modules, which were not
            # installed at the top-level. Cannot use ModuleNotFoundError,
            # because it is raised by failed imports too.
            continue
        try:
            print('...trying to load module', name, end=': ')
            importlib.import_module(name)
            print('OK')
        except Exception:
            print('ERROR:')
            traceback.print_exc(file=sys.stdout)
            ret = 1
            continue

    # Try to load entry points of console scripts too, making sure they
    # work. They should be removed if they don't. Other groups may not be
    # safe, as they can depend on optional packages.
    for group, v in dist.get_entry_map().items():
        if group not in {'console_scripts', 'gui_scripts'}:
            continue
        for name, ep in v.items():
            try:
                print('...trying to load endpoint', group, name, end=': ')
                ep.load()
                print('OK')
            except Exception:
                print('ERROR:')
                traceback.print_exc(file=sys.stdout)
                ret = 1

sys.exit(ret)

M guix/build-system/python.scm => guix/build-system/python.scm +8 -0
@@ 2,6 2,7 @@
;;; Copyright © 2013, 2014, 2015, 2016, 2017 Ludovic Courtès <ludo@gnu.org>
;;; Copyright © 2013 Andreas Enge <andreas@enge.fr>
;;; Copyright © 2013 Nikita Karetnikov <nikita@karetnikov.org>
;;; Copyright © 2021 Lars-Dominik Braun <lars@6xq.net>
;;;
;;; This file is part of GNU Guix.
;;;


@@ 19,6 20,8 @@
;;; along with GNU Guix.  If not, see <http://www.gnu.org/licenses/>.

(define-module (guix build-system python)
  #:use-module ((gnu packages) #:select (search-auxiliary-file))
  #:use-module (guix gexp)
  #:use-module (guix store)
  #:use-module (guix utils)
  #:use-module (guix memoization)


@@ 70,6 73,10 @@ extension, such as '.tar.gz'."
  (let ((python (resolve-interface '(gnu packages python))))
    (module-ref python 'python-2)))

(define sanity-check.py
  ;; The script used to validate the installation of a Python package.
  (search-auxiliary-file "python/sanity-check.py"))

(define* (package-with-explicit-python python old-prefix new-prefix
                                       #:key variant-property)
  "Return a procedure of one argument, P.  The procedure creates a package with


@@ 156,6 163,7 @@ pre-defined variants."
                        ;; Keep the standard inputs of 'gnu-build-system'.
                        ,@(standard-packages)))
         (build-inputs `(("python" ,python)
                         ("sanity-check.py" ,(local-file sanity-check.py))
                         ,@native-inputs))
         (outputs outputs)
         (build python-build)

M guix/build/python-build-system.scm => guix/build/python-build-system.scm +16 -10
@@ 9,6 9,7 @@
;;; Copyright © 2019, 2020, 2021 Maxim Cournoyer <maxim.cournoyer@gmail.com>
;;; Copyright © 2020 Jakub Kądziołka <kuba@kadziolka.net>
;;; Copyright © 2020 Efraim Flashner <efraim@flashner.co.il>
;;; Copyright © 2021 Lars-Dominik Braun <lars@6xq.net>
;;;
;;; This file is part of GNU Guix.
;;;


@@ 132,6 133,15 @@
             (apply invoke "python" "./setup.py" command params)))
      (error "no setup.py found")))

(define* (sanity-check #:key tests? inputs outputs #:allow-other-keys)
  "Ensure packages depending on this package via setuptools work properly,
their advertised endpoints work and their top level modules are importable
without errors."
  (let ((sanity-check.py (assoc-ref inputs "sanity-check.py")))
    ;; Make sure the working directory is empty (i.e. no Python modules in it)
    (with-directory-excursion "/tmp"
      (invoke "python" sanity-check.py (site-packages inputs outputs)))))

(define* (build #:key use-setuptools? #:allow-other-keys)
  "Build a given Python package."
  (call-setuppy "build" '() use-setuptools?)


@@ 209,8 219,7 @@ running checks after installing the package."
    ;; '--invalidation-mode' option, do not generate any.
    (unless <3.7?
      (invoke "python" "-m" "compileall" "--invalidation-mode=unchecked-hash"
              out))
    #t))
              out))))

(define* (wrap #:key inputs outputs #:allow-other-keys)
  (define (list-of-files dir)


@@ 244,8 253,7 @@ installed with setuptools."
         (easy-install-pth (string-append site-packages "/easy-install.pth"))
         (new-pth (string-append site-packages "/" name ".pth")))
    (when (file-exists? easy-install-pth)
      (rename-file easy-install-pth new-pth))
    #t))
      (rename-file easy-install-pth new-pth))))

(define* (ensure-no-mtimes-pre-1980 #:rest _)
  "Ensure that there are no mtimes before 1980-01-02 in the source tree."


@@ 257,8 265,7 @@ installed with setuptools."
    (ftw "." (lambda (file stat flag)
               (unless (<= early-1980 (stat:mtime stat))
                 (utime file early-1980 early-1980))
               #t))
    #t))
               #t))))

(define* (enable-bytecode-determinism #:rest _)
  "Improve determinism of pyc files."


@@ 266,8 273,7 @@ installed with setuptools."
  (setenv "PYTHONHASHSEED" "0")
  ;; Prevent Python from creating .pyc files when loading modules (such as
  ;; when running a test suite).
  (setenv "PYTHONDONTWRITEBYTECODE" "1")
  #t)
  (setenv "PYTHONDONTWRITEBYTECODE" "1"))

(define* (ensure-no-cythonized-files #:rest _)
  "Check the source code for @code{.c} files which may have been pre-generated


@@ 278,8 284,7 @@ by Cython."
              (string-append (string-drop-right file 3) "c")))
        (when (file-exists? generated-file)
          (format #t "Possible Cythonized file found: ~a~%" generated-file))))
    (find-files "." "\\.pyx$"))
  #t)
    (find-files "." "\\.pyx$")))

(define %standard-phases
  ;; The build phase only builds C extensions and copies the Python sources,


@@ 301,6 306,7 @@ by Cython."
    (add-after 'install 'wrap wrap)
    (add-before 'check 'add-install-to-pythonpath add-install-to-pythonpath)
    (add-before 'check 'add-install-to-path add-install-to-path)
    (add-after 'check 'sanity-check sanity-check)
    (add-before 'strip 'rename-pth-file rename-pth-file)))

(define* (python-build #:key inputs (phases %standard-phases)

M tests/builders.scm => tests/builders.scm +83 -3
@@ 1,5 1,6 @@
;;; GNU Guix --- Functional package management for GNU
;;; Copyright © 2012, 2013, 2014, 2015, 2019 Ludovic Courtès <ludo@gnu.org>
;;; Copyright © 2021 Lars-Dominik Braun <lars@6xq.net>
;;;
;;; This file is part of GNU Guix.
;;;


@@ 23,15 24,15 @@
  #:use-module (guix build-system gnu)
  #:use-module (guix build gnu-build-system)
  #:use-module (guix build utils)
  #:use-module (guix build-system python)
  #:use-module (guix store)
  #:use-module (guix monads)
  #:use-module (guix utils)
  #:use-module (guix base32)
  #:use-module (guix derivations)
  #:use-module (gcrypt hash)
  #:use-module (guix tests)
  #:use-module ((guix packages)
                #:select (package?
                          package-derivation package-native-search-paths))
  #:use-module (guix packages)
  #:use-module (gnu packages bootstrap)
  #:use-module (ice-9 match)
  #:use-module (ice-9 textual-ports)


@@ 111,4 112,83 @@
             (call-with-input-file name get-string-all))))))))
 compressors)


;;;
;;; Test the sanity-check phase of the Python build system.
;;;

(define* (make-python-dummy name #:key (setup-py-extra "")
                            (init-py "") (use-setuptools? #t))
  (dummy-package (string-append "python-dummy-" name)
    (version "0.1")
    (build-system python-build-system)
    (arguments
     `(#:tests? #f
       #:use-setuptools? ,use-setuptools?
       #:phases
       (modify-phases %standard-phases
         (replace 'unpack
           (lambda _
             (mkdir-p "dummy")
             (with-output-to-file "dummy/__init__.py"
               (lambda _
                 (display ,init-py)))
             (with-output-to-file "setup.py"
               (lambda _
                 (format #t "\
~a
setup(
     name='dummy-~a',
     version='0.1',
     packages=['dummy'],
     ~a
     )"
                         (if ,use-setuptools?
                             "from setuptools import setup"
                             "from distutils.core import setup")
                         ,name ,setup-py-extra))))))))))

(define python-dummy-ok
  (make-python-dummy "ok"))

;; distutil won't install any metadata, so make sure our script does not fail
;; on a otherwise fine package.
(define python-dummy-no-setuptools
  (make-python-dummy
   "no-setuptools" #:use-setuptools? #f))

(define python-dummy-fail-requirements
  (make-python-dummy "fail-requirements"
                     #:setup-py-extra "install_requires=['nonexistent'],"))

(define python-dummy-fail-import
  (make-python-dummy "fail-import" #:init-py "import nonexistent"))

(define python-dummy-fail-console-script
  (make-python-dummy "fail-console-script"
                     #:setup-py-extra (string-append "entry_points={'console_scripts': "
                                                     "['broken = dummy:nonexistent']},")))

(define (check-build-success store p)
  (unless store (test-skip 1))
  (test-assert (string-append "python-build-system: " (package-name p))
    (let* ((drv (package-derivation store p)))
      (build-derivations store (list drv)))))

(define (check-build-failure store p)
  (unless store (test-skip 1))
  (test-assert (string-append "python-build-system: " (package-name p))
    (not (false-if-exception (package-derivation store python-dummy-fail-requirements)))))

(with-external-store store
  (for-each (lambda (p) (check-build-success store p))
            (list
             python-dummy-ok
             python-dummy-no-setuptools))
  (for-each (lambda (p) (check-build-failure store p))
            (list
             python-dummy-fail-requirements
             python-dummy-fail-import
             python-dummy-fail-console-script)))

(test-end "builders")