import: pypi: Support more types of archives.

This change enables the PyPI importer to look for requirements in a source
archive of a different type than "tar.gz" or "tar.bz2".  Also, scan the source
archive to find a requires.txt file.

* guix/import/pypi.scm: (guess-requirements)[tarball-directory]: Remove procedure.
[guess-requirements-from-source]: Use COMRESSED-FILE? to determine if an
archive type is supported, and some file extension logic that chooses either
"tar" or "unzip" as the extractor.  Search for the requires.txt file in the
archive instead of using a static, expected location.
(guess-requirements): Rename the TARBALL argument to ARCHIVE, to denote the
archive format is no longer bound specifically to the Tar format.
(compute-inputs): Likewise.
* tests/pypi.scm ("pypi->guix-package, no wheel"): Mock the requires.txt at a
non-standard location.
("pypi->guix-package, no usable requirement file."): New test.
This commit is contained in:
Maxim Cournoyer 2019-03-28 00:26:02 -04:00
parent cc9a77cd39
commit c799ad7276
No known key found for this signature in database
GPG Key ID: 1260E46482E63562
2 changed files with 83 additions and 46 deletions

View File

@ -39,7 +39,8 @@
#:use-module ((guix build utils) #:use-module ((guix build utils)
#:select ((package-name->name+version #:select ((package-name->name+version
. hyphen-package-name->name+version) . hyphen-package-name->name+version)
find-files)) find-files
invoke))
#:use-module (guix import utils) #:use-module (guix import utils)
#:use-module ((guix download) #:prefix download:) #:use-module ((guix download) #:prefix download:)
#:use-module (guix import json) #:use-module (guix import json)
@ -189,28 +190,11 @@ requirement names."
(loop (cons (specification->requirement-name line) (loop (cons (specification->requirement-name line)
result)))))))))) result))))))))))
(define (guess-requirements source-url wheel-url tarball) (define (guess-requirements source-url wheel-url archive)
"Given SOURCE-URL, WHEEL-URL and a TARBALL of the package, return a list "Given SOURCE-URL, WHEEL-URL and a ARCHIVE of the package, return a list
of the required packages specified in the requirements.txt file. TARBALL will of the required packages specified in the requirements.txt file. ARCHIVE will
be extracted in a temporary directory." be extracted in a temporary directory."
(define (tarball-directory url)
;; Given the URL of the package's tarball, return the name of the directory
;; that will be created upon decompressing it. If the filetype is not
;; supported, return #f.
;; TODO: Support more archive formats.
(let ((basename (substring url (+ 1 (string-rindex url #\/)))))
(cond
((string-suffix? ".tar.gz" basename)
(string-drop-right basename 7))
((string-suffix? ".tar.bz2" basename)
(string-drop-right basename 8))
(else
(begin
(warning (G_ "Unsupported archive format: \
cannot determine package dependencies"))
#f)))))
(define (read-wheel-metadata wheel-archive) (define (read-wheel-metadata wheel-archive)
;; Given WHEEL-ARCHIVE, a ZIP Python wheel archive, return the package's ;; Given WHEEL-ARCHIVE, a ZIP Python wheel archive, return the package's
;; requirements. ;; requirements.
@ -245,23 +229,28 @@ cannot determine package dependencies"))
(define (guess-requirements-from-source) (define (guess-requirements-from-source)
;; Return the package's requirements by guessing them from the source. ;; Return the package's requirements by guessing them from the source.
(let ((dirname (tarball-directory source-url))) (if (compressed-file? source-url)
(if (string? dirname)
(call-with-temporary-directory (call-with-temporary-directory
(lambda (dir) (lambda (dir)
(let* ((pypi-name (string-take dirname (string-rindex dirname #\-))) (parameterize ((current-error-port (%make-void-port "rw+"))
(requires.txt (string-append dirname "/" pypi-name
".egg-info" "/requires.txt"))
(exit-code (parameterize ((current-error-port (%make-void-port "rw+"))
(current-output-port (%make-void-port "rw+"))) (current-output-port (%make-void-port "rw+")))
(system* "tar" "xf" tarball "-C" dir requires.txt)))) (if (string=? "zip" (file-extension source-url))
(if (zero? exit-code) (invoke "unzip" archive "-d" dir)
(parse-requires.txt (string-append dir "/" requires.txt)) (invoke "tar" "xf" archive "-C" dir)))
(let ((requires.txt-files
(find-files dir (lambda (abs-file-name _)
(string-match "\\.egg-info/requires.txt$"
abs-file-name)))))
(match requires.txt-files
(()
(warning (G_ "Cannot guess requirements from source archive:\
no requires.txt file found.~%"))
'())
(else (parse-requires.txt (first requires.txt-files)))))))
(begin (begin
(warning (warning (G_ "Unsupported archive format; \
(G_ "Failed to extract file: ~a from source.~%") cannot determine package dependencies from source archive: ~a~%")
requires.txt) (basename source-url))
'())))))
'()))) '())))
;; First, try to compute the requirements using the wheel, else, fallback to ;; First, try to compute the requirements using the wheel, else, fallback to
@ -270,13 +259,13 @@ cannot determine package dependencies"))
(or (guess-requirements-from-wheel) (or (guess-requirements-from-wheel)
(guess-requirements-from-source))) (guess-requirements-from-source)))
(define (compute-inputs source-url wheel-url tarball) (define (compute-inputs source-url wheel-url archive)
"Given the SOURCE-URL of an already downloaded TARBALL, return a list of "Given the SOURCE-URL of an already downloaded ARCHIVE, return a list of
name/variable pairs describing the required inputs of this package. Also name/variable pairs describing the required inputs of this package. Also
return the unaltered list of upstream dependency names." return the unaltered list of upstream dependency names."
(let ((dependencies (let ((dependencies
(remove (cut string=? "argparse" <>) (remove (cut string=? "argparse" <>)
(guess-requirements source-url wheel-url tarball)))) (guess-requirements source-url wheel-url archive))))
(values (sort (values (sort
(map (lambda (input) (map (lambda (input)
(let ((guix-name (python->package-name input))) (let ((guix-name (python->package-name input)))

View File

@ -20,6 +20,7 @@
(define-module (test-pypi) (define-module (test-pypi)
#:use-module (guix import pypi) #:use-module (guix import pypi)
#:use-module (guix base32) #:use-module (guix base32)
#:use-module (guix memoization)
#:use-module (gcrypt hash) #:use-module (gcrypt hash)
#:use-module (guix tests) #:use-module (guix tests)
#:use-module (guix build-system python) #:use-module (guix build-system python)
@ -134,8 +135,9 @@ pytest (>=2.5.0)
(match url (match url
("https://example.com/foo-1.0.0.tar.gz" ("https://example.com/foo-1.0.0.tar.gz"
(begin (begin
(mkdir-p "foo-1.0.0/foo.egg-info/") ;; Unusual requires.txt location should still be found.
(with-output-to-file "foo-1.0.0/foo.egg-info/requires.txt" (mkdir-p "foo-1.0.0/src/bizarre.egg-info")
(with-output-to-file "foo-1.0.0/src/bizarre.egg-info/requires.txt"
(lambda () (lambda ()
(display test-requires.txt))) (display test-requires.txt)))
(parameterize ((current-output-port (%make-void-port "rw+"))) (parameterize ((current-output-port (%make-void-port "rw+")))
@ -241,4 +243,50 @@ pytest (>=2.5.0)
(x (x
(pk 'fail x #f)))))) (pk 'fail x #f))))))
(test-assert "pypi->guix-package, no usable requirement file."
;; Replace network resources with sample data.
(mock ((guix import utils) url-fetch
(lambda (url file-name)
(match url
("https://example.com/foo-1.0.0.tar.gz"
(mkdir-p "foo-1.0.0/foo.egg-info/")
(parameterize ((current-output-port (%make-void-port "rw+")))
(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" #f)
(_ (error "Unexpected URL: " url)))))
(mock ((guix http-client) http-fetch
(lambda (url . rest)
(match url
("https://pypi.org/pypi/foo/json"
(values (open-input-string test-json)
(string-length test-json)))
("https://example.com/foo-1.0.0-py2.py3-none-any.whl" #f)
(_ (error "Unexpected URL: " url)))))
;; Not clearing the memoization cache here would mean returning the value
;; computed in the previous test.
(invalidate-memoization! pypi->guix-package)
(match (pypi->guix-package "foo")
(('package
('name "python-foo")
('version "1.0.0")
('source ('origin
('method 'url-fetch)
('uri ('pypi-uri "foo" 'version))
('sha256
('base32
(? string? hash)))))
('build-system 'python-build-system)
('home-page "http://example.com")
('synopsis "summary")
('description "summary")
('license 'license:lgpl2.0))
(string=? (bytevector->nix-base32-string
test-source-hash)
hash))
(x
(pk 'fail x #f))))))
(test-end "pypi") (test-end "pypi")