Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
index.py509 linesDownload Raw Back to distlib
1# -*- coding: utf-8 -*-2#3# Copyright (C) 2013-2023 Vinay Sajip.4# Licensed to the Python Software Foundation under a contributor agreement.5# See LICENSE.txt and CONTRIBUTORS.txt.6#7import hashlib8import logging9import os10import shutil11import subprocess12import tempfile13try:14    from threading import Thread15except ImportError:  # pragma: no cover16    from dummy_threading import Thread17 18from . import DistlibException19from .compat import (HTTPBasicAuthHandler, Request, HTTPPasswordMgr,20                     urlparse, build_opener, string_types)21from .util import zip_dir, ServerProxy22 23logger = logging.getLogger(__name__)24 25DEFAULT_INDEX = 'https://pypi.org/pypi'26DEFAULT_REALM = 'pypi'27 28 29class PackageIndex(object):30    """31    This class represents a package index compatible with PyPI, the Python32    Package Index.33    """34 35    boundary = b'----------ThIs_Is_tHe_distlib_index_bouNdaRY_$'36 37    def __init__(self, url=None):38        """39        Initialise an instance.40 41        :param url: The URL of the index. If not specified, the URL for PyPI is42                    used.43        """44        self.url = url or DEFAULT_INDEX45        self.read_configuration()46        scheme, netloc, path, params, query, frag = urlparse(self.url)47        if params or query or frag or scheme not in ('http', 'https'):48            raise DistlibException('invalid repository: %s' % self.url)49        self.password_handler = None50        self.ssl_verifier = None51        self.gpg = None52        self.gpg_home = None53        with open(os.devnull, 'w') as sink:54            # Use gpg by default rather than gpg2, as gpg2 insists on55            # prompting for passwords56            for s in ('gpg', 'gpg2'):57                try:58                    rc = subprocess.check_call([s, '--version'], stdout=sink,59                                               stderr=sink)60                    if rc == 0:61                        self.gpg = s62                        break63                except OSError:64                    pass65 66    def _get_pypirc_command(self):67        """68        Get the distutils command for interacting with PyPI configurations.69        :return: the command.70        """71        from .util import _get_pypirc_command as cmd72        return cmd()73 74    def read_configuration(self):75        """76        Read the PyPI access configuration as supported by distutils. This populates77        ``username``, ``password``, ``realm`` and ``url`` attributes from the78        configuration.79        """80        from .util import _load_pypirc81        cfg = _load_pypirc(self)82        self.username = cfg.get('username')83        self.password = cfg.get('password')84        self.realm = cfg.get('realm', 'pypi')85        self.url = cfg.get('repository', self.url)86 87    def save_configuration(self):88        """89        Save the PyPI access configuration. You must have set ``username`` and90        ``password`` attributes before calling this method.91        """92        self.check_credentials()93        from .util import _store_pypirc94        _store_pypirc(self)95 96    def check_credentials(self):97        """98        Check that ``username`` and ``password`` have been set, and raise an99        exception if not.100        """101        if self.username is None or self.password is None:102            raise DistlibException('username and password must be set')103        pm = HTTPPasswordMgr()104        _, netloc, _, _, _, _ = urlparse(self.url)105        pm.add_password(self.realm, netloc, self.username, self.password)106        self.password_handler = HTTPBasicAuthHandler(pm)107 108    def register(self, metadata):  # pragma: no cover109        """110        Register a distribution on PyPI, using the provided metadata.111 112        :param metadata: A :class:`Metadata` instance defining at least a name113                         and version number for the distribution to be114                         registered.115        :return: The HTTP response received from PyPI upon submission of the116                request.117        """118        self.check_credentials()119        metadata.validate()120        d = metadata.todict()121        d[':action'] = 'verify'122        request = self.encode_request(d.items(), [])123        self.send_request(request)124        d[':action'] = 'submit'125        request = self.encode_request(d.items(), [])126        return self.send_request(request)127 128    def _reader(self, name, stream, outbuf):129        """130        Thread runner for reading lines of from a subprocess into a buffer.131 132        :param name: The logical name of the stream (used for logging only).133        :param stream: The stream to read from. This will typically a pipe134                       connected to the output stream of a subprocess.135        :param outbuf: The list to append the read lines to.136        """137        while True:138            s = stream.readline()139            if not s:140                break141            s = s.decode('utf-8').rstrip()142            outbuf.append(s)143            logger.debug('%s: %s' % (name, s))144        stream.close()145 146    def get_sign_command(self, filename, signer, sign_password, keystore=None):  # pragma: no cover147        """148        Return a suitable command for signing a file.149 150        :param filename: The pathname to the file to be signed.151        :param signer: The identifier of the signer of the file.152        :param sign_password: The passphrase for the signer's153                              private key used for signing.154        :param keystore: The path to a directory which contains the keys155                         used in verification. If not specified, the156                         instance's ``gpg_home`` attribute is used instead.157        :return: The signing command as a list suitable to be158                 passed to :class:`subprocess.Popen`.159        """160        cmd = [self.gpg, '--status-fd', '2', '--no-tty']161        if keystore is None:162            keystore = self.gpg_home163        if keystore:164            cmd.extend(['--homedir', keystore])165        if sign_password is not None:166            cmd.extend(['--batch', '--passphrase-fd', '0'])167        td = tempfile.mkdtemp()168        sf = os.path.join(td, os.path.basename(filename) + '.asc')169        cmd.extend(['--detach-sign', '--armor', '--local-user',170                    signer, '--output', sf, filename])171        logger.debug('invoking: %s', ' '.join(cmd))172        return cmd, sf173 174    def run_command(self, cmd, input_data=None):175        """176        Run a command in a child process , passing it any input data specified.177 178        :param cmd: The command to run.179        :param input_data: If specified, this must be a byte string containing180                           data to be sent to the child process.181        :return: A tuple consisting of the subprocess' exit code, a list of182                 lines read from the subprocess' ``stdout``, and a list of183                 lines read from the subprocess' ``stderr``.184        """185        kwargs = {186            'stdout': subprocess.PIPE,187            'stderr': subprocess.PIPE,188        }189        if input_data is not None:190            kwargs['stdin'] = subprocess.PIPE191        stdout = []192        stderr = []193        p = subprocess.Popen(cmd, **kwargs)194        # We don't use communicate() here because we may need to195        # get clever with interacting with the command196        t1 = Thread(target=self._reader, args=('stdout', p.stdout, stdout))197        t1.start()198        t2 = Thread(target=self._reader, args=('stderr', p.stderr, stderr))199        t2.start()200        if input_data is not None:201            p.stdin.write(input_data)202            p.stdin.close()203 204        p.wait()205        t1.join()206        t2.join()207        return p.returncode, stdout, stderr208 209    def sign_file(self, filename, signer, sign_password, keystore=None):  # pragma: no cover210        """211        Sign a file.212 213        :param filename: The pathname to the file to be signed.214        :param signer: The identifier of the signer of the file.215        :param sign_password: The passphrase for the signer's216                              private key used for signing.217        :param keystore: The path to a directory which contains the keys218                         used in signing. If not specified, the instance's219                         ``gpg_home`` attribute is used instead.220        :return: The absolute pathname of the file where the signature is221                 stored.222        """223        cmd, sig_file = self.get_sign_command(filename, signer, sign_password,224                                              keystore)225        rc, stdout, stderr = self.run_command(cmd,226                                              sign_password.encode('utf-8'))227        if rc != 0:228            raise DistlibException('sign command failed with error '229                                   'code %s' % rc)230        return sig_file231 232    def upload_file(self, metadata, filename, signer=None, sign_password=None,233                    filetype='sdist', pyversion='source', keystore=None):234        """235        Upload a release file to the index.236 237        :param metadata: A :class:`Metadata` instance defining at least a name238                         and version number for the file to be uploaded.239        :param filename: The pathname of the file to be uploaded.240        :param signer: The identifier of the signer of the file.241        :param sign_password: The passphrase for the signer's242                              private key used for signing.243        :param filetype: The type of the file being uploaded. This is the244                        distutils command which produced that file, e.g.245                        ``sdist`` or ``bdist_wheel``.246        :param pyversion: The version of Python which the release relates247                          to. For code compatible with any Python, this would248                          be ``source``, otherwise it would be e.g. ``3.2``.249        :param keystore: The path to a directory which contains the keys250                         used in signing. If not specified, the instance's251                         ``gpg_home`` attribute is used instead.252        :return: The HTTP response received from PyPI upon submission of the253                request.254        """255        self.check_credentials()256        if not os.path.exists(filename):257            raise DistlibException('not found: %s' % filename)258        metadata.validate()259        d = metadata.todict()260        sig_file = None261        if signer:262            if not self.gpg:263                logger.warning('no signing program available - not signed')264            else:265                sig_file = self.sign_file(filename, signer, sign_password,266                                          keystore)267        with open(filename, 'rb') as f:268            file_data = f.read()269        md5_digest = hashlib.md5(file_data).hexdigest()270        sha256_digest = hashlib.sha256(file_data).hexdigest()271        d.update({272            ':action': 'file_upload',273            'protocol_version': '1',274            'filetype': filetype,275            'pyversion': pyversion,276            'md5_digest': md5_digest,277            'sha256_digest': sha256_digest,278        })279        files = [('content', os.path.basename(filename), file_data)]280        if sig_file:281            with open(sig_file, 'rb') as f:282                sig_data = f.read()283            files.append(('gpg_signature', os.path.basename(sig_file),284                         sig_data))285            shutil.rmtree(os.path.dirname(sig_file))286        request = self.encode_request(d.items(), files)287        return self.send_request(request)288 289    def upload_documentation(self, metadata, doc_dir):  # pragma: no cover290        """291        Upload documentation to the index.292 293        :param metadata: A :class:`Metadata` instance defining at least a name294                         and version number for the documentation to be295                         uploaded.296        :param doc_dir: The pathname of the directory which contains the297                        documentation. This should be the directory that298                        contains the ``index.html`` for the documentation.299        :return: The HTTP response received from PyPI upon submission of the300                request.301        """302        self.check_credentials()303        if not os.path.isdir(doc_dir):304            raise DistlibException('not a directory: %r' % doc_dir)305        fn = os.path.join(doc_dir, 'index.html')306        if not os.path.exists(fn):307            raise DistlibException('not found: %r' % fn)308        metadata.validate()309        name, version = metadata.name, metadata.version310        zip_data = zip_dir(doc_dir).getvalue()311        fields = [(':action', 'doc_upload'),312                  ('name', name), ('version', version)]313        files = [('content', name, zip_data)]314        request = self.encode_request(fields, files)315        return self.send_request(request)316 317    def get_verify_command(self, signature_filename, data_filename,318                           keystore=None):319        """320        Return a suitable command for verifying a file.321 322        :param signature_filename: The pathname to the file containing the323                                   signature.324        :param data_filename: The pathname to the file containing the325                              signed data.326        :param keystore: The path to a directory which contains the keys327                         used in verification. If not specified, the328                         instance's ``gpg_home`` attribute is used instead.329        :return: The verifying command as a list suitable to be330                 passed to :class:`subprocess.Popen`.331        """332        cmd = [self.gpg, '--status-fd', '2', '--no-tty']333        if keystore is None:334            keystore = self.gpg_home335        if keystore:336            cmd.extend(['--homedir', keystore])337        cmd.extend(['--verify', signature_filename, data_filename])338        logger.debug('invoking: %s', ' '.join(cmd))339        return cmd340 341    def verify_signature(self, signature_filename, data_filename,342                         keystore=None):343        """344        Verify a signature for a file.345 346        :param signature_filename: The pathname to the file containing the347                                   signature.348        :param data_filename: The pathname to the file containing the349                              signed data.350        :param keystore: The path to a directory which contains the keys351                         used in verification. If not specified, the352                         instance's ``gpg_home`` attribute is used instead.353        :return: True if the signature was verified, else False.354        """355        if not self.gpg:356            raise DistlibException('verification unavailable because gpg '357                                   'unavailable')358        cmd = self.get_verify_command(signature_filename, data_filename,359                                      keystore)360        rc, stdout, stderr = self.run_command(cmd)361        if rc not in (0, 1):362            raise DistlibException('verify command failed with error code %s' % rc)363        return rc == 0364 365    def download_file(self, url, destfile, digest=None, reporthook=None):366        """367        This is a convenience method for downloading a file from an URL.368        Normally, this will be a file from the index, though currently369        no check is made for this (i.e. a file can be downloaded from370        anywhere).371 372        The method is just like the :func:`urlretrieve` function in the373        standard library, except that it allows digest computation to be374        done during download and checking that the downloaded data375        matched any expected value.376 377        :param url: The URL of the file to be downloaded (assumed to be378                    available via an HTTP GET request).379        :param destfile: The pathname where the downloaded file is to be380                         saved.381        :param digest: If specified, this must be a (hasher, value)382                       tuple, where hasher is the algorithm used (e.g.383                       ``'md5'``) and ``value`` is the expected value.384        :param reporthook: The same as for :func:`urlretrieve` in the385                           standard library.386        """387        if digest is None:388            digester = None389            logger.debug('No digest specified')390        else:391            if isinstance(digest, (list, tuple)):392                hasher, digest = digest393            else:394                hasher = 'md5'395            digester = getattr(hashlib, hasher)()396            logger.debug('Digest specified: %s' % digest)397        # The following code is equivalent to urlretrieve.398        # We need to do it this way so that we can compute the399        # digest of the file as we go.400        with open(destfile, 'wb') as dfp:401            # addinfourl is not a context manager on 2.x402            # so we have to use try/finally403            sfp = self.send_request(Request(url))404            try:405                headers = sfp.info()406                blocksize = 8192407                size = -1408                read = 0409                blocknum = 0410                if "content-length" in headers:411                    size = int(headers["Content-Length"])412                if reporthook:413                    reporthook(blocknum, blocksize, size)414                while True:415                    block = sfp.read(blocksize)416                    if not block:417                        break418                    read += len(block)419                    dfp.write(block)420                    if digester:421                        digester.update(block)422                    blocknum += 1423                    if reporthook:424                        reporthook(blocknum, blocksize, size)425            finally:426                sfp.close()427 428        # check that we got the whole file, if we can429        if size >= 0 and read < size:430            raise DistlibException(431                'retrieval incomplete: got only %d out of %d bytes'432                % (read, size))433        # if we have a digest, it must match.434        if digester:435            actual = digester.hexdigest()436            if digest != actual:437                raise DistlibException('%s digest mismatch for %s: expected '438                                       '%s, got %s' % (hasher, destfile,439                                                       digest, actual))440            logger.debug('Digest verified: %s', digest)441 442    def send_request(self, req):443        """444        Send a standard library :class:`Request` to PyPI and return its445        response.446 447        :param req: The request to send.448        :return: The HTTP response from PyPI (a standard library HTTPResponse).449        """450        handlers = []451        if self.password_handler:452            handlers.append(self.password_handler)453        if self.ssl_verifier:454            handlers.append(self.ssl_verifier)455        opener = build_opener(*handlers)456        return opener.open(req)457 458    def encode_request(self, fields, files):459        """460        Encode fields and files for posting to an HTTP server.461 462        :param fields: The fields to send as a list of (fieldname, value)463                       tuples.464        :param files: The files to send as a list of (fieldname, filename,465                      file_bytes) tuple.466        """467        # Adapted from packaging, which in turn was adapted from468        # http://code.activestate.com/recipes/146306469 470        parts = []471        boundary = self.boundary472        for k, values in fields:473            if not isinstance(values, (list, tuple)):474                values = [values]475 476            for v in values:477                parts.extend((478                    b'--' + boundary,479                    ('Content-Disposition: form-data; name="%s"' %480                     k).encode('utf-8'),481                    b'',482                    v.encode('utf-8')))483        for key, filename, value in files:484            parts.extend((485                b'--' + boundary,486                ('Content-Disposition: form-data; name="%s"; filename="%s"' %487                 (key, filename)).encode('utf-8'),488                b'',489                value))490 491        parts.extend((b'--' + boundary + b'--', b''))492 493        body = b'\r\n'.join(parts)494        ct = b'multipart/form-data; boundary=' + boundary495        headers = {496            'Content-type': ct,497            'Content-length': str(len(body))498        }499        return Request(self.url, body, headers)500 501    def search(self, terms, operator=None):  # pragma: no cover502        if isinstance(terms, string_types):503            terms = {'name': terms}504        rpc_proxy = ServerProxy(self.url, timeout=3.0)505        try:506            return rpc_proxy.search(terms, operator or 'and')507        finally:508            rpc_proxy('close')()509 
codekingpro/portable-devtools · Team Ai