unofficial mirror of guix-devel@gnu.org 
 help / color / mirror / code / Atom feed
From: Cyril Roelandt <tipecaml@gmail.com>
To: guix-devel@gnu.org
Subject: [PATCH] import: pypi: read requirements from wheels.
Date: Sat, 26 Mar 2016 02:45:50 +0100	[thread overview]
Message-ID: <1458956750-21917-1-git-send-email-tipecaml@gmail.com> (raw)
In-Reply-To: <87povdgq7h.fsf@gnu.org>

* 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.
---
 doc/guix.texi        |   4 +-
 guix/import/pypi.scm | 113 +++++++++++++++++++++++++++++++++++++++------------
 tests/pypi.scm       |  78 ++++++++++++++++++++++++++++++++++-
 3 files changed, 166 insertions(+), 29 deletions(-)

diff --git a/doc/guix.texi b/doc/guix.texi
index 08d7a08..918a8f5 100644
--- a/doc/guix.texi
+++ b/doc/guix.texi
@@ -4478,7 +4478,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 8ae4948..c7f8d33 100644
--- a/guix/import/pypi.scm
+++ b/guix/import/pypi.scm
@@ -72,6 +72,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."
@@ -89,6 +99,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."
@@ -98,10 +113,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
@@ -148,26 +163,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
@@ -176,13 +234,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."
@@ -207,7 +265,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)
@@ -226,11 +284,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 cf351a5..640f59f 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
-- 
2.6.2

  parent reply	other threads:[~2016-03-26  1:46 UTC|newest]

Thread overview: 11+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2016-01-21 22:08 [PATCH] import: pypi: read requirements from wheels Cyril Roelandt
2016-01-24 20:08 ` Ludovic Courtès
2016-01-24 20:26   ` Cyril Roelandt
2016-01-26 10:11     ` Ludovic Courtès
2016-02-27  2:49   ` Cyril Roelandt
2016-03-02  9:54     ` Ludovic Courtès
2016-03-25 23:24       ` Cyril Roelandt
2016-03-26  1:45       ` Cyril Roelandt [this message]
2016-05-06 20:27         ` Leo Famulari
2016-05-15 19:49           ` Ludovic Courtès
2016-06-14 20:14             ` Cyril Roelandt

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

  List information: https://guix.gnu.org/

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=1458956750-21917-1-git-send-email-tipecaml@gmail.com \
    --to=tipecaml@gmail.com \
    --cc=guix-devel@gnu.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
Code repositories for project(s) associated with this public inbox

	https://git.savannah.gnu.org/cgit/guix.git

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox;
as well as URLs for read-only IMAP folder(s) and NNTP newsgroup(s).