upload.py 7.07 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201
"""Upload a distribution to a project index."""

import os
import socket
import logging
import platform
import urllib.parse
from io import BytesIO
from base64 import standard_b64encode
from hashlib import md5
from urllib.error import HTTPError
from urllib.request import urlopen, Request

from packaging import logger
from packaging.errors import PackagingOptionError
from packaging.util import (spawn, read_pypirc, DEFAULT_REPOSITORY,
                            DEFAULT_REALM)
from packaging.command.cmd import Command


class upload(Command):

    description = "upload distribution to PyPI"

    user_options = [
        ('repository=', 'r',
         "repository URL [default: %s]" % DEFAULT_REPOSITORY),
        ('show-response', None,
         "display full response text from server"),
        ('sign', 's',
         "sign files to upload using gpg"),
        ('identity=', 'i',
         "GPG identity used to sign files"),
        ('upload-docs', None,
         "upload documentation too"),
        ]

    boolean_options = ['show-response', 'sign']

    def initialize_options(self):
        self.repository = None
        self.realm = None
        self.show_response = False
        self.username = ''
        self.password = ''
        self.show_response = False
        self.sign = False
        self.identity = None
        self.upload_docs = False

    def finalize_options(self):
        if self.repository is None:
            self.repository = DEFAULT_REPOSITORY
        if self.realm is None:
            self.realm = DEFAULT_REALM
        if self.identity and not self.sign:
            raise PackagingOptionError(
                "Must use --sign for --identity to have meaning")
        config = read_pypirc(self.repository, self.realm)
        if config != {}:
            self.username = config['username']
            self.password = config['password']
            self.repository = config['repository']
            self.realm = config['realm']

        # getting the password from the distribution
        # if previously set by the register command
        if not self.password and self.distribution.password:
            self.password = self.distribution.password

    def run(self):
        if not self.distribution.dist_files:
            raise PackagingOptionError(
                "No dist file created in earlier command")
        for command, pyversion, filename in self.distribution.dist_files:
            self.upload_file(command, pyversion, filename)
        if self.upload_docs:
            upload_docs = self.get_finalized_command("upload_docs")
            upload_docs.repository = self.repository
            upload_docs.username = self.username
            upload_docs.password = self.password
            upload_docs.run()

    # XXX to be refactored with register.post_to_server
    def upload_file(self, command, pyversion, filename):
        # Makes sure the repository URL is compliant
        scheme, netloc, url, params, query, fragments = \
            urllib.parse.urlparse(self.repository)
        if params or query or fragments:
            raise AssertionError("Incompatible url %s" % self.repository)

        if scheme not in ('http', 'https'):
            raise AssertionError("unsupported scheme " + scheme)

        # Sign if requested
        if self.sign:
            gpg_args = ["gpg", "--detach-sign", "-a", filename]
            if self.identity:
                gpg_args[2:2] = ["--local-user", self.identity]
            spawn(gpg_args,
                  dry_run=self.dry_run)

        # Fill in the data - send all the metadata in case we need to
        # register a new release
        with open(filename, 'rb') as f:
            content = f.read()

        data = self.distribution.metadata.todict()

        # extra upload infos
        data[':action'] = 'file_upload'
        data['protcol_version'] = '1'
        data['content'] = (os.path.basename(filename), content)
        data['filetype'] = command
        data['pyversion'] = pyversion
        data['md5_digest'] = md5(content).hexdigest()

        if command == 'bdist_dumb':
            data['comment'] = 'built for %s' % platform.platform(terse=True)

        if self.sign:
            with open(filename + '.asc') as fp:
                sig = fp.read()
            data['gpg_signature'] = [
                (os.path.basename(filename) + ".asc", sig)]

        # set up the authentication
        # The exact encoding of the authentication string is debated.
        # Anyway PyPI only accepts ascii for both username or password.
        user_pass = (self.username + ":" + self.password).encode('ascii')
        auth = b"Basic " + standard_b64encode(user_pass)

        # Build up the MIME payload for the POST data
        boundary = b'--------------GHSKFJDLGDS7543FJKLFHRE75642756743254'
        sep_boundary = b'\n--' + boundary
        end_boundary = sep_boundary + b'--'
        body = BytesIO()

        file_fields = ('content', 'gpg_signature')

        for key, value in data.items():
            # handle multiple entries for the same name
            if not isinstance(value, tuple):
                value = [value]

            content_dispo = '\nContent-Disposition: form-data; name="%s"' % key

            if key in file_fields:
                filename_, content = value
                filename_ = ';filename="%s"' % filename_
                body.write(sep_boundary)
                body.write(content_dispo.encode('utf-8'))
                body.write(filename_.encode('utf-8'))
                body.write(b"\n\n")
                body.write(content)
            else:
                for value in value:
                    value = str(value).encode('utf-8')
                    body.write(sep_boundary)
                    body.write(content_dispo.encode('utf-8'))
                    body.write(b"\n\n")
                    body.write(value)
                    if value and value.endswith(b'\r'):
                        # write an extra newline (lurve Macs)
                        body.write(b'\n')

        body.write(end_boundary)
        body.write(b"\n")
        body = body.getvalue()

        logger.info("Submitting %s to %s", filename, self.repository)

        # build the Request
        headers = {'Content-type':
                        'multipart/form-data; boundary=%s' %
                        boundary.decode('ascii'),
                   'Content-length': str(len(body)),
                   'Authorization': auth}

        request = Request(self.repository, data=body,
                          headers=headers)
        # send the data
        try:
            result = urlopen(request)
            status = result.code
            reason = result.msg
        except socket.error as e:
            logger.error(e)
            return
        except HTTPError as e:
            status = e.code
            reason = e.msg

        if status == 200:
            logger.info('Server response (%s): %s', status, reason)
        else:
            logger.error('Upload failed (%s): %s', status, reason)

        if self.show_response and logger.isEnabledFor(logging.INFO):
            sep = '-' * 75
            logger.info('%s\n%s\n%s', sep, result.read().decode(), sep)