aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCyril Roelandt <tipecaml@gmail.com>2015-12-27 03:26:11 +0100
committerCyril Roelandt <tipecaml@gmail.com>2016-06-14 22:03:22 +0200
commit266785d21e9ed3fcbecebea302231cf35e303d66 (patch)
tree944e99e1c6da36b629798f8f5ff1851a8c951397
parent4f54a63e1e71bf9a48e66bd4459809b699889620 (diff)
downloadgnu-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.texi4
-rw-r--r--guix/import/pypi.scm113
-rw-r--r--tests/pypi.scm78
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