From mboxrd@z Thu Jan 1 00:00:00 1970 From: T460s laptop Subject: bug#24450: [PATCHv2] bug#24450: pypi importer outputs strange character series in optional dependency case. Date: Fri, 29 Mar 2019 22:12:38 -0400 Message-ID: <877ech5cvd.fsf_-_@kwak.i-did-not-set--mail-host-address--so-tickle-me> References: <87h99fipj1.fsf@we.make.ritual.n0.is> <87tvfm1eos.fsf@gmail.com> Mime-Version: 1.0 Content-Type: multipart/signed; boundary="==-=-="; micalg=pgp-sha256; protocol="application/pgp-signature" Return-path: Received: from eggs.gnu.org ([209.51.188.92]:42134) by lists.gnu.org with esmtp (Exim 4.71) (envelope-from ) id 1hA3UX-0005NL-RZ for bug-guix@gnu.org; Fri, 29 Mar 2019 22:13:09 -0400 Received: from Debian-exim by eggs.gnu.org with spam-scanned (Exim 4.71) (envelope-from ) id 1hA3UV-0004Oy-8g for bug-guix@gnu.org; Fri, 29 Mar 2019 22:13:05 -0400 Received: from debbugs.gnu.org ([209.51.188.43]:50787) by eggs.gnu.org with esmtps (TLS1.0:RSA_AES_128_CBC_SHA1:16) (Exim 4.71) (envelope-from ) id 1hA3UU-0004Ok-PN for bug-guix@gnu.org; Fri, 29 Mar 2019 22:13:03 -0400 Received: from Debian-debbugs by debbugs.gnu.org with local (Exim 4.84_2) (envelope-from ) id 1hA3UU-0001pW-IU for bug-guix@gnu.org; Fri, 29 Mar 2019 22:13:02 -0400 Sender: "Debbugs-submit" Resent-Message-ID: In-Reply-To: <87tvfm1eos.fsf@gmail.com> (Maxim Cournoyer's message of "Fri, 29 Mar 2019 00:34:43 -0400") List-Id: Bug reports for GNU Guix List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Errors-To: bug-guix-bounces+gcggb-bug-guix=m.gmane.org@gnu.org Sender: "bug-Guix" To: Maxim Cournoyer Cc: ng0 , 24450@debbugs.gnu.org --==-=-= Content-Type: multipart/mixed; boundary="=-=-=" --=-=-= Content-Type: text/plain The previous 0007 patch had broken the recursive importer. This reworked version fixes this. The rest of the patches stack sent in the previous message is still good as is. --=-=-= Content-Type: text/x-patch; charset=utf-8 Content-Disposition: attachment; filename=0007-import-pypi-Include-optional-test-inputs-as-native-i.patch Content-Transfer-Encoding: quoted-printable From=2037e499d5d5d5f690aa0a065c730e13f6a31dd30d Mon Sep 17 00:00:00 2001 From: Maxim Cournoyer Date: Thu, 28 Mar 2019 23:12:26 -0400 Subject: [PATCH] import: pypi: Include optional test inputs as native-input= s. * guix/import/pypi.scm (maybe-inputs): Add INPUT-TYPE argument, and use it. (test-section?): New predicate. (parse-requires.txt): Collect the optional test inputs, and return them as = the second element of the returned list. (parse-wheel-metadata): Likewise. (guess-requirements): Adapt, and hide unzip output. (make-pypi-sexp): Likewise, and include the test inputs requirements as nat= ive inputs in the returned package expression. * tests/pypi.scm (test-requires.txt): Include a test section in the test-requires.txt data. (test-requires.txt-beaker): New variable. ("parse-requires.txt"): Adapt. ("parse-requires.txt - Beaker"): New test. ("parse-wheel-metadata, with extras"): Adapt. ("parse-wheel-metadata, with extras - Jedi"): Adapt. ("pypi->guix-package, no wheel"): Re-indent, and add the expected native-inputs. ("pypi->guix-package, wheels"): Likewise. ("pypi->guix-package, no usable requirement file."): New test. =2D-- guix/import/pypi.scm | 195 ++++++++++++++++++++++++++++--------------- tests/pypi.scm | 123 ++++++++++++++++++++------- 2 files changed, 222 insertions(+), 96 deletions(-) diff --git a/guix/import/pypi.scm b/guix/import/pypi.scm index c520213b6a..099768f0c8 100644 =2D-- a/guix/import/pypi.scm +++ b/guix/import/pypi.scm @@ -4,6 +4,7 @@ ;;; Copyright =C2=A9 2015, 2016, 2017 Ludovic Court=C3=A8s ;;; Copyright =C2=A9 2017 Mathieu Othacehe ;;; Copyright =C2=A9 2018 Ricardo Wurmus +;;; Copyright =C2=A9 2019 Maxim Cournoyer ;;; ;;; This file is part of GNU Guix. ;;; @@ -26,6 +27,7 @@ #:use-module (ice-9 receive) #:use-module ((ice-9 rdelim) #:select (read-line)) #:use-module (srfi srfi-1) + #:use-module (srfi srfi-11) #:use-module (srfi srfi-26) #:use-module (srfi srfi-34) #:use-module (srfi srfi-35) @@ -106,14 +108,15 @@ package on PyPI." ((name version _ ...) (string-append name "-" version ".dist-info")))) =20 =2D(define (maybe-inputs package-inputs) +(define (maybe-inputs package-inputs input-type) "Given a list of PACKAGE-INPUTS, tries to generate the 'inputs' field of= a =2Dpackage definition." +package definition. INPUT-TYPE, a symbol, is used to populate the name of +the input field." (match package-inputs (() '()) ((package-inputs ...) =2D `((propagated-inputs (,'quasiquote ,package-inputs)))))) + `((,input-type (,'quasiquote ,package-inputs)))))) =20 (define %requirement-name-regexp ;; Regexp to match the requirement name in a requirement specification. @@ -147,11 +150,21 @@ package definition." (or (regexp-exec %requirement-name-regexp spec) (error (G_ "Could not extract requirement name in spec:") spec)))) =20 +(define (test-section? name) + "Return #t if the section name contains 'test' or 'dev'." + (any (cut string-contains-ci name <>) + '("test" "dev"))) + (define (parse-requires.txt requires.txt) =2D "Given REQUIRES.TXT, a Setuptools requires.txt file, return a list of =2Drequirement names." =2D ;; This is a very incomplete parser, which job is to select the non-op= tional =2D ;; dependencies and strip them out of any version information. + "Given REQUIRES.TXT, a Setuptools requires.txt file, return a pair of re= quirements. + +The first element of the pair contains the required dependencies while the +second the optional test dependencies. Note that currently, optional, +non-test dependencies are omitted since these can be difficult or expensiv= e to +satisfy." + + ;; This is a very incomplete parser, which job is to read in the require= ment + ;; specification lines, and strip them out of any version information. ;; Alternatively, we could implement a PEG parser with the (ice-9 peg) ;; library and the requirements grammar defined by PEP-0508 ;; (https://www.python.org/dev/peps/pep-0508/). @@ -168,57 +181,89 @@ requirement names." =20 (call-with-input-file requires.txt (lambda (port) =2D (let loop ((result '())) + (let loop ((required-deps '()) + (test-deps '()) + (inside-test-section? #f) + (optional? #f)) (let ((line (read-line port))) =2D ;; Stop when a section is encountered, as sections contains op= tional =2D ;; (extra) requirements. Non-optional requirements must appear =2D ;; before any section is defined. =2D (if (or (eof-object? line) (section-header? line)) + (if (eof-object? line) ;; Duplicates can occur, since the same requirement can be ;; listed multiple times with different conditional markers,= e.g. ;; pytest >=3D 3 ; python_version >=3D "3.3" ;; pytest < 3 ; python_version < "3.3" =2D (reverse (delete-duplicates result)) + (map (compose reverse delete-duplicates) + (list required-deps test-deps)) (cond ((or (string-null? line) (comment? line)) =2D (loop result)) =2D (else + (loop required-deps test-deps inside-test-section? optiona= l?)) + ((section-header? line) + ;; Encountering a section means that all the requirements + ;; listed below are optional. Since we want to pick only t= he + ;; test dependencies from the optional dependencies, we mu= st + ;; track those separately. + (loop required-deps test-deps (test-section? line) #t)) + (inside-test-section? + (loop required-deps + (cons (specification->requirement-name line) + test-deps) + inside-test-section? optional?)) + ((not optional?) (loop (cons (specification->requirement-name line) =2D result)))))))))) + required-deps) + test-deps inside-test-section? optional?)) + (optional? + ;; Skip optional items. + (loop required-deps test-deps inside-test-section? optiona= l?)) + (else + (warning (G_ "parse-requires.txt reached an unexpected \ +condition on line ~a~%") line))))))))) =20 (define (parse-wheel-metadata metadata) =2D "Given METADATA, a Wheel metadata file, return a list of requirement n= ames." + "Given METADATA, a Wheel metadata file, return a pair of requirements. + +The first element of the pair contains the required dependencies while the= second the optional +test dependencies. Note that currently, optional, non-test dependencies a= re +omitted since these can be difficult or expensive to satisfy." ;; METADATA is a RFC-2822-like, header based file. =20 (define (requires-dist-header? line) ;; Return #t if the given LINE is a Requires-Dist header. =2D (regexp-match? (string-match "^Requires-Dist: " line))) + (string-match "^Requires-Dist: " line)) =20 (define (requires-dist-value line) (string-drop line (string-length "Requires-Dist: "))) =20 (define (extra? line) ;; Return #t if the given LINE is an "extra" requirement. =2D (regexp-match? (string-match "extra =3D=3D " line))) + (string-match "extra =3D=3D '(.*)'" line)) + + (define (test-requirement? line) + (let ((extra-label (match:substring (extra? line) 1))) + (and extra-label (test-section? extra-label)))) =20 (call-with-input-file metadata (lambda (port) =2D (let loop ((requirements '())) + (let loop ((required-deps '()) + (test-deps '())) (let ((line (read-line port))) =2D ;; Stop at the first 'Provides-Extra' section: the non-optional =2D ;; requirements appear before the optional ones. (if (eof-object? line) =2D (reverse (delete-duplicates requirements)) + (map (compose reverse delete-duplicates) + (list required-deps test-deps)) (cond ((and (requires-dist-header? line) (not (extra? line))) (loop (cons (specification->requirement-name (requires-dist-value line)) =2D requirements))) + required-deps) + test-deps)) + ((and (requires-dist-header? line) (test-requirement? line)) + (loop required-deps + (cons (specification->requirement-name (requires-dis= t-value line)) + test-deps))) (else =2D (loop requirements))))))))) + (loop required-deps test-deps))))))))) ;skip line =20 (define (guess-requirements source-url wheel-url archive) =2D "Given SOURCE-URL, WHEEL-URL and a ARCHIVE of the package, return a li= st + "Given SOURCE-URL, WHEEL-URL and an ARCHIVE of the package, return a list of the required packages specified in the requirements.txt file. ARCHIVE = will be extracted in a temporary directory." =20 @@ -244,7 +289,10 @@ cannot determine package dependencies") (file-extensio= n url)) (metadata (string-append dirname "/METADATA"))) (call-with-temporary-directory (lambda (dir) =2D (if (zero? (system* "unzip" "-q" wheel-archive "-d" dir metadat= a)) + (if (zero? + (parameterize ((current-error-port (%make-void-port "rw+")) + (current-output-port (%make-void-port "rw+"))) + (system* "unzip" wheel-archive "-d" dir metadata))) (parse-wheel-metadata (string-append dir "/" metadata)) (begin (warning @@ -283,32 +331,41 @@ cannot determine package dependencies") (file-extensi= on url)) (warning (G_ "Failed to extract file: ~a from source.~%") requires.txt) =2D '()))))) =2D '()))) + (list '() '())))))) + (list '() '())))) =20 ;; First, try to compute the requirements using the wheel, else, fallbac= k to ;; reading the "requires.txt" from the egg-info directory from the source =2D ;; tarball. + ;; archive. (or (guess-requirements-from-wheel) (guess-requirements-from-source))) =20 (define (compute-inputs source-url wheel-url archive) =2D "Given the SOURCE-URL of an already downloaded ARCHIVE, return a list = of =2Dname/variable pairs describing the required inputs of this package. Also + "Given the SOURCE-URL and WHEEL-URL of an already downloaded ARCHIVE, re= turn +a pair of lists, each consisting of a list of name/variable pairs, for the +propagated inputs and the native inputs, respectively. Also return the unaltered list of upstream dependency names." =2D (let ((dependencies =2D (remove (cut string=3D? "argparse" <>) =2D (guess-requirements source-url wheel-url archive)))) =2D (values (sort =2D (map (lambda (input) =2D (let ((guix-name (python->package-name input))) =2D (list guix-name (list 'unquote (string->symbol gui= x-name))))) =2D dependencies) =2D (lambda args =2D (match args =2D (((a _ ...) (b _ ...)) =2D (string-ci) deps)) + + (define (requirement->package-name/sort deps) + (sort + (map (lambda (input) + (let ((guix-name (python->package-name input))) + (list guix-name (list 'unquote (string->symbol guix-name))))) + deps) + (lambda args + (match args + (((a _ ...) (b _ ...)) + (string-cipackage-name/sort strip-argparse)) + + (let ((dependencies (guess-requirements source-url wheel-url archive))) + (values (map process-requirements dependencies) + (concatenate dependencies)))) =20 (define (make-pypi-sexp name version source-url wheel-url home-page synops= is description license) @@ -317,29 +374,33 @@ VERSION, SOURCE-URL, HOME-PAGE, SYNOPSIS, DESCRIPTION= , and LICENSE." (call-with-temporary-output-file (lambda (temp port) (and (url-fetch source-url temp) =2D (receive (input-package-names upstream-dependency-names) + (receive (guix-dependencies upstream-dependencies) (compute-inputs source-url wheel-url temp) =2D (values =2D `(package =2D (name ,(python->package-name name)) =2D (version ,version) =2D (source (origin =2D (method url-fetch) =2D =2D ;; Sometimes 'pypi-uri' doesn't quite work due= to mixed =2D ;; cases in NAME, for instance, as is the case= with =2D ;; "uwsgi". In that case, fall back to a full= URL. =2D (uri (pypi-uri ,(string-downcase name) version= )) =2D (sha256 =2D (base32 =2D ,(guix-hash-url temp))))) =2D (build-system python-build-system) =2D ,@(maybe-inputs input-package-names) =2D (home-page ,home-page) =2D (synopsis ,synopsis) =2D (description ,description) =2D (license ,(license->symbol license))) =2D upstream-dependency-names)))))) + (match guix-dependencies + ((required-inputs test-inputs) + (values + `(package + (name ,(python->package-name name)) + (version ,version) + (source (origin + (method url-fetch) + ;; Sometimes 'pypi-uri' doesn't quite work du= e to mixed + ;; cases in NAME, for instance, as is the cas= e with + ;; "uwsgi". In that case, fall back to a ful= l URL. + (uri (pypi-uri ,(string-downcase name) versio= n)) + (sha256 + (base32 + ,(guix-hash-url temp))))) + (build-system python-build-system) + ,@(maybe-inputs required-inputs 'propagated-inputs) + ,@(maybe-inputs test-inputs 'native-inputs) + (home-page ,home-page) + (synopsis ,synopsis) + (description ,description) + (license ,(license->symbol license))) + ;; Flatten the nested lists and return the upstream + ;; dependencies. + upstream-dependencies)))))))) =20 (define pypi->guix-package (memoize diff --git a/tests/pypi.scm b/tests/pypi.scm index ca8cb5f6de..aa08e2cb54 100644 =2D-- a/tests/pypi.scm +++ b/tests/pypi.scm @@ -1,6 +1,7 @@ ;;; GNU Guix --- Functional package management for GNU ;;; Copyright =C2=A9 2014 David Thompson ;;; Copyright =C2=A9 2016 Ricardo Wurmus +;;; Copyright =C2=A9 2019 Maxim Cournoyer ;;; ;;; This file is part of GNU Guix. ;;; @@ -65,11 +66,6 @@ sha1=3Dda9234ee9982d4bbb3c72346a6de940a148ea686")) =20 (define test-requires.txt "\ =2Dbar =2Dbaz > 13.37 =2D") =2D =2D(define test-requires-with-sections "\ # A comment foo ~=3D 3 bar !=3D 2 @@ -78,12 +74,25 @@ bar !=3D 2 pytest (>=3D2.5.0) ") =20 +;; Beaker contains only optional dependencies. +(define test-requires.txt-beaker "\ +[crypto] +pycryptopp>=3D0.5.12 + +[cryptography] +cryptography + +[testsuite] +Mock +coverage +") + (define test-metadata "\ Classifier: Programming Language :: Python :: 3.7 Requires-Dist: baz ~=3D 3 Requires-Dist: bar !=3D 2 Provides-Extra: test =2Dpytest (>=3D2.5.0) +Requires-Dist: pytest (>=3D2.5.0) ; extra =3D=3D 'test' ") =20 (define test-metadata-with-extras " @@ -137,25 +146,31 @@ Requires-Dist: pytest (>=3D3.1.0); extra =3D=3D 'test= ing' '("Fizzy" "PickyThing" "SomethingWithMarker" "requests" "pip") (map specification->requirement-name test-specifications)) =20 =2D(test-equal "parse-requires.txt, with sections" =2D '("foo" "bar") +(test-equal "parse-requires.txt" + (list '("foo" "bar") '("pytest")) (mock ((ice-9 ports) call-with-input-file call-with-input-string) =2D (parse-requires.txt test-requires-with-sections))) + (parse-requires.txt test-requires.txt))) + +(test-equal "parse-requires.txt - Beaker" + (list '() '("Mock" "coverage")) + (mock ((ice-9 ports) call-with-input-file + call-with-input-string) + (parse-requires.txt test-requires.txt-beaker))) =20 (test-equal "parse-wheel-metadata, with extras" =2D '("wrapt" "bar") + (list '("wrapt" "bar") '("tox" "bumpversion")) (mock ((ice-9 ports) call-with-input-file call-with-input-string) (parse-wheel-metadata test-metadata-with-extras))) =20 (test-equal "parse-wheel-metadata, with extras - Jedi" =2D '("parso") + (list '("parso") '("pytest")) (mock ((ice-9 ports) call-with-input-file call-with-input-string) (parse-wheel-metadata test-metadata-with-extras-jedi))) =20 =2D(test-assert "pypi->guix-package" +(test-assert "pypi->guix-package, no wheel" ;; Replace network resources with sample data. (mock ((guix import utils) url-fetch (lambda (url file-name) @@ -195,7 +210,10 @@ Requires-Dist: pytest (>=3D3.1.0); extra =3D=3D 'testi= ng' ('propagated-inputs ('quasiquote (("python-bar" ('unquote 'python-bar)) =2D ("python-baz" ('unquote 'python-baz))))) + ("python-foo" ('unquote 'python-foo))))) + ('native-inputs + ('quasiquote + (("python-pytest" ('unquote 'python-pytest))))) ('home-page "http://example.com") ('synopsis "summary") ('description "summary") @@ -216,25 +234,25 @@ Requires-Dist: pytest (>=3D3.1.0); extra =3D=3D 'test= ing' (begin (mkdir-p "foo-1.0.0/foo.egg-info/") (with-output-to-file "foo-1.0.0/foo.egg-info/requires.txt" =2D (lambda () =2D (display "wrong data to make sure we're testing whe= els "))) + (lambda () + (display "wrong data to make sure we're testing wheels= "))) (parameterize ((current-output-port (%make-void-port "rw+"= ))) (system* "tar" "czvf" file-name "foo-1.0.0/")) =2D (delete-file-recursively "foo-1.0.0") =2D (set! test-source-hash =2D (call-with-input-file file-name port-sha256)))) + (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" =2D (begin =2D (mkdir "foo-1.0.0.dist-info") =2D (with-output-to-file "foo-1.0.0.dist-info/METADATA" =2D (lambda () =2D (display test-metadata))) =2D (let ((zip-file (string-append file-name ".zip"))) =2D ;; zip always adds a "zip" extension to the file it c= reates, =2D ;; so we need to rename it. =2D (system* "zip" zip-file "foo-1.0.0.dist-info/METADATA= ") =2D (rename-file zip-file file-name)) =2D (delete-file-recursively "foo-1.0.0.dist-info"))) + (begin + (mkdir "foo-1.0.0.dist-info") + (with-output-to-file "foo-1.0.0.dist-info/METADATA" + (lambda () + (display test-metadata))) + (let ((zip-file (string-append file-name ".zip"))) + ;; zip always adds a "zip" extension to the file it crea= tes, + ;; so we need to rename it. + (system* "zip" "-q" zip-file "foo-1.0.0.dist-info/METADA= TA") + (rename-file zip-file file-name)) + (delete-file-recursively "foo-1.0.0.dist-info"))) (_ (error "Unexpected URL: " url))))) (mock ((guix http-client) http-fetch (lambda (url . rest) @@ -262,6 +280,9 @@ Requires-Dist: pytest (>=3D3.1.0); extra =3D=3D 'testin= g' ('quasiquote (("python-bar" ('unquote 'python-bar)) ("python-baz" ('unquote 'python-baz))))) + ('native-inputs + ('quasiquote + (("python-pytest" ('unquote 'python-pytest))))) ('home-page "http://example.com") ('synopsis "summary") ('description "summary") @@ -272,4 +293,48 @@ Requires-Dist: pytest (>=3D3.1.0); extra =3D=3D 'testi= ng' (x (pk 'fail x #f)))))) =20 +(test-assert "pypi->guix-package, no usable requirement file." + ;; Replace network resources with sample data. + (mock ((guix import utils) url-fetch + (lambda (url file-name) + (match url + ("https://example.com/foo-1.0.0.tar.gz" + (set! test-source-hash + (call-with-input-file file-name port-sha256)) + #t) + ("https://example.com/foo-1.0.0-py2.py3-none-any.whl" #t) + (_ (error "Unexpected URL: " url))))) + (mock ((guix http-client) http-fetch + (lambda (url . rest) + (match url + ("https://pypi.org/pypi/foo/json" + (values (open-input-string test-json) + (string-length test-json))) + ("https://example.com/foo-1.0.0-py2.py3-none-any.whl" #= f) + (_ (error "Unexpected URL: " url))))) + ;; Not clearing the memoization cache here would mean return= ing 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 'python-build-system) + ('home-page "http://example.com") + ('synopsis "summary") + ('description "summary") + ('license 'license:lgpl2.0)) + (string=3D? (bytevector->nix-base32-string + test-source-hash) + hash)) + (x + (pk 'fail x #f))) + ))) + (test-end "pypi") =2D-=20 2.20.1 --=-=-=-- --==-=-= Content-Type: application/pgp-signature; name="signature.asc" -----BEGIN PGP SIGNATURE----- iQIzBAEBCAAdFiEEJ9WGpPiQCFQyn/CfEmDkZILmNWIFAlye0JYACgkQEmDkZILm NWJGEg/9HElFyj+z1M2Z6ffboYKW3WJ0D7q+vV0fvkacd3X/8XkC6DPEjI8veFYf 3qiygovcK6tgI5sAVzRWZ4WJ7oW284YoUURGjfax1ilvJl3kGkzqNy45nD3CQo3A JK/rfW432Pu2UgC/Ewu+4V1NZqU5gCoHnz/rVNt0gRwWANBCLQG9fia/0u7zq8WK E0UIlKJZeOi6/1xr+QYcSYtMV9Nr3mb7OOC3TleHrz+lrT8YbmrsqdxXfZuJw3iA FY4jMX6SGAlfhKc7/+2F2z+iwkev7tTg1AAVhLWyqeR+zqYwBOCj5TOEAFMBcO8e FMUr6GONxdJfHwSapnzzEq6HcTqhx+MutMCiynap3hBVxONDA2uXmGTM/pe0zrZm Dqdr4XgFgaSJcaAtc3Ar/v6jwRy3muNKHBl6opQjd7zbxIju9hgzsiXvTbC/RUgg 9Oc7Yd2PT2kCBvJ1GgCk996EHcY1ACOeHHI0Yg0XHO6JCx1Dqi9i0vImGqcoOg/o OmA0cxRntaCgiCSyGaSyBzcyHjsfieKwZlRQk5DaeMfguzGHGFS9b6OHuJ7Ofdb7 QESgYGuD5YlQhAsNc64A0GTm6+JDh9v84MwUqyR0EcU3toPO3r2zAWeGoi8k3FDZ IaPuNC3qZ9XY2Pp7kHsHcTCRs+lXcoIDxZElkOgNMWCvW5wKpr4= =2WjY -----END PGP SIGNATURE----- --==-=-=--