~ruther/guix-local

8bb3bb19c2cabdbea9ece2358c57809c2c4b1561 — Lars-Dominik Braun 1 year, 5 months ago f2b7e8f
import: pypi: Support extracting dependencies from pyproject.toml.

* guix/import/pypi.scm (guess-requirements): Support extracting
dependencies from pyproject.toml.
* tests/pypi.scm: ("pypi->guix-package, no requires.txt, but wheel."):
Renamed from "pypi->guix-package, wheels", remove requires.txt file,
because the current implementation cannot detect invalid files.
("pypi->guix-package, no usable requirement file, no wheel."): Renamed
from "pypi->guix-package, no usable requirement file.".
(test-pyproject.toml): New variable.
("pypi->guix-package, no wheel, no requires.txt, but pyproject.toml"):
New test.
("pypi->guix-package, no wheel, but requires.txt and pyproject.toml"):
Ditto.

Change-Id: Ib525750eb6ff4139a8209420042b28ae3c850764
Reviewed-by: Ludovic Courtès <ludo@gnu.org>
Signed-off-by: Sharlatan Hellseher <sharlatanus@gmail.com>
2 files changed, 152 insertions(+), 23 deletions(-)

M guix/import/pypi.scm
M tests/pypi.scm
M guix/import/pypi.scm => guix/import/pypi.scm +56 -18
@@ 57,6 57,7 @@
  #:use-module (guix import utils)
  #:use-module (guix import json)
  #:use-module (json)
  #:use-module (guix build toml)
  #:use-module (guix packages)
  #:use-module (guix upstream)
  #:use-module ((guix licenses) #:prefix license:)


@@ 386,7 387,42 @@ be extracted in a temporary directory."
       (if wheel-url
           (and (url-fetch wheel-url temp)
                (read-wheel-metadata temp))
           #f))))
           (list '() '())))))

  (define (guess-requirements-from-pyproject.toml dir)
    (let* ((pyproject.toml-files (find-files dir (lambda (abs-file-name _)
                                          (string-match "/pyproject.toml$"
                                          abs-file-name))))
          (pyproject.toml (match pyproject.toml-files
                            (()
                              (warning (G_ "Cannot guess requirements from \
pyproject.toml file, because it does not exist.~%"))
                              '())
                            (else (parse-toml-file (first pyproject.toml-files)))))
          (pyproject-build-requirements
           (or (recursive-assoc-ref pyproject.toml '("build-system" "requires")) '()))
          (pyproject-dependencies
           (or (recursive-assoc-ref pyproject.toml '("project" "dependencies")) '()))
          ;; This is more of a convention, since optional-dependencies is a table of arbitrary values.
          (pyproject-test-dependencies
           (or (recursive-assoc-ref pyproject.toml '("project" "optional-dependencies" "test")) '())))
      (if (null? pyproject.toml)
        #f
        (list (map specification->requirement-name pyproject-dependencies)
              (map specification->requirement-name
                   (append pyproject-build-requirements
                           pyproject-test-dependencies))))))

  (define (guess-requirements-from-requires.txt 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.~%"))
        #f)
       (else (parse-requires.txt (first requires.txt-files))))))

  (define (guess-requirements-from-source)
    ;; Return the package's requirements by guessing them from the source.


@@ 398,27 434,29 @@ be extracted in a temporary directory."
             (if (string=? "zip" (file-extension source-url))
                 (invoke "unzip" archive "-d" dir)
                 (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.~%"))
                (list '() '()))
               (else (parse-requires.txt (first requires.txt-files)))))))
               (list (guess-requirements-from-pyproject.toml dir)
                     (guess-requirements-from-requires.txt dir))))
        (begin
          (warning (G_ "Unsupported archive format; \
cannot determine package dependencies from source archive: ~a~%")
                   (basename source-url))
          (list '() '()))))

  ;; First, try to compute the requirements using the wheel, else, fallback to
  ;; reading the "requires.txt" from the egg-info directory from the source
  ;; archive.
  (or (guess-requirements-from-wheel)
      (guess-requirements-from-source)))
          (list #f #f))))

  (define (merge a b)
    "Given lists A and B with two iteams each, combine A1 and B1, as well as A2 and B2."
    (match (list a b)
      (((first-propagated first-native) (second-propagated second-native))
       (list (append first-propagated second-propagated) (append first-native second-native)))))

  ;; requires.txt and the metadata of a wheel contain redundant information,
  ;; so fetch only one of them, preferring requires.txt from the source
  ;; distribution, which we always fetch, since the source tarball also
  ;; contains pyproject.toml.
  (match (guess-requirements-from-source)
    ((from-pyproject.toml #f)
      (merge (or from-pyproject.toml '(() ())) (or (guess-requirements-from-wheel) '(() ()))))
    ((from-pyproject.toml from-requires.txt)
      (merge (or from-pyproject.toml '(() ())) from-requires.txt))))

(define (compute-inputs source-url wheel-url archive)
  "Given the SOURCE-URL and WHEEL-URL of an already downloaded ARCHIVE, return

M tests/pypi.scm => tests/pypi.scm +96 -5
@@ 112,6 112,20 @@ Mock
coverage
")

(define test-pyproject.toml "\
[build-system]
requires = [\"dummy-build-dep-a\", \"dummy-build-dep-b\"]

[project]
dependencies = [
  \"dummy-dep-a\",
  \"dummy-dep-b\",
]

[project.optional-dependencies]
test = [\"dummy-test-dep-a\", \"dummy-test-dep-b\"]
")

(define test-metadata "\
Classifier: Programming Language :: Python :: 3.7
Requires-Dist: baz ~= 3


@@ 325,13 339,90 @@ files specified by SPECS.  Return its file name."
        (x
         (pk 'fail x #f))))))

(test-assert "pypi->guix-package, no wheel, no requires.txt, but pyproject.toml"
  (let ((tarball (pypi-tarball
                  "foo-1.0.0"
                  `(("pyproject.toml" ,test-pyproject.toml))))
        (twice (lambda (lst) (append lst lst))))
    (with-pypi (twice `(("/foo-1.0.0.tar.gz" 200 ,(file-dump tarball))
                        ("/foo-1.0.0-py2.py3-none-any.whl" 404 "")
                        ("/foo/json" 200 ,(lambda (port)
                                            (display (foo-json) port)))))
      ;; 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 pyproject-build-system)
            (propagated-inputs (list python-dummy-dep-a python-dummy-dep-b))
            (native-inputs (list python-dummy-build-dep-a python-dummy-build-dep-b
                                 python-dummy-test-dep-a python-dummy-test-dep-b))
            (home-page "http://example.com")
            (synopsis "summary")
            (description "summary.")
            (license license:lgpl2.0))
         (and (string=? default-sha256/base32 hash)
              (equal? (pypi->guix-package "foo" #:version "1.0.0")
                      (pypi->guix-package "foo"))
              (guard (c ((error? c) #t))
                (pypi->guix-package "foo" #:version "42"))))
        (x
         (pk 'fail x #f))))))

(test-assert "pypi->guix-package, no wheel, but requires.txt and pyproject.toml"
  (let ((tarball (pypi-tarball
                  "foo-1.0.0"
                  `(("foo-1.0.0/pyproject.toml" ,test-pyproject.toml)
                    ("foo-1.0.0/bizarre.egg-info/requires.txt"
                     ,test-requires.txt))))
        (twice (lambda (lst) (append lst lst))))
    (with-pypi (twice `(("/foo-1.0.0.tar.gz" 200 ,(file-dump tarball))
                        ("/foo-1.0.0-py2.py3-none-any.whl" 404 "")
                        ("/foo/json" 200 ,(lambda (port)
                                            (display (foo-json) port)))))
      ;; 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 pyproject-build-system)
            ;; Information from requires.txt and pyproject.toml is combined.
            (propagated-inputs (list python-bar python-dummy-dep-a python-dummy-dep-b
                                     python-foo))
            (native-inputs (list python-dummy-build-dep-a python-dummy-build-dep-b
                                 python-dummy-test-dep-a python-dummy-test-dep-b
                                 python-pytest))
            (home-page "http://example.com")
            (synopsis "summary")
            (description "summary.")
            (license license:lgpl2.0))
         (and (string=? default-sha256/base32 hash)
              (equal? (pypi->guix-package "foo" #:version "1.0.0")
                      (pypi->guix-package "foo"))
              (guard (c ((error? c) #t))
                (pypi->guix-package "foo" #:version "42"))))
        (x
         (pk 'fail x #f))))))

(test-skip (if (which "zip") 0 1))
(test-assert "pypi->guix-package, wheels"
(test-assert "pypi->guix-package, no requires.txt, but wheel."
  (let ((tarball (pypi-tarball
                  "foo-1.0.0"
                  '(("foo-1.0.0/foo.egg-info/requires.txt"
                     "wrong data \
to make sure we're testing wheels"))))
                  '(("foo-1.0.0/foo.egg-info/.empty" ""))))
        (wheel (wheel-file "foo-1.0.0"
                           `(("METADATA" ,test-metadata)))))
    (with-pypi `(("/foo-1.0.0.tar.gz" 200 ,(file-dump tarball))


@@ 362,7 453,7 @@ to make sure we're testing wheels"))))
        (x
         (pk 'fail x #f))))))

(test-assert "pypi->guix-package, no usable requirement file."
(test-assert "pypi->guix-package, no usable requirement file, no wheel."
  (let ((tarball (pypi-tarball "foo-1.0.0"
                               '(("foo.egg-info/.empty" "")))))
    (with-pypi `(("/foo-1.0.0.tar.gz" 200 ,(file-dump tarball))