| # -*- coding: utf-8 -*- |
| # |
| # Copyright (C) 2013 Vinay Sajip. |
| # Licensed to the Python Software Foundation under a contributor agreement. |
| # See LICENSE.txt and CONTRIBUTORS.txt. |
| # |
| import hashlib |
| import logging |
| import os |
| import shutil |
| import subprocess |
| import tempfile |
| try: |
| from threading import Thread |
| except ImportError: # pragma: no cover |
| from dummy_threading import Thread |
| |
| from . import DistlibException |
| from .compat import (HTTPBasicAuthHandler, Request, HTTPPasswordMgr, |
| urlparse, build_opener, string_types) |
| from .util import zip_dir, ServerProxy |
| |
| logger = logging.getLogger(__name__) |
| |
| DEFAULT_INDEX = 'https://pypi.org/pypi' |
| DEFAULT_REALM = 'pypi' |
| |
| class PackageIndex(object): |
| """ |
| This class represents a package index compatible with PyPI, the Python |
| Package Index. |
| """ |
| |
| boundary = b'----------ThIs_Is_tHe_distlib_index_bouNdaRY_$' |
| |
| def __init__(self, url=None): |
| """ |
| Initialise an instance. |
| |
| :param url: The URL of the index. If not specified, the URL for PyPI is |
| used. |
| """ |
| self.url = url or DEFAULT_INDEX |
| self.read_configuration() |
| scheme, netloc, path, params, query, frag = urlparse(self.url) |
| if params or query or frag or scheme not in ('http', 'https'): |
| raise DistlibException('invalid repository: %s' % self.url) |
| self.password_handler = None |
| self.ssl_verifier = None |
| self.gpg = None |
| self.gpg_home = None |
| with open(os.devnull, 'w') as sink: |
| # Use gpg by default rather than gpg2, as gpg2 insists on |
| # prompting for passwords |
| for s in ('gpg', 'gpg2'): |
| try: |
| rc = subprocess.check_call([s, '--version'], stdout=sink, |
| stderr=sink) |
| if rc == 0: |
| self.gpg = s |
| break |
| except OSError: |
| pass |
| |
| def _get_pypirc_command(self): |
| """ |
| Get the distutils command for interacting with PyPI configurations. |
| :return: the command. |
| """ |
| from .util import _get_pypirc_command as cmd |
| return cmd() |
| |
| def read_configuration(self): |
| """ |
| Read the PyPI access configuration as supported by distutils. This populates |
| ``username``, ``password``, ``realm`` and ``url`` attributes from the |
| configuration. |
| """ |
| from .util import _load_pypirc |
| cfg = _load_pypirc(self) |
| self.username = cfg.get('username') |
| self.password = cfg.get('password') |
| self.realm = cfg.get('realm', 'pypi') |
| self.url = cfg.get('repository', self.url) |
| |
| def save_configuration(self): |
| """ |
| Save the PyPI access configuration. You must have set ``username`` and |
| ``password`` attributes before calling this method. |
| """ |
| self.check_credentials() |
| from .util import _store_pypirc |
| _store_pypirc(self) |
| |
| def check_credentials(self): |
| """ |
| Check that ``username`` and ``password`` have been set, and raise an |
| exception if not. |
| """ |
| if self.username is None or self.password is None: |
| raise DistlibException('username and password must be set') |
| pm = HTTPPasswordMgr() |
| _, netloc, _, _, _, _ = urlparse(self.url) |
| pm.add_password(self.realm, netloc, self.username, self.password) |
| self.password_handler = HTTPBasicAuthHandler(pm) |
| |
| def register(self, metadata): # pragma: no cover |
| """ |
| Register a distribution on PyPI, using the provided metadata. |
| |
| :param metadata: A :class:`Metadata` instance defining at least a name |
| and version number for the distribution to be |
| registered. |
| :return: The HTTP response received from PyPI upon submission of the |
| request. |
| """ |
| self.check_credentials() |
| metadata.validate() |
| d = metadata.todict() |
| d[':action'] = 'verify' |
| request = self.encode_request(d.items(), []) |
| response = self.send_request(request) |
| d[':action'] = 'submit' |
| request = self.encode_request(d.items(), []) |
| return self.send_request(request) |
| |
| def _reader(self, name, stream, outbuf): |
| """ |
| Thread runner for reading lines of from a subprocess into a buffer. |
| |
| :param name: The logical name of the stream (used for logging only). |
| :param stream: The stream to read from. This will typically a pipe |
| connected to the output stream of a subprocess. |
| :param outbuf: The list to append the read lines to. |
| """ |
| while True: |
| s = stream.readline() |
| if not s: |
| break |
| s = s.decode('utf-8').rstrip() |
| outbuf.append(s) |
| logger.debug('%s: %s' % (name, s)) |
| stream.close() |
| |
| def get_sign_command(self, filename, signer, sign_password, keystore=None): # pragma: no cover |
| """ |
| Return a suitable command for signing a file. |
| |
| :param filename: The pathname to the file to be signed. |
| :param signer: The identifier of the signer of the file. |
| :param sign_password: The passphrase for the signer's |
| private key used for signing. |
| :param keystore: The path to a directory which contains the keys |
| used in verification. If not specified, the |
| instance's ``gpg_home`` attribute is used instead. |
| :return: The signing command as a list suitable to be |
| passed to :class:`subprocess.Popen`. |
| """ |
| cmd = [self.gpg, '--status-fd', '2', '--no-tty'] |
| if keystore is None: |
| keystore = self.gpg_home |
| if keystore: |
| cmd.extend(['--homedir', keystore]) |
| if sign_password is not None: |
| cmd.extend(['--batch', '--passphrase-fd', '0']) |
| td = tempfile.mkdtemp() |
| sf = os.path.join(td, os.path.basename(filename) + '.asc') |
| cmd.extend(['--detach-sign', '--armor', '--local-user', |
| signer, '--output', sf, filename]) |
| logger.debug('invoking: %s', ' '.join(cmd)) |
| return cmd, sf |
| |
| def run_command(self, cmd, input_data=None): |
| """ |
| Run a command in a child process , passing it any input data specified. |
| |
| :param cmd: The command to run. |
| :param input_data: If specified, this must be a byte string containing |
| data to be sent to the child process. |
| :return: A tuple consisting of the subprocess' exit code, a list of |
| lines read from the subprocess' ``stdout``, and a list of |
| lines read from the subprocess' ``stderr``. |
| """ |
| kwargs = { |
| 'stdout': subprocess.PIPE, |
| 'stderr': subprocess.PIPE, |
| } |
| if input_data is not None: |
| kwargs['stdin'] = subprocess.PIPE |
| stdout = [] |
| stderr = [] |
| p = subprocess.Popen(cmd, **kwargs) |
| # We don't use communicate() here because we may need to |
| # get clever with interacting with the command |
| t1 = Thread(target=self._reader, args=('stdout', p.stdout, stdout)) |
| t1.start() |
| t2 = Thread(target=self._reader, args=('stderr', p.stderr, stderr)) |
| t2.start() |
| if input_data is not None: |
| p.stdin.write(input_data) |
| p.stdin.close() |
| |
| p.wait() |
| t1.join() |
| t2.join() |
| return p.returncode, stdout, stderr |
| |
| def sign_file(self, filename, signer, sign_password, keystore=None): # pragma: no cover |
| """ |
| Sign a file. |
| |
| :param filename: The pathname to the file to be signed. |
| :param signer: The identifier of the signer of the file. |
| :param sign_password: The passphrase for the signer's |
| private key used for signing. |
| :param keystore: The path to a directory which contains the keys |
| used in signing. If not specified, the instance's |
| ``gpg_home`` attribute is used instead. |
| :return: The absolute pathname of the file where the signature is |
| stored. |
| """ |
| cmd, sig_file = self.get_sign_command(filename, signer, sign_password, |
| keystore) |
| rc, stdout, stderr = self.run_command(cmd, |
| sign_password.encode('utf-8')) |
| if rc != 0: |
| raise DistlibException('sign command failed with error ' |
| 'code %s' % rc) |
| return sig_file |
| |
| def upload_file(self, metadata, filename, signer=None, sign_password=None, |
| filetype='sdist', pyversion='source', keystore=None): |
| """ |
| Upload a release file to the index. |
| |
| :param metadata: A :class:`Metadata` instance defining at least a name |
| and version number for the file to be uploaded. |
| :param filename: The pathname of the file to be uploaded. |
| :param signer: The identifier of the signer of the file. |
| :param sign_password: The passphrase for the signer's |
| private key used for signing. |
| :param filetype: The type of the file being uploaded. This is the |
| distutils command which produced that file, e.g. |
| ``sdist`` or ``bdist_wheel``. |
| :param pyversion: The version of Python which the release relates |
| to. For code compatible with any Python, this would |
| be ``source``, otherwise it would be e.g. ``3.2``. |
| :param keystore: The path to a directory which contains the keys |
| used in signing. If not specified, the instance's |
| ``gpg_home`` attribute is used instead. |
| :return: The HTTP response received from PyPI upon submission of the |
| request. |
| """ |
| self.check_credentials() |
| if not os.path.exists(filename): |
| raise DistlibException('not found: %s' % filename) |
| metadata.validate() |
| d = metadata.todict() |
| sig_file = None |
| if signer: |
| if not self.gpg: |
| logger.warning('no signing program available - not signed') |
| else: |
| sig_file = self.sign_file(filename, signer, sign_password, |
| keystore) |
| with open(filename, 'rb') as f: |
| file_data = f.read() |
| md5_digest = hashlib.md5(file_data).hexdigest() |
| sha256_digest = hashlib.sha256(file_data).hexdigest() |
| d.update({ |
| ':action': 'file_upload', |
| 'protocol_version': '1', |
| 'filetype': filetype, |
| 'pyversion': pyversion, |
| 'md5_digest': md5_digest, |
| 'sha256_digest': sha256_digest, |
| }) |
| files = [('content', os.path.basename(filename), file_data)] |
| if sig_file: |
| with open(sig_file, 'rb') as f: |
| sig_data = f.read() |
| files.append(('gpg_signature', os.path.basename(sig_file), |
| sig_data)) |
| shutil.rmtree(os.path.dirname(sig_file)) |
| request = self.encode_request(d.items(), files) |
| return self.send_request(request) |
| |
| def upload_documentation(self, metadata, doc_dir): # pragma: no cover |
| """ |
| Upload documentation to the index. |
| |
| :param metadata: A :class:`Metadata` instance defining at least a name |
| and version number for the documentation to be |
| uploaded. |
| :param doc_dir: The pathname of the directory which contains the |
| documentation. This should be the directory that |
| contains the ``index.html`` for the documentation. |
| :return: The HTTP response received from PyPI upon submission of the |
| request. |
| """ |
| self.check_credentials() |
| if not os.path.isdir(doc_dir): |
| raise DistlibException('not a directory: %r' % doc_dir) |
| fn = os.path.join(doc_dir, 'index.html') |
| if not os.path.exists(fn): |
| raise DistlibException('not found: %r' % fn) |
| metadata.validate() |
| name, version = metadata.name, metadata.version |
| zip_data = zip_dir(doc_dir).getvalue() |
| fields = [(':action', 'doc_upload'), |
| ('name', name), ('version', version)] |
| files = [('content', name, zip_data)] |
| request = self.encode_request(fields, files) |
| return self.send_request(request) |
| |
| def get_verify_command(self, signature_filename, data_filename, |
| keystore=None): |
| """ |
| Return a suitable command for verifying a file. |
| |
| :param signature_filename: The pathname to the file containing the |
| signature. |
| :param data_filename: The pathname to the file containing the |
| signed data. |
| :param keystore: The path to a directory which contains the keys |
| used in verification. If not specified, the |
| instance's ``gpg_home`` attribute is used instead. |
| :return: The verifying command as a list suitable to be |
| passed to :class:`subprocess.Popen`. |
| """ |
| cmd = [self.gpg, '--status-fd', '2', '--no-tty'] |
| if keystore is None: |
| keystore = self.gpg_home |
| if keystore: |
| cmd.extend(['--homedir', keystore]) |
| cmd.extend(['--verify', signature_filename, data_filename]) |
| logger.debug('invoking: %s', ' '.join(cmd)) |
| return cmd |
| |
| def verify_signature(self, signature_filename, data_filename, |
| keystore=None): |
| """ |
| Verify a signature for a file. |
| |
| :param signature_filename: The pathname to the file containing the |
| signature. |
| :param data_filename: The pathname to the file containing the |
| signed data. |
| :param keystore: The path to a directory which contains the keys |
| used in verification. If not specified, the |
| instance's ``gpg_home`` attribute is used instead. |
| :return: True if the signature was verified, else False. |
| """ |
| if not self.gpg: |
| raise DistlibException('verification unavailable because gpg ' |
| 'unavailable') |
| cmd = self.get_verify_command(signature_filename, data_filename, |
| keystore) |
| rc, stdout, stderr = self.run_command(cmd) |
| if rc not in (0, 1): |
| raise DistlibException('verify command failed with error ' |
| 'code %s' % rc) |
| return rc == 0 |
| |
| def download_file(self, url, destfile, digest=None, reporthook=None): |
| """ |
| This is a convenience method for downloading a file from an URL. |
| Normally, this will be a file from the index, though currently |
| no check is made for this (i.e. a file can be downloaded from |
| anywhere). |
| |
| The method is just like the :func:`urlretrieve` function in the |
| standard library, except that it allows digest computation to be |
| done during download and checking that the downloaded data |
| matched any expected value. |
| |
| :param url: The URL of the file to be downloaded (assumed to be |
| available via an HTTP GET request). |
| :param destfile: The pathname where the downloaded file is to be |
| saved. |
| :param digest: If specified, this must be a (hasher, value) |
| tuple, where hasher is the algorithm used (e.g. |
| ``'md5'``) and ``value`` is the expected value. |
| :param reporthook: The same as for :func:`urlretrieve` in the |
| standard library. |
| """ |
| if digest is None: |
| digester = None |
| logger.debug('No digest specified') |
| else: |
| if isinstance(digest, (list, tuple)): |
| hasher, digest = digest |
| else: |
| hasher = 'md5' |
| digester = getattr(hashlib, hasher)() |
| logger.debug('Digest specified: %s' % digest) |
| # The following code is equivalent to urlretrieve. |
| # We need to do it this way so that we can compute the |
| # digest of the file as we go. |
| with open(destfile, 'wb') as dfp: |
| # addinfourl is not a context manager on 2.x |
| # so we have to use try/finally |
| sfp = self.send_request(Request(url)) |
| try: |
| headers = sfp.info() |
| blocksize = 8192 |
| size = -1 |
| read = 0 |
| blocknum = 0 |
| if "content-length" in headers: |
| size = int(headers["Content-Length"]) |
| if reporthook: |
| reporthook(blocknum, blocksize, size) |
| while True: |
| block = sfp.read(blocksize) |
| if not block: |
| break |
| read += len(block) |
| dfp.write(block) |
| if digester: |
| digester.update(block) |
| blocknum += 1 |
| if reporthook: |
| reporthook(blocknum, blocksize, size) |
| finally: |
| sfp.close() |
| |
| # check that we got the whole file, if we can |
| if size >= 0 and read < size: |
| raise DistlibException( |
| 'retrieval incomplete: got only %d out of %d bytes' |
| % (read, size)) |
| # if we have a digest, it must match. |
| if digester: |
| actual = digester.hexdigest() |
| if digest != actual: |
| raise DistlibException('%s digest mismatch for %s: expected ' |
| '%s, got %s' % (hasher, destfile, |
| digest, actual)) |
| logger.debug('Digest verified: %s', digest) |
| |
| def send_request(self, req): |
| """ |
| Send a standard library :class:`Request` to PyPI and return its |
| response. |
| |
| :param req: The request to send. |
| :return: The HTTP response from PyPI (a standard library HTTPResponse). |
| """ |
| handlers = [] |
| if self.password_handler: |
| handlers.append(self.password_handler) |
| if self.ssl_verifier: |
| handlers.append(self.ssl_verifier) |
| opener = build_opener(*handlers) |
| return opener.open(req) |
| |
| def encode_request(self, fields, files): |
| """ |
| Encode fields and files for posting to an HTTP server. |
| |
| :param fields: The fields to send as a list of (fieldname, value) |
| tuples. |
| :param files: The files to send as a list of (fieldname, filename, |
| file_bytes) tuple. |
| """ |
| # Adapted from packaging, which in turn was adapted from |
| # http://code.activestate.com/recipes/146306 |
| |
| parts = [] |
| boundary = self.boundary |
| for k, values in fields: |
| if not isinstance(values, (list, tuple)): |
| values = [values] |
| |
| for v in values: |
| parts.extend(( |
| b'--' + boundary, |
| ('Content-Disposition: form-data; name="%s"' % |
| k).encode('utf-8'), |
| b'', |
| v.encode('utf-8'))) |
| for key, filename, value in files: |
| parts.extend(( |
| b'--' + boundary, |
| ('Content-Disposition: form-data; name="%s"; filename="%s"' % |
| (key, filename)).encode('utf-8'), |
| b'', |
| value)) |
| |
| parts.extend((b'--' + boundary + b'--', b'')) |
| |
| body = b'\r\n'.join(parts) |
| ct = b'multipart/form-data; boundary=' + boundary |
| headers = { |
| 'Content-type': ct, |
| 'Content-length': str(len(body)) |
| } |
| return Request(self.url, body, headers) |
| |
| def search(self, terms, operator=None): # pragma: no cover |
| if isinstance(terms, string_types): |
| terms = {'name': terms} |
| rpc_proxy = ServerProxy(self.url, timeout=3.0) |
| try: |
| return rpc_proxy.search(terms, operator or 'and') |
| finally: |
| rpc_proxy('close')() |