gnu: guix: Update snapshot.
[guix.git] / guix / import / pypi.scm
blobbb0db1ba85feec858c214cb0c72648c50e6a74f8
1 ;;; GNU Guix --- Functional package management for GNU
2 ;;; Copyright © 2014 David Thompson <davet@gnu.org>
3 ;;; Copyright © 2015 Cyril Roelandt <tipecaml@gmail.com>
4 ;;; Copyright © 2015, 2016, 2017 Ludovic Courtès <ludo@gnu.org>
5 ;;; Copyright © 2017 Mathieu Othacehe <m.othacehe@gmail.com>
6 ;;;
7 ;;; This file is part of GNU Guix.
8 ;;;
9 ;;; GNU Guix is free software; you can redistribute it and/or modify it
10 ;;; under the terms of the GNU General Public License as published by
11 ;;; the Free Software Foundation; either version 3 of the License, or (at
12 ;;; your option) any later version.
13 ;;;
14 ;;; GNU Guix is distributed in the hope that it will be useful, but
15 ;;; WITHOUT ANY WARRANTY; without even the implied warranty of
16 ;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 ;;; GNU General Public License for more details.
18 ;;;
19 ;;; You should have received a copy of the GNU General Public License
20 ;;; along with GNU Guix.  If not, see <http://www.gnu.org/licenses/>.
22 (define-module (guix import pypi)
23   #:use-module (ice-9 binary-ports)
24   #:use-module (ice-9 match)
25   #:use-module (ice-9 pretty-print)
26   #:use-module (ice-9 regex)
27   #:use-module ((ice-9 rdelim) #:select (read-line))
28   #:use-module (srfi srfi-1)
29   #:use-module (srfi srfi-26)
30   #:use-module (srfi srfi-34)
31   #:use-module (srfi srfi-35)
32   #:use-module (rnrs bytevectors)
33   #:use-module (json)
34   #:use-module (web uri)
35   #:use-module (guix ui)
36   #:use-module (guix utils)
37   #:use-module ((guix build utils)
38                 #:select ((package-name->name+version
39                            . hyphen-package-name->name+version)))
40   #:use-module (guix import utils)
41   #:use-module ((guix download) #:prefix download:)
42   #:use-module (guix import json)
43   #:use-module (guix packages)
44   #:use-module (guix upstream)
45   #:use-module ((guix licenses) #:prefix license:)
46   #:use-module (guix build-system python)
47   #:export (guix-package->pypi-name
48             pypi->guix-package
49             %pypi-updater))
51 (define (pypi-fetch name)
52   "Return an alist representation of the PyPI metadata for the package NAME,
53 or #f on failure."
54   (json-fetch (string-append "https://pypi.python.org/pypi/"
55                              name "/json")))
57 ;; For packages found on PyPI that lack a source distribution.
58 (define-condition-type &missing-source-error &error
59   missing-source-error?
60   (package  missing-source-error-package))
62 (define (latest-source-release pypi-package)
63   "Return the latest source release for PYPI-PACKAGE."
64   (let ((releases (assoc-ref* pypi-package "releases"
65                               (assoc-ref* pypi-package "info" "version"))))
66     (or (find (lambda (release)
67                 (string=? "sdist" (assoc-ref release "packagetype")))
68               releases)
69         (raise (condition (&missing-source-error
70                            (package pypi-package)))))))
72 (define (latest-wheel-release pypi-package)
73   "Return the url of the wheel for the latest release of pypi-package,
74 or #f if there isn't any."
75   (let ((releases (assoc-ref* pypi-package "releases"
76                               (assoc-ref* pypi-package "info" "version"))))
77     (or (find (lambda (release)
78                 (string=? "bdist_wheel" (assoc-ref release "packagetype")))
79               releases)
80         #f)))
82 (define (python->package-name name)
83   "Given the NAME of a package on PyPI, return a Guix-compliant name for the
84 package."
85   (if (string-prefix? "python-" name)
86       (snake-case name)
87       (string-append "python-" (snake-case name))))
89 (define (guix-package->pypi-name package)
90   "Given a Python PACKAGE built from pypi.python.org, return the name of the
91 package on PyPI."
92   (define (url->pypi-name url)
93     (hyphen-package-name->name+version
94      (basename (file-sans-extension url))))
96   (match (and=> (package-source package) origin-uri)
97     ((? string? url)
98      (url->pypi-name url))
99     ((lst ...)
100      (any url->pypi-name lst))
101     (#f #f)))
103 (define (wheel-url->extracted-directory wheel-url)
104   (match (string-split (basename wheel-url) #\-)
105     ((name version _ ...)
106      (string-append name "-" version ".dist-info"))))
108 (define (maybe-inputs package-inputs)
109   "Given a list of PACKAGE-INPUTS, tries to generate the 'inputs' field of a
110 package definition."
111   (match package-inputs
112     (()
113      '())
114     ((package-inputs ...)
115      `((propagated-inputs (,'quasiquote ,package-inputs))))))
117 (define (guess-requirements source-url wheel-url tarball)
118   "Given SOURCE-URL, WHEEL-URL and a TARBALL of the package, return a list of
119 the required packages specified in the requirements.txt file. TARBALL will be
120 extracted in the current directory, and will be deleted."
122   (define (tarball-directory url)
123     ;; Given the URL of the package's tarball, return the name of the directory
124     ;; that will be created upon decompressing it. If the filetype is not
125     ;; supported, return #f.
126     ;; TODO: Support more archive formats.
127     (let ((basename (substring url (+ 1 (string-rindex url #\/)))))
128       (cond
129        ((string-suffix? ".tar.gz" basename)
130         (string-drop-right basename 7))
131        ((string-suffix? ".tar.bz2" basename)
132         (string-drop-right basename 8))
133        (else
134         (begin
135           (warning (G_ "Unsupported archive format: \
136 cannot determine package dependencies"))
137           #f)))))
139   (define (clean-requirement s)
140     ;; Given a requirement LINE, as can be found in a Python requirements.txt
141     ;; file, remove everything other than the actual name of the required
142     ;; package, and return it.
143     (string-take s
144      (or (string-index s #\space)
145          (string-length s))))
147   (define (comment? line)
148     ;; Return #t if the given LINE is a comment, #f otherwise.
149     (eq? (string-ref (string-trim line) 0) #\#))
151   (define (read-requirements requirements-file)
152     ;; Given REQUIREMENTS-FILE, a Python requirements.txt file, return a list
153     ;; of name/variable pairs describing the requirements.
154     (call-with-input-file requirements-file
155       (lambda (port)
156         (let loop ((result '()))
157           (let ((line (read-line port)))
158             (if (eof-object? line)
159                 result
160                 (cond
161                  ((or (string-null? line) (comment? line))
162                   (loop result))
163                  (else
164                   (loop (cons (python->package-name (clean-requirement line))
165                               result))))))))))
167   (define (read-wheel-metadata wheel-archive)
168     ;; Given WHEEL-ARCHIVE, a ZIP Python wheel archive, return the package's
169     ;; requirements.
170     (let* ((dirname (wheel-url->extracted-directory wheel-url))
171            (json-file (string-append dirname "/metadata.json")))
172       (and (zero? (system* "unzip" "-q" wheel-archive json-file))
173            (dynamic-wind
174              (const #t)
175              (lambda ()
176                (call-with-input-file json-file
177                  (lambda (port)
178                    (let* ((metadata (json->scm port))
179                           (run_requires (hash-ref metadata "run_requires"))
180                           (requirements (if run_requires
181                                             (hash-ref (list-ref run_requires 0)
182                                                        "requires")
183                                             '())))
184                      (map (lambda (r)
185                             (python->package-name (clean-requirement r)))
186                           requirements)))))
187              (lambda ()
188                (delete-file json-file)
189                (rmdir dirname))))))
191   (define (guess-requirements-from-wheel)
192     ;; Return the package's requirements using the wheel, or #f if an error
193     ;; occurs.
194     (call-with-temporary-output-file
195      (lambda (temp port)
196        (if wheel-url
197          (and (url-fetch wheel-url temp)
198               (read-wheel-metadata temp))
199          #f))))
202   (define (guess-requirements-from-source)
203     ;; Return the package's requirements by guessing them from the source.
204     (let ((dirname (tarball-directory source-url)))
205       (if (string? dirname)
206           (let* ((req-file (string-append dirname "/requirements.txt"))
207                  (exit-code (system* "tar" "xf" tarball req-file)))
208             ;; TODO: support more formats.
209             (if (zero? exit-code)
210                 (dynamic-wind
211                   (const #t)
212                   (lambda ()
213                     (read-requirements req-file))
214                   (lambda ()
215                     (delete-file req-file)
216                     (rmdir dirname)))
217                 (begin
218                   (warning (G_ "'tar xf' failed with exit code ~a\n")
219                            exit-code)
220                   '())))
221           '())))
223   ;; First, try to compute the requirements using the wheel, since that is the
224   ;; most reliable option. If a wheel is not provided for this package, try
225   ;; getting them by reading the "requirements.txt" file from the source. Note
226   ;; that "requirements.txt" is not mandatory, so this is likely to fail.
227   (or (guess-requirements-from-wheel)
228       (guess-requirements-from-source)))
231 (define (compute-inputs source-url wheel-url tarball)
232   "Given the SOURCE-URL of an already downloaded TARBALL, return a list of
233 name/variable pairs describing the required inputs of this package."
234   (sort
235     (map (lambda (input)
236            (list input (list 'unquote (string->symbol input))))
237          (remove (cut string=? "python-argparse" <>)
238                  (guess-requirements source-url wheel-url tarball)))
239     (lambda args
240       (match args
241         (((a _ ...) (b _ ...))
242          (string-ci<? a b))))))
244 (define (make-pypi-sexp name version source-url wheel-url home-page synopsis
245                         description license)
246   "Return the `package' s-expression for a python package with the given NAME,
247 VERSION, SOURCE-URL, HOME-PAGE, SYNOPSIS, DESCRIPTION, and LICENSE."
248   (call-with-temporary-output-file
249    (lambda (temp port)
250      (and (url-fetch source-url temp)
251           `(package
252              (name ,(python->package-name name))
253              (version ,version)
254              (source (origin
255                        (method url-fetch)
257                        ;; Sometimes 'pypi-uri' doesn't quite work due to mixed
258                        ;; cases in NAME, for instance, as is the case with
259                        ;; "uwsgi".  In that case, fall back to a full URL.
260                        (uri (pypi-uri ,(string-downcase name) version))
261                        (sha256
262                         (base32
263                          ,(guix-hash-url temp)))))
264              (build-system python-build-system)
265              ,@(maybe-inputs (compute-inputs source-url wheel-url temp))
266              (home-page ,home-page)
267              (synopsis ,synopsis)
268              (description ,description)
269              (license ,(license->symbol license)))))))
271 (define (pypi->guix-package package-name)
272   "Fetch the metadata for PACKAGE-NAME from pypi.python.org, and return the
273 `package' s-expression corresponding to that package, or #f on failure."
274   (let ((package (pypi-fetch package-name)))
275     (and package
276          (guard (c ((missing-source-error? c)
277                     (let ((package (missing-source-error-package c)))
278                       (leave (G_ "no source release for pypi package ~a ~a~%")
279                              (assoc-ref* package "info" "name")
280                              (assoc-ref* package "info" "version")))))
281            (let ((name (assoc-ref* package "info" "name"))
282                  (version (assoc-ref* package "info" "version"))
283                  (release (assoc-ref (latest-source-release package) "url"))
284                  (wheel (assoc-ref (latest-wheel-release package) "url"))
285                  (synopsis (assoc-ref* package "info" "summary"))
286                  (description (assoc-ref* package "info" "summary"))
287                  (home-page (assoc-ref* package "info" "home_page"))
288                  (license (string->license (assoc-ref* package "info" "license"))))
289              (make-pypi-sexp name version release wheel home-page synopsis
290                              description license))))))
292 (define (string->license str)
293   "Convert the string STR into a license object."
294   (match str
295     ("GNU LGPL" license:lgpl2.0)
296     ("GPL" license:gpl3)
297     ((or "BSD" "BSD License") license:bsd-3)
298     ((or "MIT" "MIT license" "Expat license") license:expat)
299     ("Public domain" license:public-domain)
300     ((or "Apache License, Version 2.0" "Apache 2.0") license:asl2.0)
301     (_ #f)))
303 (define (pypi-package? package)
304   "Return true if PACKAGE is a Python package from PyPI."
306   (define (pypi-url? url)
307     (or (string-prefix? "https://pypi.python.org/" url)
308         (string-prefix? "https://pypi.io/packages" url)))
310   (let ((source-url (and=> (package-source package) origin-uri))
311         (fetch-method (and=> (package-source package) origin-method)))
312     (and (eq? fetch-method download:url-fetch)
313          (match source-url
314            ((? string?)
315             (pypi-url? source-url))
316            ((source-url ...)
317             (any pypi-url? source-url))))))
319 (define (latest-release package)
320   "Return an <upstream-source> for the latest release of PACKAGE."
321   (let* ((pypi-name    (guix-package->pypi-name package))
322          (pypi-package (pypi-fetch pypi-name)))
323     (and pypi-package
324          (guard (c ((missing-source-error? c) #f))
325            (let* ((metadata pypi-package)
326                   (version (assoc-ref* metadata "info" "version"))
327                   (url (assoc-ref (latest-source-release metadata) "url")))
328              (upstream-source
329               (package (package-name package))
330               (version version)
331               (urls (list url))))))))
333 (define %pypi-updater
334   (upstream-updater
335    (name 'pypi)
336    (description "Updater for PyPI packages")
337    (pred pypi-package?)
338    (latest latest-release)))