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>
This commit is contained in:
Lars-Dominik Braun 2024-12-15 13:22:00 +01:00 committed by Sharlatan Hellseher
parent f2b7e8f762
commit 8bb3bb19c2
No known key found for this signature in database
GPG key ID: 76D727BFF62CD2B5
2 changed files with 152 additions and 23 deletions

View file

@ -57,6 +57,7 @@ (define-module (guix import pypi)
#: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 @@ (define (guess-requirements-from-wheel)
(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 @@ (define (guess-requirements-from-source)
(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 '() '()))))
(list #f #f))))
;; 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)))
(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

View file

@ -112,6 +112,20 @@ (define test-requires.txt-beaker "\
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 @@ (define-syntax-rule (with-pypi responses body ...)
(x
(pk 'fail x #f))))))
(test-skip (if (which "zip") 0 1))
(test-assert "pypi->guix-package, wheels"
(test-assert "pypi->guix-package, no wheel, no requires.txt, but pyproject.toml"
(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"))))
`(("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, no requires.txt, but wheel."
(let ((tarball (pypi-tarball
"foo-1.0.0"
'(("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 @@ (define-syntax-rule (with-pypi responses body ...)
(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))