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.
master
Cyril Roelandt 2015-12-27 03:26:11 +01:00
parent 4f54a63e1e
commit 266785d21e
3 changed files with 166 additions and 29 deletions

View File

@ -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. Index}@footnote{This functionality requires Guile-JSON to be installed.
@xref{Requirements}.}. Information is taken from the JSON-formatted @xref{Requirements}.}. Information is taken from the JSON-formatted
description available at @code{pypi.python.org} and usually includes all 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 The command below imports metadata for the @code{itsdangerous} Python
package: package:

View File

@ -71,6 +71,16 @@ or #f on failure."
(raise (condition (&missing-source-error (raise (condition (&missing-source-error
(package pypi-package))))))) (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) (define (python->package-name name)
"Given the NAME of a package on PyPI, return a Guix-compliant name for the "Given the NAME of a package on PyPI, return a Guix-compliant name for the
package." package."
@ -88,6 +98,11 @@ package on PyPI."
;; '/' + package name + '/' + ... ;; '/' + package name + '/' + ...
(substring source-url 42 (string-rindex source-url #\/)))) (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) (define (maybe-inputs package-inputs)
"Given a list of PACKAGE-INPUTS, tries to generate the 'inputs' field of a "Given a list of PACKAGE-INPUTS, tries to generate the 'inputs' field of a
package definition." package definition."
@ -97,10 +112,10 @@ package definition."
((package-inputs ...) ((package-inputs ...)
`((inputs (,'quasiquote ,package-inputs)))))) `((inputs (,'quasiquote ,package-inputs))))))
(define (guess-requirements source-url tarball) (define (guess-requirements source-url wheel-url tarball)
"Given SOURCE-URL and a TARBALL of the package, return a list of the required "Given SOURCE-URL, WHEEL-URL and a TARBALL of the package, return a list of
packages specified in the requirements.txt file. TARBALL will be extracted in the required packages specified in the requirements.txt file. TARBALL will be
the current directory, and will be deleted." extracted in the current directory, and will be deleted."
(define (tarball-directory url) (define (tarball-directory url)
;; Given the URL of the package's tarball, return the name of the directory ;; Given the URL of the package's tarball, return the name of the directory
@ -147,6 +162,41 @@ cannot determine package dependencies"))
(loop (cons (python->package-name (clean-requirement line)) (loop (cons (python->package-name (clean-requirement line))
result)))))))))) result))))))))))
(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))) (let ((dirname (tarball-directory source-url)))
(if (string? dirname) (if (string? dirname)
(let* ((req-file (string-append dirname "/requirements.txt")) (let* ((req-file (string-append dirname "/requirements.txt"))
@ -166,7 +216,15 @@ cannot determine package dependencies"))
'()))) '())))
'()))) '())))
(define (compute-inputs source-url tarball) ;; 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 wheel-url tarball)
"Given the SOURCE-URL of an already downloaded TARBALL, return a list of "Given the SOURCE-URL of an already downloaded TARBALL, return a list of
name/variable pairs describing the required inputs of this package." name/variable pairs describing the required inputs of this package."
(sort (sort
@ -175,13 +233,13 @@ name/variable pairs describing the required inputs of this package."
(append '("python-setuptools") (append '("python-setuptools")
;; Argparse has been part of Python since 2.7. ;; Argparse has been part of Python since 2.7.
(remove (cut string=? "python-argparse" <>) (remove (cut string=? "python-argparse" <>)
(guess-requirements source-url tarball)))) (guess-requirements source-url wheel-url tarball))))
(lambda args (lambda args
(match args (match args
(((a _ ...) (b _ ...)) (((a _ ...) (b _ ...))
(string-ci<? 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) description license)
"Return the `package' s-expression for a python package with the given NAME, "Return the `package' s-expression for a python package with the given NAME,
VERSION, SOURCE-URL, HOME-PAGE, SYNOPSIS, DESCRIPTION, and LICENSE." VERSION, SOURCE-URL, HOME-PAGE, SYNOPSIS, DESCRIPTION, and LICENSE."
@ -206,7 +264,7 @@ VERSION, SOURCE-URL, HOME-PAGE, SYNOPSIS, DESCRIPTION, and LICENSE."
(base32 (base32
,(guix-hash-url temp))))) ,(guix-hash-url temp)))))
(build-system python-build-system) (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) (home-page ,home-page)
(synopsis ,synopsis) (synopsis ,synopsis)
(description ,description) (description ,description)
@ -225,11 +283,12 @@ VERSION, SOURCE-URL, HOME-PAGE, SYNOPSIS, DESCRIPTION, and LICENSE."
(let ((name (assoc-ref* package "info" "name")) (let ((name (assoc-ref* package "info" "name"))
(version (assoc-ref* package "info" "version")) (version (assoc-ref* package "info" "version"))
(release (assoc-ref (latest-source-release package) "url")) (release (assoc-ref (latest-source-release package) "url"))
(wheel (assoc-ref (latest-wheel-release package) "url"))
(synopsis (assoc-ref* package "info" "summary")) (synopsis (assoc-ref* package "info" "summary"))
(description (assoc-ref* package "info" "summary")) (description (assoc-ref* package "info" "summary"))
(home-page (assoc-ref* package "info" "home_page")) (home-page (assoc-ref* package "info" "home_page"))
(license (string->license (assoc-ref* package "info" "license")))) (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)))))) description license))))))
(define (pypi-package? package) (define (pypi-package? package)

View File

@ -21,7 +21,7 @@
#:use-module (guix base32) #:use-module (guix base32)
#:use-module (guix hash) #:use-module (guix hash)
#:use-module (guix tests) #: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 (srfi srfi-64)
#:use-module (ice-9 match)) #:use-module (ice-9 match))
@ -42,6 +42,9 @@
}, { }, {
\"url\": \"https://example.com/foo-1.0.0.tar.gz\", \"url\": \"https://example.com/foo-1.0.0.tar.gz\",
\"packagetype\": \"sdist\", \"packagetype\": \"sdist\",
}, {
\"url\": \"https://example.com/foo-1.0.0-py2.py3-none-any.whl\",
\"packagetype\": \"bdist_wheel\",
} }
] ]
} }
@ -56,6 +59,18 @@
bar bar
baz > 13.37") baz > 13.37")
(define test-metadata
"{
\"run_requires\": [
{
\"requires\": [
\"bar\",
\"baz (>13.37)\"
]
}
]
}")
(test-begin "pypi") (test-begin "pypi")
(test-assert "pypi->guix-package" (test-assert "pypi->guix-package"
@ -77,6 +92,67 @@ baz > 13.37")
(delete-file-recursively "foo-1.0.0") (delete-file-recursively "foo-1.0.0")
(set! test-source-hash (set! test-source-hash
(call-with-input-file file-name port-sha256)))) (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))))) (_ (error "Unexpected URL: " url)))))
(match (pypi->guix-package "foo") (match (pypi->guix-package "foo")
(('package (('package