tests: pypi: Rewrite tests using a local HTTP server.

* guix/import/pypi.scm (%pypi-base-url): New variable.
(pypi-fetch): Use it.
* tests/pypi.scm (foo-json): Compute URLs relative to '%local-url'.
(test-json-1, test-json-2, test-source-hash): Remove.
(file-dump): New procedure.
(with-pypi): New macro.
("pypi->guix-package, no wheel")
("pypi->guix-package, wheels")
("pypi->guix-package, no usable requirement file.")
("pypi->guix-package, package name contains \"-\" followed by digits"):
Rewrite using 'with-pypi'.
This commit is contained in:
Ludovic Courtès 2023-05-17 12:09:40 +02:00
parent 09526da78f
commit d2f36abd02
No known key found for this signature in database
GPG key ID: 090B11993D9AEBB5
2 changed files with 160 additions and 202 deletions

View file

@ -55,7 +55,8 @@ (define-module (guix import pypi)
#:use-module (guix packages) #:use-module (guix packages)
#:use-module (guix upstream) #:use-module (guix upstream)
#:use-module ((guix licenses) #:prefix license:) #:use-module ((guix licenses) #:prefix license:)
#:export (parse-requires.txt #:export (%pypi-base-url
parse-requires.txt
parse-wheel-metadata parse-wheel-metadata
specification->requirement-name specification->requirement-name
guix-package->pypi-name guix-package->pypi-name
@ -67,6 +68,10 @@ (define-module (guix import pypi)
;; The PyPI API (notice the rhyme) is "documented" at: ;; The PyPI API (notice the rhyme) is "documented" at:
;; <https://warehouse.readthedocs.io/api-reference/json/>. ;; <https://warehouse.readthedocs.io/api-reference/json/>.
(define %pypi-base-url
;; Base URL of the PyPI API.
(make-parameter "https://pypi.org/pypi/"))
(define non-empty-string-or-false (define non-empty-string-or-false
(match-lambda (match-lambda
("" #f) ("" #f)
@ -123,7 +128,7 @@ (define-json-mapping <distribution> make-distribution distribution?
(define (pypi-fetch name) (define (pypi-fetch name)
"Return a <pypi-project> record for package NAME, or #f on failure." "Return a <pypi-project> record for package NAME, or #f on failure."
(and=> (json-fetch (string-append "https://pypi.org/pypi/" name "/json")) (and=> (json-fetch (string-append (%pypi-base-url) name "/json"))
json->pypi-project)) json->pypi-project))
;; For packages found on PyPI that lack a source distribution. ;; For packages found on PyPI that lack a source distribution.

View file

@ -27,10 +27,11 @@ (define-module (test-pypi)
#:use-module (guix utils) #:use-module (guix utils)
#:use-module (gcrypt hash) #:use-module (gcrypt hash)
#:use-module (guix tests) #:use-module (guix tests)
#:use-module (guix tests http)
#:use-module (guix build-system python) #:use-module (guix build-system python)
#:use-module ((guix build utils) #:use-module ((guix build utils)
#:select (delete-file-recursively #:select (delete-file-recursively
which mkdir-p which mkdir-p dump-port
with-directory-excursion)) with-directory-excursion))
#:use-module ((guix diagnostics) #:select (guix-warning-port)) #:use-module ((guix diagnostics) #:select (guix-warning-port))
#:use-module ((guix build syscalls) #:select (mkdtemp!)) #:use-module ((guix build syscalls) #:select (mkdtemp!))
@ -57,25 +58,19 @@ (define* (foo-json #:key (name "foo") (name-in-url #f))
(urls . #()) (urls . #())
(releases (releases
. ((1.0.0 . ((1.0.0
. #(((url . ,(format #f "https://example.com/~a-1.0.0.egg" . #(((url . ,(format #f "~a/~a-1.0.0.egg"
(%local-url #:path "")
(or name-in-url name))) (or name-in-url name)))
(packagetype . "bdist_egg")) (packagetype . "bdist_egg"))
((url . ,(format #f "https://example.com/~a-1.0.0.tar.gz" ((url . ,(format #f "~a/~a-1.0.0.tar.gz"
(%local-url #:path "")
(or name-in-url name))) (or name-in-url name)))
(packagetype . "sdist")) (packagetype . "sdist"))
((url . ,(format #f "https://example.com/~a-1.0.0-py2.py3-none-any.whl" ((url . ,(format #f "~a/~a-1.0.0-py2.py3-none-any.whl"
(%local-url #:path "")
(or name-in-url name))) (or name-in-url name)))
(packagetype . "bdist_wheel"))))))))) (packagetype . "bdist_wheel")))))))))
(define test-json-1
(foo-json))
(define test-json-2
(foo-json #:name "foo-99"))
(define test-source-hash
"")
(define test-specifications (define test-specifications
'("Fizzy [foo, bar]" '("Fizzy [foo, bar]"
"PickyThing<1.6,>1.9,!=1.9.6,<2.0a0,==2.4c1" "PickyThing<1.6,>1.9,!=1.9.6,<2.0a0,==2.4c1"
@ -187,6 +182,18 @@ (define (wheel-file name specs)
(delete-file-recursively directory) (delete-file-recursively directory)
whl-file)) whl-file))
(define (file-dump file)
"Return a procedure that dumps FILE to the given port."
(lambda (output)
(call-with-input-file file
(lambda (input)
(dump-port input output)))))
(define-syntax-rule (with-pypi responses body ...)
(with-http-server responses
(parameterize ((%pypi-base-url (%local-url #:path "/")))
body ...)))
(test-begin "pypi") (test-begin "pypi")
@ -275,200 +282,146 @@ (define (wheel-file name specs)
"https://files.pythonhosted.org/packages/f0/f00/goo-0.0.0.tar.gz")) "https://files.pythonhosted.org/packages/f0/f00/goo-0.0.0.tar.gz"))
(test-assert "pypi->guix-package, no wheel" (test-assert "pypi->guix-package, no wheel"
;; Replace network resources with sample data. (let ((tarball (pypi-tarball
(mock ((guix import utils) url-fetch "foo-1.0.0"
(lambda (url file-name) `(("src/bizarre.egg-info/requires.txt"
(match url ,test-requires.txt))))
("https://example.com/foo-1.0.0.tar.gz" (twice (lambda (lst) (append lst lst))))
;; Unusual requires.txt location should still be found. (with-pypi (twice `(("/foo-1.0.0.tar.gz" 200 ,(file-dump tarball))
(let ((tarball (pypi-tarball "foo-1.0.0" ("/foo-1.0.0-py2.py3-none-any.whl" 404 "")
`(("src/bizarre.egg-info/requires.txt" ("/foo/json" 200 ,(lambda (port)
,test-requires.txt))))) (display (foo-json) port)))))
(copy-file tarball file-name) (match (pypi->guix-package "foo")
(set! test-source-hash (('package
(call-with-input-file file-name port-sha256)))) ('name "python-foo")
("https://example.com/foo-1.0.0-py2.py3-none-any.whl" #f) ('version "1.0.0")
(_ (error "Unexpected URL: " url))))) ('source ('origin
(mock ((guix http-client) http-fetch ('method 'url-fetch)
(lambda (url . rest) ('uri ('pypi-uri "foo" 'version))
(match url ('sha256
("https://pypi.org/pypi/foo/json" ('base32
(values (open-input-string test-json-1) (? string? hash)))))
(string-length test-json-1))) ('build-system 'pyproject-build-system)
("https://example.com/foo-1.0.0-py2.py3-none-any.whl" #f) ('propagated-inputs ('list 'python-bar 'python-foo))
(_ (error "Unexpected URL: " url))))) ('native-inputs ('list 'python-pytest))
(match (pypi->guix-package "foo") ('home-page "http://example.com")
(('package ('synopsis "summary")
('name "python-foo") ('description "summary")
('version "1.0.0") ('license 'license:lgpl2.0))
('source ('origin (and (string=? (bytevector->nix-base32-string
('method 'url-fetch) (file-sha256 tarball))
('uri ('pypi-uri "foo" 'version)) hash)
('sha256 (equal? (pypi->guix-package "foo" #:version "1.0.0")
('base32 (pypi->guix-package "foo"))
(? string? hash))))) (guard (c ((error? c) #t))
('build-system 'pyproject-build-system) (pypi->guix-package "foo" #:version "42"))))
('propagated-inputs ('list 'python-bar 'python-foo)) (x
('native-inputs ('list 'python-pytest)) (pk 'fail x #f))))))
('home-page "http://example.com")
('synopsis "summary")
('description "summary")
('license 'license:lgpl2.0))
(and (string=? (bytevector->nix-base32-string
test-source-hash)
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-skip (if (which "zip") 0 1))
(test-assert "pypi->guix-package, wheels" (test-assert "pypi->guix-package, wheels"
;; Replace network resources with sample data. (let ((tarball (pypi-tarball
(mock ((guix import utils) url-fetch "foo-1.0.0"
(lambda (url file-name) '(("foo-1.0.0/foo.egg-info/requires.txt"
(match url "wrong data \
("https://example.com/foo-1.0.0.tar.gz" to make sure we're testing wheels"))))
(let ((tarball (pypi-tarball (wheel (wheel-file "foo-1.0.0"
"foo-1.0.0" `(("METADATA" ,test-metadata)))))
'(("foo-1.0.0/foo.egg-info/requires.txt" (with-pypi `(("/foo-1.0.0.tar.gz" 200 ,(file-dump tarball))
"wrong data \ ("/foo-1.0.0-py2.py3-none-any.whl"
to make sure we're testing wheels"))))) 200 ,(file-dump wheel))
(copy-file tarball file-name) ("/foo/json" 200 ,(lambda (port)
(set! test-source-hash (display (foo-json) port))))
(call-with-input-file file-name port-sha256)))) ;; Not clearing the memoization cache here would mean returning the value
("https://example.com/foo-1.0.0-py2.py3-none-any.whl" ;; computed in the previous test.
(let ((wheel (wheel-file "foo-1.0.0" (invalidate-memoization! pypi->guix-package)
`(("METADATA" ,test-metadata))))) (match (pypi->guix-package "foo")
(copy-file wheel file-name))) (('package
(_ (error "Unexpected URL: " url))))) ('name "python-foo")
(mock ((guix http-client) http-fetch ('version "1.0.0")
(lambda (url . rest) ('source ('origin
(match url ('method 'url-fetch)
("https://pypi.org/pypi/foo/json" ('uri ('pypi-uri "foo" 'version))
(values (open-input-string test-json-1) ('sha256
(string-length test-json-1))) ('base32
("https://example.com/foo-1.0.0-py2.py3-none-any.whl" #f) (? string? hash)))))
(_ (error "Unexpected URL: " url))))) ('build-system 'pyproject-build-system)
;; Not clearing the memoization cache here would mean returning the value ('propagated-inputs ('list 'python-bar 'python-baz))
;; computed in the previous test. ('native-inputs ('list 'python-pytest))
(invalidate-memoization! pypi->guix-package) ('home-page "http://example.com")
(match (pypi->guix-package "foo") ('synopsis "summary")
(('package ('description "summary")
('name "python-foo") ('license 'license:lgpl2.0))
('version "1.0.0") (string=? (bytevector->nix-base32-string (file-sha256 tarball))
('source ('origin hash))
('method 'url-fetch) (x
('uri ('pypi-uri "foo" 'version)) (pk 'fail x #f))))))
('sha256
('base32
(? string? hash)))))
('build-system 'pyproject-build-system)
('propagated-inputs ('list 'python-bar 'python-baz))
('native-inputs ('list 'python-pytest))
('home-page "http://example.com")
('synopsis "summary")
('description "summary")
('license 'license:lgpl2.0))
(string=? (bytevector->nix-base32-string
test-source-hash)
hash))
(x
(pk 'fail x #f))))))
(test-assert "pypi->guix-package, no usable requirement file." (test-assert "pypi->guix-package, no usable requirement file."
;; Replace network resources with sample data. (let ((tarball (pypi-tarball "foo-1.0.0"
(mock ((guix import utils) url-fetch '(("foo.egg-info/.empty" "")))))
(lambda (url file-name) (with-pypi `(("/foo-1.0.0.tar.gz" 200 ,(file-dump tarball))
(match url ("/foo-1.0.0-py2.py3-none-any.whl" 404 "")
("https://example.com/foo-1.0.0.tar.gz" ("/foo/json" 200 ,(lambda (port)
(let ((tarball (pypi-tarball "foo-1.0.0" (display (foo-json) port))))
'(("foo.egg-info/.empty" ""))))) ;; Not clearing the memoization cache here would mean returning the
(copy-file tarball file-name) ;; value computed in the previous test.
(set! test-source-hash (invalidate-memoization! pypi->guix-package)
(call-with-input-file file-name port-sha256)))) (match (pypi->guix-package "foo")
("https://example.com/foo-1.0.0-py2.py3-none-any.whl" #f) (('package
(_ (error "Unexpected URL: " url))))) ('name "python-foo")
(mock ((guix http-client) http-fetch ('version "1.0.0")
(lambda (url . rest) ('source ('origin
(match url ('method 'url-fetch)
("https://pypi.org/pypi/foo/json" ('uri ('pypi-uri "foo" 'version))
(values (open-input-string test-json-1) ('sha256
(string-length test-json-1))) ('base32
("https://example.com/foo-1.0.0-py2.py3-none-any.whl" #f) (? string? hash)))))
(_ (error "Unexpected URL: " url))))) ('build-system 'pyproject-build-system)
;; Not clearing the memoization cache here would mean returning the value ('home-page "http://example.com")
;; computed in the previous test. ('synopsis "summary")
(invalidate-memoization! pypi->guix-package) ('description "summary")
(match (pypi->guix-package "foo") ('license 'license:lgpl2.0))
(('package (string=? (bytevector->nix-base32-string (file-sha256 tarball))
('name "python-foo") hash))
('version "1.0.0") (x
('source ('origin (pk 'fail x #f))))))
('method 'url-fetch)
('uri ('pypi-uri "foo" 'version))
('sha256
('base32
(? string? hash)))))
('build-system 'pyproject-build-system)
('home-page "http://example.com")
('synopsis "summary")
('description "summary")
('license 'license:lgpl2.0))
(string=? (bytevector->nix-base32-string
test-source-hash)
hash))
(x
(pk 'fail x #f))))))
(test-assert "pypi->guix-package, package name contains \"-\" followed by digits" (test-assert "pypi->guix-package, package name contains \"-\" followed by digits"
;; Replace network resources with sample data. (let ((tarball (pypi-tarball "foo-99-1.0.0"
(mock ((guix import utils) url-fetch `(("src/bizarre.egg-info/requires.txt"
(lambda (url file-name) ,test-requires.txt)))))
(match url (with-pypi `(("/foo-99-1.0.0.tar.gz" 200 ,(file-dump tarball))
("https://example.com/foo-99-1.0.0.tar.gz" ("/foo-99-1.0.0-py2.py3-none-any.whl" 404 "")
(let ((tarball (pypi-tarball "foo-99-1.0.0" ("/foo-99/json" 200 ,(lambda (port)
`(("src/bizarre.egg-info/requires.txt" (display (foo-json #:name "foo-99")
,test-requires.txt))))) port))))
;; Unusual requires.txt location should still be found. (match (pypi->guix-package "foo-99")
(copy-file tarball file-name) (('package
(set! test-source-hash ('name "python-foo-99")
(call-with-input-file file-name port-sha256)))) ('version "1.0.0")
("https://example.com/foo-99-1.0.0-py2.py3-none-any.whl" #f) ('source ('origin
(_ (error "Unexpected URL: " url))))) ('method 'url-fetch)
(mock ((guix http-client) http-fetch ('uri ('pypi-uri "foo-99" 'version))
(lambda (url . rest) ('sha256
(match url ('base32
("https://pypi.org/pypi/foo-99/json" (? string? hash)))))
(values (open-input-string test-json-2) ('properties ('quote (("upstream-name" . "foo-99"))))
(string-length test-json-2))) ('build-system 'pyproject-build-system)
("https://example.com/foo-99-1.0.0-py2.py3-none-any.whl" #f) ('propagated-inputs ('list 'python-bar 'python-foo))
(_ (error "Unexpected URL: " url))))) ('native-inputs ('list 'python-pytest))
(match (pypi->guix-package "foo-99") ('home-page "http://example.com")
(('package ('synopsis "summary")
('name "python-foo-99") ('description "summary")
('version "1.0.0") ('license 'license:lgpl2.0))
('source ('origin (string=? (bytevector->nix-base32-string (file-sha256 tarball))
('method 'url-fetch) hash))
('uri ('pypi-uri "foo-99" 'version)) (x
('sha256 (pk 'fail x #f))))))
('base32
(? string? hash)))))
('properties ('quote (("upstream-name" . "foo-99"))))
('build-system 'pyproject-build-system)
('propagated-inputs ('list 'python-bar 'python-foo))
('native-inputs ('list 'python-pytest))
('home-page "http://example.com")
('synopsis "summary")
('description "summary")
('license 'license:lgpl2.0))
(string=? (bytevector->nix-base32-string
test-source-hash)
hash))
(x
(pk 'fail x #f))))))
(test-end "pypi") (test-end "pypi")
(delete-file-recursively sample-directory) (delete-file-recursively sample-directory)
;; Local Variables:
;; eval: (put 'with-pypi 'scheme-indent-function 1)
;; End: