diff options
author | Cyril Roelandt <tipecaml@gmail.com> | 2015-12-27 03:26:11 +0100 |
---|---|---|
committer | Cyril Roelandt <tipecaml@gmail.com> | 2016-06-14 22:03:22 +0200 |
commit | 266785d21e9ed3fcbecebea302231cf35e303d66 (patch) | |
tree | 944e99e1c6da36b629798f8f5ff1851a8c951397 | |
parent | 4f54a63e1e71bf9a48e66bd4459809b699889620 (diff) | |
download | gnu-guix-266785d21e9ed3fcbecebea302231cf35e303d66.tar gnu-guix-266785d21e9ed3fcbecebea302231cf35e303d66.tar.gz |
import: pypi: read requirements from wheels.
* doc/guix.tex (Invoking guix import): Mention that the pypi importer
works better with "unzip".
* guix/import/pypi.scm (latest-wheel-release,
wheel-url->extracted-directory): New procedures.
* tests/pypi.scm (("pypi->guix-package, wheels"): New test.
-rw-r--r-- | doc/guix.texi | 4 | ||||
-rw-r--r-- | guix/import/pypi.scm | 113 | ||||
-rw-r--r-- | tests/pypi.scm | 78 |
3 files changed, 166 insertions, 29 deletions
diff --git a/doc/guix.texi b/doc/guix.texi index 46d9e77fe6..0a30b52fca 100644 --- a/doc/guix.texi +++ b/doc/guix.texi @@ -4545,7 +4545,9 @@ Import metadata from the @uref{https://pypi.python.org/, Python Package Index}@footnote{This functionality requires Guile-JSON to be installed. @xref{Requirements}.}. Information is taken from the JSON-formatted description available at @code{pypi.python.org} and usually includes all -the relevant information, including package dependencies. +the relevant information, including package dependencies. For maximum +efficiency, it is recommended to install the @command{unzip} utility, so +that the importer can unzip Python wheels and gather data from them. The command below imports metadata for the @code{itsdangerous} Python package: diff --git a/guix/import/pypi.scm b/guix/import/pypi.scm index de30f4bea6..70ef507666 100644 --- a/guix/import/pypi.scm +++ b/guix/import/pypi.scm @@ -71,6 +71,16 @@ or #f on failure." (raise (condition (&missing-source-error (package pypi-package))))))) +(define (latest-wheel-release pypi-package) + "Return the url of the wheel for the latest release of pypi-package, +or #f if there isn't any." + (let ((releases (assoc-ref* pypi-package "releases" + (assoc-ref* pypi-package "info" "version")))) + (or (find (lambda (release) + (string=? "bdist_wheel" (assoc-ref release "packagetype"))) + releases) + #f))) + (define (python->package-name name) "Given the NAME of a package on PyPI, return a Guix-compliant name for the package." @@ -88,6 +98,11 @@ package on PyPI." ;; '/' + package name + '/' + ... (substring source-url 42 (string-rindex source-url #\/)))) +(define (wheel-url->extracted-directory wheel-url) + (match (string-split (basename wheel-url) #\-) + ((name version _ ...) + (string-append name "-" version ".dist-info")))) + (define (maybe-inputs package-inputs) "Given a list of PACKAGE-INPUTS, tries to generate the 'inputs' field of a package definition." @@ -97,10 +112,10 @@ package definition." ((package-inputs ...) `((inputs (,'quasiquote ,package-inputs)))))) -(define (guess-requirements source-url tarball) - "Given SOURCE-URL and a TARBALL of the package, return a list of the required -packages specified in the requirements.txt file. TARBALL will be extracted in -the current directory, and will be deleted." +(define (guess-requirements source-url wheel-url tarball) + "Given SOURCE-URL, WHEEL-URL and a TARBALL of the package, return a list of +the required packages specified in the requirements.txt file. TARBALL will be +extracted in the current directory, and will be deleted." (define (tarball-directory url) ;; Given the URL of the package's tarball, return the name of the directory @@ -147,26 +162,69 @@ cannot determine package dependencies")) (loop (cons (python->package-name (clean-requirement line)) result)))))))))) - (let ((dirname (tarball-directory source-url))) - (if (string? dirname) - (let* ((req-file (string-append dirname "/requirements.txt")) - (exit-code (system* "tar" "xf" tarball req-file))) - ;; TODO: support more formats. - (if (zero? exit-code) - (dynamic-wind - (const #t) - (lambda () - (read-requirements req-file)) - (lambda () - (delete-file req-file) - (rmdir dirname))) - (begin - (warning (_ "'tar xf' failed with exit code ~a\n") - exit-code) - '()))) - '()))) + (define (read-wheel-metadata wheel-archive) + ;; Given WHEEL-ARCHIVE, a ZIP Python wheel archive, return the package's + ;; requirements. + (let* ((dirname (wheel-url->extracted-directory wheel-url)) + (json-file (string-append dirname "/metadata.json"))) + (and (zero? (system* "unzip" "-q" wheel-archive json-file)) + (dynamic-wind + (const #t) + (lambda () + (call-with-input-file json-file + (lambda (port) + (let* ((metadata (json->scm port)) + (run_requires (hash-ref metadata "run_requires")) + (requirements (hash-ref (list-ref run_requires 0) + "requires"))) + (map (lambda (r) + (python->package-name (clean-requirement r))) + requirements))))) + (lambda () + (delete-file json-file) + (rmdir dirname)))))) + + (define (guess-requirements-from-wheel) + ;; Return the package's requirements using the wheel, or #f if an error + ;; occurs. + (call-with-temporary-output-file + (lambda (temp port) + (if wheel-url + (and (url-fetch wheel-url temp) + (read-wheel-metadata temp)) + #f)))) + + + (define (guess-requirements-from-source) + ;; Return the package's requirements by guessing them from the source. + (let ((dirname (tarball-directory source-url))) + (if (string? dirname) + (let* ((req-file (string-append dirname "/requirements.txt")) + (exit-code (system* "tar" "xf" tarball req-file))) + ;; TODO: support more formats. + (if (zero? exit-code) + (dynamic-wind + (const #t) + (lambda () + (read-requirements req-file)) + (lambda () + (delete-file req-file) + (rmdir dirname))) + (begin + (warning (_ "'tar xf' failed with exit code ~a\n") + exit-code) + '()))) + '()))) + + ;; First, try to compute the requirements using the wheel, since that is the + ;; most reliable option. If a wheel is not provided for this package, try + ;; getting them by reading the "requirements.txt" file from the source. Note + ;; that "requirements.txt" is not mandatory, so this is likely to fail. + (or (guess-requirements-from-wheel) + (guess-requirements-from-source))) + -(define (compute-inputs source-url tarball) +(define (compute-inputs source-url wheel-url tarball) "Given the SOURCE-URL of an already downloaded TARBALL, return a list of name/variable pairs describing the required inputs of this package." (sort @@ -175,13 +233,13 @@ name/variable pairs describing the required inputs of this package." (append '("python-setuptools") ;; Argparse has been part of Python since 2.7. (remove (cut string=? "python-argparse" <>) - (guess-requirements source-url tarball)))) + (guess-requirements source-url wheel-url tarball)))) (lambda args (match args (((a _ ...) (b _ ...)) (string-ci<? a b)))))) -(define (make-pypi-sexp name version source-url home-page synopsis +(define (make-pypi-sexp name version source-url wheel-url home-page synopsis description license) "Return the `package' s-expression for a python package with the given NAME, VERSION, SOURCE-URL, HOME-PAGE, SYNOPSIS, DESCRIPTION, and LICENSE." @@ -206,7 +264,7 @@ VERSION, SOURCE-URL, HOME-PAGE, SYNOPSIS, DESCRIPTION, and LICENSE." (base32 ,(guix-hash-url temp))))) (build-system python-build-system) - ,@(maybe-inputs (compute-inputs source-url temp)) + ,@(maybe-inputs (compute-inputs source-url wheel-url temp)) (home-page ,home-page) (synopsis ,synopsis) (description ,description) @@ -225,11 +283,12 @@ VERSION, SOURCE-URL, HOME-PAGE, SYNOPSIS, DESCRIPTION, and LICENSE." (let ((name (assoc-ref* package "info" "name")) (version (assoc-ref* package "info" "version")) (release (assoc-ref (latest-source-release package) "url")) + (wheel (assoc-ref (latest-wheel-release package) "url")) (synopsis (assoc-ref* package "info" "summary")) (description (assoc-ref* package "info" "summary")) (home-page (assoc-ref* package "info" "home_page")) (license (string->license (assoc-ref* package "info" "license")))) - (make-pypi-sexp name version release home-page synopsis + (make-pypi-sexp name version release wheel home-page synopsis description license)))))) (define (pypi-package? package) diff --git a/tests/pypi.scm b/tests/pypi.scm index e463467c41..379c288394 100644 --- a/tests/pypi.scm +++ b/tests/pypi.scm @@ -21,7 +21,7 @@ #:use-module (guix base32) #:use-module (guix hash) #:use-module (guix tests) - #:use-module ((guix build utils) #:select (delete-file-recursively)) + #:use-module ((guix build utils) #:select (delete-file-recursively which)) #:use-module (srfi srfi-64) #:use-module (ice-9 match)) @@ -42,6 +42,9 @@ }, { \"url\": \"https://example.com/foo-1.0.0.tar.gz\", \"packagetype\": \"sdist\", + }, { + \"url\": \"https://example.com/foo-1.0.0-py2.py3-none-any.whl\", + \"packagetype\": \"bdist_wheel\", } ] } @@ -56,6 +59,18 @@ bar baz > 13.37") +(define test-metadata + "{ + \"run_requires\": [ + { + \"requires\": [ + \"bar\", + \"baz (>13.37)\" + ] + } + ] +}") + (test-begin "pypi") (test-assert "pypi->guix-package" @@ -77,6 +92,67 @@ baz > 13.37") (delete-file-recursively "foo-1.0.0") (set! test-source-hash (call-with-input-file file-name port-sha256)))) + ("https://example.com/foo-1.0.0-py2.py3-none-any.whl" #f) + (_ (error "Unexpected URL: " url))))) + (match (pypi->guix-package "foo") + (('package + ('name "python-foo") + ('version "1.0.0") + ('source ('origin + ('method 'url-fetch) + ('uri (string-append "https://example.com/foo-" + version ".tar.gz")) + ('sha256 + ('base32 + (? string? hash))))) + ('build-system 'python-build-system) + ('inputs + ('quasiquote + (("python-bar" ('unquote 'python-bar)) + ("python-baz" ('unquote 'python-baz)) + ("python-setuptools" ('unquote 'python-setuptools))))) + ('home-page "http://example.com") + ('synopsis "summary") + ('description "summary") + ('license 'lgpl2.0)) + (string=? (bytevector->nix-base32-string + test-source-hash) + hash)) + (x + (pk 'fail x #f))))) + +(test-skip (if (which "zip") 0 1)) +(test-assert "pypi->guix-package, wheels" + ;; Replace network resources with sample data. + (mock ((guix import utils) url-fetch + (lambda (url file-name) + (match url + ("https://pypi.python.org/pypi/foo/json" + (with-output-to-file file-name + (lambda () + (display test-json)))) + ("https://example.com/foo-1.0.0.tar.gz" + (begin + (mkdir "foo-1.0.0") + (with-output-to-file "foo-1.0.0/requirements.txt" + (lambda () + (display test-requirements))) + (system* "tar" "czvf" file-name "foo-1.0.0/") + (delete-file-recursively "foo-1.0.0") + (set! test-source-hash + (call-with-input-file file-name port-sha256)))) + ("https://example.com/foo-1.0.0-py2.py3-none-any.whl" + (begin + (mkdir "foo-1.0.0.dist-info") + (with-output-to-file "foo-1.0.0.dist-info/metadata.json" + (lambda () + (display test-metadata))) + (let ((zip-file (string-append file-name ".zip"))) + ;; zip always adds a "zip" extension to the file it creates, + ;; so we need to rename it. + (system* "zip" zip-file "foo-1.0.0.dist-info/metadata.json") + (rename-file zip-file file-name)) + (delete-file-recursively "foo-1.0.0.dist-info"))) (_ (error "Unexpected URL: " url))))) (match (pypi->guix-package "foo") (('package |