Toggle diff (506 lines)
diff --git a/guix/import/pypi.scm b/guix/import/pypi.scm
index c520213b6a..099768f0c8 100644
--- a/guix/import/pypi.scm
+++ b/guix/import/pypi.scm
;;; Copyright © 2015, 2016, 2017 Ludovic Courtès <ludo@gnu.org>
;;; Copyright © 2017 Mathieu Othacehe <m.othacehe@gmail.com>
;;; Copyright © 2018 Ricardo Wurmus <rekado@elephly.net>
+;;; Copyright © 2019 Maxim Cournoyer <maxim.cournoyer@gmail.com>
;;; This file is part of GNU Guix.
#:use-module (ice-9 receive)
#:use-module ((ice-9 rdelim) #:select (read-line))
#:use-module (srfi srfi-1)
+ #:use-module (srfi srfi-11)
#:use-module (srfi srfi-26)
#:use-module (srfi srfi-34)
#:use-module (srfi srfi-35)
@@ -106,14 +108,15 @@ package on PyPI."
(string-append name "-" version ".dist-info"))))
-(define (maybe-inputs package-inputs)
+(define (maybe-inputs package-inputs input-type)
"Given a list of PACKAGE-INPUTS, tries to generate the 'inputs' field of a
+package definition. INPUT-TYPE, a symbol, is used to populate the name of
- `((propagated-inputs (,'quasiquote ,package-inputs))))))
+ `((,input-type (,'quasiquote ,package-inputs))))))
(define %requirement-name-regexp
;; Regexp to match the requirement name in a requirement specification.
@@ -147,11 +150,21 @@ package definition."
(or (regexp-exec %requirement-name-regexp spec)
(error (G_ "Could not extract requirement name in spec:") spec))))
+(define (test-section? name)
+ "Return #t if the section name contains 'test' or 'dev'."
+ (any (cut string-contains-ci name <>)
(define (parse-requires.txt requires.txt)
- "Given REQUIRES.TXT, a Setuptools requires.txt file, return a list of
- ;; This is a very incomplete parser, which job is to select the non-optional
- ;; dependencies and strip them out of any version information.
+ "Given REQUIRES.TXT, a Setuptools requires.txt file, return a pair of requirements.
+The first element of the pair contains the required dependencies while the
+second the optional test dependencies. Note that currently, optional,
+non-test dependencies are omitted since these can be difficult or expensive to
+ ;; This is a very incomplete parser, which job is to read in the requirement
+ ;; specification lines, and strip them out of any version information.
;; Alternatively, we could implement a PEG parser with the (ice-9 peg)
;; library and the requirements grammar defined by PEP-0508
;; (https://www.python.org/dev/peps/pep-0508/).
@@ -168,57 +181,89 @@ requirement names."
(call-with-input-file requires.txt
- (let loop ((result '()))
+ (let loop ((required-deps '())
+ (inside-test-section? #f)
(let ((line (read-line port)))
- ;; Stop when a section is encountered, as sections contains optional
- ;; (extra) requirements. Non-optional requirements must appear
- ;; before any section is defined.
- (if (or (eof-object? line) (section-header? line))
;; Duplicates can occur, since the same requirement can be
;; listed multiple times with different conditional markers, e.g.
;; pytest >= 3 ; python_version >= "3.3"
;; pytest < 3 ; python_version < "3.3"
- (reverse (delete-duplicates result))
+ (map (compose reverse delete-duplicates)
+ (list required-deps test-deps))
((or (string-null? line) (comment? line))
+ (loop required-deps test-deps inside-test-section? optional?))
+ ((section-header? line)
+ ;; Encountering a section means that all the requirements
+ ;; listed below are optional. Since we want to pick only the
+ ;; test dependencies from the optional dependencies, we must
+ ;; track those separately.
+ (loop required-deps test-deps (test-section? line) #t))
+ (cons (specification->requirement-name line)
+ inside-test-section? optional?))
(loop (cons (specification->requirement-name line)
+ test-deps inside-test-section? optional?))
+ ;; Skip optional items.
+ (loop required-deps test-deps inside-test-section? optional?))
+ (warning (G_ "parse-requires.txt reached an unexpected \
+condition on line ~a~%") line)))))))))
(define (parse-wheel-metadata metadata)
- "Given METADATA, a Wheel metadata file, return a list of requirement names."
+ "Given METADATA, a Wheel metadata file, return a pair of requirements.
+The first element of the pair contains the required dependencies while the second the optional
+test dependencies. Note that currently, optional, non-test dependencies are
+omitted since these can be difficult or expensive to satisfy."
;; METADATA is a RFC-2822-like, header based file.
(define (requires-dist-header? line)
;; Return #t if the given LINE is a Requires-Dist header.
- (regexp-match? (string-match "^Requires-Dist: " line)))
+ (string-match "^Requires-Dist: " line))
(define (requires-dist-value line)
(string-drop line (string-length "Requires-Dist: ")))
;; Return #t if the given LINE is an "extra" requirement.
- (regexp-match? (string-match "extra == " line)))
+ (string-match "extra == '(.*)'" line))
+ (define (test-requirement? line)
+ (let ((extra-label (match:substring (extra? line) 1)))
+ (and extra-label (test-section? extra-label))))
(call-with-input-file metadata
- (let loop ((requirements '()))
+ (let loop ((required-deps '())
(let ((line (read-line port)))
- ;; Stop at the first 'Provides-Extra' section: the non-optional
- ;; requirements appear before the optional ones.
- (reverse (delete-duplicates requirements))
+ (map (compose reverse delete-duplicates)
+ (list required-deps test-deps))
((and (requires-dist-header? line) (not (extra? line)))
(loop (cons (specification->requirement-name
(requires-dist-value line))
+ ((and (requires-dist-header? line) (test-requirement? line))
+ (cons (specification->requirement-name (requires-dist-value line))
- (loop requirements)))))))))
+ (loop required-deps test-deps))))))))) ;skip line
(define (guess-requirements source-url wheel-url archive)
- "Given SOURCE-URL, WHEEL-URL and a ARCHIVE of the package, return a list
+ "Given SOURCE-URL, WHEEL-URL and an ARCHIVE of the package, return a list
of the required packages specified in the requirements.txt file. ARCHIVE will
be extracted in a temporary directory."
@@ -244,7 +289,10 @@ cannot determine package dependencies") (file-extension url))
(metadata (string-append dirname "/METADATA")))
(call-with-temporary-directory
- (if (zero? (system* "unzip" "-q" wheel-archive "-d" dir metadata))
+ (parameterize ((current-error-port (%make-void-port "rw+"))
+ (current-output-port (%make-void-port "rw+")))
+ (system* "unzip" wheel-archive "-d" dir metadata)))
(parse-wheel-metadata (string-append dir "/" metadata))
@@ -283,32 +331,41 @@ cannot determine package dependencies") (file-extension url))
(G_ "Failed to extract file: ~a from source.~%")
;; First, try to compute the requirements using the wheel, else, fallback to
;; reading the "requires.txt" from the egg-info directory from the source
(or (guess-requirements-from-wheel)
(guess-requirements-from-source)))
(define (compute-inputs source-url wheel-url archive)
- "Given the SOURCE-URL of an already downloaded ARCHIVE, return a list of
-name/variable pairs describing the required inputs of this package. Also
+ "Given the SOURCE-URL and WHEEL-URL of an already downloaded ARCHIVE, return
+a pair of lists, each consisting of a list of name/variable pairs, for the
+propagated inputs and the native inputs, respectively. Also
return the unaltered list of upstream dependency names."
- (remove (cut string=? "argparse" <>)
- (guess-requirements source-url wheel-url archive))))
- (let ((guix-name (python->package-name input)))
- (list guix-name (list 'unquote (string->symbol guix-name)))))
+ (define (strip-argparse deps)
+ (remove (cut string=? "argparse" <>) deps))
+ (define (requirement->package-name/sort deps)
+ (let ((guix-name (python->package-name input)))
+ (list guix-name (list 'unquote (string->symbol guix-name)))))
+ (define process-requirements
+ (compose requirement->package-name/sort strip-argparse))
+ (let ((dependencies (guess-requirements source-url wheel-url archive)))
+ (values (map process-requirements dependencies)
+ (concatenate dependencies))))
(define (make-pypi-sexp name version source-url wheel-url home-page synopsis
@@ -317,29 +374,33 @@ VERSION, SOURCE-URL, HOME-PAGE, SYNOPSIS, DESCRIPTION, and LICENSE."
(call-with-temporary-output-file
(and (url-fetch source-url temp)
- (receive (input-package-names upstream-dependency-names)
+ (receive (guix-dependencies upstream-dependencies)
(compute-inputs source-url wheel-url temp)
- (name ,(python->package-name name))
- ;; Sometimes 'pypi-uri' doesn't quite work due to mixed
- ;; cases in NAME, for instance, as is the case with
- ;; "uwsgi". In that case, fall back to a full URL.
- (uri (pypi-uri ,(string-downcase name) version))
- ,(guix-hash-url temp)))))
- (build-system python-build-system)
- ,@(maybe-inputs input-package-names)
- (description ,description)
- (license ,(license->symbol license)))
- upstream-dependency-names))))))
+ (match guix-dependencies
+ ((required-inputs test-inputs)
+ (name ,(python->package-name name))
+ ;; Sometimes 'pypi-uri' doesn't quite work due to mixed
+ ;; cases in NAME, for instance, as is the case with
+ ;; "uwsgi". In that case, fall back to a full URL.
+ (uri (pypi-uri ,(string-downcase name) version))
+ ,(guix-hash-url temp)))))
+ (build-system python-build-system)
+ ,@(maybe-inputs required-inputs 'propagated-inputs)
+ ,@(maybe-inputs test-inputs 'native-inputs)
+ (description ,description)
+ (license ,(license->symbol license)))
+ ;; Flatten the nested lists and return the upstream
+ upstream-dependencies))))))))
(define pypi->guix-package
diff --git a/tests/pypi.scm b/tests/pypi.scm
index ca8cb5f6de..aa08e2cb54 100644
;;; GNU Guix --- Functional package management for GNU
;;; Copyright © 2014 David Thompson <davet@gnu.org>
;;; Copyright © 2016 Ricardo Wurmus <rekado@elephly.net>
+;;; Copyright © 2019 Maxim Cournoyer <maxim.cournoyer@gmail.com>
;;; This file is part of GNU Guix.
sha1=da9234ee9982d4bbb3c72346a6de940a148ea686"))
(define test-requires.txt "\
-(define test-requires-with-sections "\
@@ -78,12 +74,25 @@ bar != 2
+;; Beaker contains only optional dependencies.
+(define test-requires.txt-beaker "\
Classifier: Programming Language :: Python :: 3.7
+Requires-Dist: pytest (>=2.5.0) ; extra == 'test'
(define test-metadata-with-extras "
@@ -137,25 +146,31 @@ Requires-Dist: pytest (>=3.1.0); extra == 'testing'
'("Fizzy" "PickyThing" "SomethingWithMarker" "requests" "pip")
(map specification->requirement-name test-specifications))
-(test-equal "parse-requires.txt, with sections"
+(test-equal "parse-requires.txt"
+ (list '("foo" "bar") '("pytest"))
(mock ((ice-9 ports) call-with-input-file
- (parse-requires.txt test-requires-with-sections)))
+ (parse-requires.txt test-requires.txt)))
+(test-equal "parse-requires.txt - Beaker"
+ (list '() '("Mock" "coverage"))
+ (mock ((ice-9 ports) call-with-input-file
+ call-with-input-string)
+ (parse-requires.txt test-requires.txt-beaker)))
(test-equal "parse-wheel-metadata, with extras"
+ (list '("wrapt" "bar") '("tox" "bumpversion"))
(mock ((ice-9 ports) call-with-input-file
(parse-wheel-metadata test-metadata-with-extras)))
(test-equal "parse-wheel-metadata, with extras - Jedi"
+ (list '("parso") '("pytest"))
(mock ((ice-9 ports) call-with-input-file
(parse-wheel-metadata test-metadata-with-extras-jedi)))
-(test-assert "pypi->guix-package"
+(test-assert "pypi->guix-package, no wheel"
;; Replace network resources with sample data.
(mock ((guix import utils) url-fetch
@@ -195,7 +210,10 @@ Requires-Dist: pytest (>=3.1.0); extra == 'testing'
(("python-bar" ('unquote 'python-bar))
- ("python-baz" ('unquote 'python-baz)))))
+ ("python-foo" ('unquote 'python-foo)))))
+ (("python-pytest" ('unquote 'python-pytest)))))
('home-page "http://example.com")
@@ -216,25 +234,25 @@ Requires-Dist: pytest (>=3.1.0); extra == 'testing'
(mkdir-p "foo-1.0.0/foo.egg-info/")
(with-output-to-file "foo-1.0.0/foo.egg-info/requires.txt"
- (display "wrong data to make sure we're testing wheels ")))
+ (display "wrong data to make sure we're testing wheels ")))
(parameterize ((current-output-port (%make-void-port "rw+")))
(system* "tar" "czvf" file-name "foo-1.0.0/"))
- (delete-file-recursively "foo-1.0.0")
- (call-with-input-file file-name port-sha256))))
+ (delete-file-recursively "foo-1.0.0")
+ (call-with-input-file file-name port-sha256))))
("https://example.com/foo-1.0.0-py2.py3-none-any.whl"
- (mkdir "foo-1.0.0.dist-info")
- (with-output-to-file "foo-1.0.0.dist-info/METADATA"
- (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")
- (rename-file zip-file file-name))
- (delete-file-recursively "foo-1.0.0.dist-info")))
+ (mkdir "foo-1.0.0.dist-info")
+ (with-output-to-file "foo-1.0.0.dist-info/METADATA"
+ (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" "-q" zip-file "foo-1.0.0.dist-info/METADATA")
+ (rename-file zip-file file-name))
+ (delete-file-recursively "foo-1.0.0.dist-info")))
(_ (error "Unexpected URL: " url)))))
(mock ((guix http-client) http-fetch
@@ -262,6 +280,9 @@ Requires-Dist: pytest (>=3.1.0); extra == 'testing'
(("python-bar" ('unquote 'python-bar))
("python-baz" ('unquote 'python-baz)))))
+ (("python-pytest" ('unquote 'python-pytest)))))
('home-page "http://example.com")
@@ -272,4 +293,48 @@ Requires-Dist: pytest (>=3.1.0); extra == 'testing'
+(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)
+ ("https://example.com/foo-1.0.0.tar.gz"
+ (call-with-input-file file-name port-sha256))
+ ("https://example.com/foo-1.0.0-py2.py3-none-any.whl" #t)
+ (_ (error "Unexpected URL: " url)))))
+ (mock ((guix http-client) http-fetch
+ ("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")
+ ('uri ('pypi-uri "foo" 'version))
+ ('build-system 'python-build-system)
+ ('home-page "http://example.com")
+ ('description "summary")
+ ('license 'license:lgpl2.0))
+ (string=? (bytevector->nix-base32-string