1""" 2distutils.command.upload 3 4Implements the Distutils 'upload' subcommand (upload package to a package 5index). 6""" 7 8import os 9import io 10import platform 11import hashlib 12from base64 import standard_b64encode 13from urllib.request import urlopen, Request, HTTPError 14from urllib.parse import urlparse 15from distutils.errors import DistutilsError, DistutilsOptionError 16from distutils.core import PyPIRCCommand 17from distutils.spawn import spawn 18from distutils import log 19 20class upload(PyPIRCCommand): 21 22 description = "upload binary package to PyPI" 23 24 user_options = PyPIRCCommand.user_options + [ 25 ('sign', 's', 26 'sign files to upload using gpg'), 27 ('identity=', 'i', 'GPG identity used to sign files'), 28 ] 29 30 boolean_options = PyPIRCCommand.boolean_options + ['sign'] 31 32 def initialize_options(self): 33 PyPIRCCommand.initialize_options(self) 34 self.username = '' 35 self.password = '' 36 self.show_response = 0 37 self.sign = False 38 self.identity = None 39 40 def finalize_options(self): 41 PyPIRCCommand.finalize_options(self) 42 if self.identity and not self.sign: 43 raise DistutilsOptionError( 44 "Must use --sign for --identity to have meaning" 45 ) 46 config = self._read_pypirc() 47 if config != {}: 48 self.username = config['username'] 49 self.password = config['password'] 50 self.repository = config['repository'] 51 self.realm = config['realm'] 52 53 # getting the password from the distribution 54 # if previously set by the register command 55 if not self.password and self.distribution.password: 56 self.password = self.distribution.password 57 58 def run(self): 59 if not self.distribution.dist_files: 60 msg = ("Must create and upload files in one command " 61 "(e.g. setup.py sdist upload)") 62 raise DistutilsOptionError(msg) 63 for command, pyversion, filename in self.distribution.dist_files: 64 self.upload_file(command, pyversion, filename) 65 66 def upload_file(self, command, pyversion, filename): 67 # Makes sure the repository URL is compliant 68 schema, netloc, url, params, query, fragments = \ 69 urlparse(self.repository) 70 if params or query or fragments: 71 raise AssertionError("Incompatible url %s" % self.repository) 72 73 if schema not in ('http', 'https'): 74 raise AssertionError("unsupported schema " + schema) 75 76 # Sign if requested 77 if self.sign: 78 gpg_args = ["gpg", "--detach-sign", "-a", filename] 79 if self.identity: 80 gpg_args[2:2] = ["--local-user", self.identity] 81 spawn(gpg_args, 82 dry_run=self.dry_run) 83 84 # Fill in the data - send all the meta-data in case we need to 85 # register a new release 86 f = open(filename,'rb') 87 try: 88 content = f.read() 89 finally: 90 f.close() 91 meta = self.distribution.metadata 92 data = { 93 # action 94 ':action': 'file_upload', 95 'protocol_version': '1', 96 97 # identify release 98 'name': meta.get_name(), 99 'version': meta.get_version(), 100 101 # file content 102 'content': (os.path.basename(filename),content), 103 'filetype': command, 104 'pyversion': pyversion, 105 'md5_digest': hashlib.md5(content).hexdigest(), 106 107 # additional meta-data 108 'metadata_version': '1.0', 109 'summary': meta.get_description(), 110 'home_page': meta.get_url(), 111 'author': meta.get_contact(), 112 'author_email': meta.get_contact_email(), 113 'license': meta.get_licence(), 114 'description': meta.get_long_description(), 115 'keywords': meta.get_keywords(), 116 'platform': meta.get_platforms(), 117 'classifiers': meta.get_classifiers(), 118 'download_url': meta.get_download_url(), 119 # PEP 314 120 'provides': meta.get_provides(), 121 'requires': meta.get_requires(), 122 'obsoletes': meta.get_obsoletes(), 123 } 124 125 data['comment'] = '' 126 127 if self.sign: 128 with open(filename + ".asc", "rb") as f: 129 data['gpg_signature'] = (os.path.basename(filename) + ".asc", 130 f.read()) 131 132 # set up the authentication 133 user_pass = (self.username + ":" + self.password).encode('ascii') 134 # The exact encoding of the authentication string is debated. 135 # Anyway PyPI only accepts ascii for both username or password. 136 auth = "Basic " + standard_b64encode(user_pass).decode('ascii') 137 138 # Build up the MIME payload for the POST data 139 boundary = '--------------GHSKFJDLGDS7543FJKLFHRE75642756743254' 140 sep_boundary = b'\r\n--' + boundary.encode('ascii') 141 end_boundary = sep_boundary + b'--\r\n' 142 body = io.BytesIO() 143 for key, value in data.items(): 144 title = '\r\nContent-Disposition: form-data; name="%s"' % key 145 # handle multiple entries for the same name 146 if not isinstance(value, list): 147 value = [value] 148 for value in value: 149 if type(value) is tuple: 150 title += '; filename="%s"' % value[0] 151 value = value[1] 152 else: 153 value = str(value).encode('utf-8') 154 body.write(sep_boundary) 155 body.write(title.encode('utf-8')) 156 body.write(b"\r\n\r\n") 157 body.write(value) 158 body.write(end_boundary) 159 body = body.getvalue() 160 161 msg = "Submitting %s to %s" % (filename, self.repository) 162 self.announce(msg, log.INFO) 163 164 # build the Request 165 headers = { 166 'Content-type': 'multipart/form-data; boundary=%s' % boundary, 167 'Content-length': str(len(body)), 168 'Authorization': auth, 169 } 170 171 request = Request(self.repository, data=body, 172 headers=headers) 173 # send the data 174 try: 175 result = urlopen(request) 176 status = result.getcode() 177 reason = result.msg 178 except HTTPError as e: 179 status = e.code 180 reason = e.msg 181 except OSError as e: 182 self.announce(str(e), log.ERROR) 183 raise 184 185 if status == 200: 186 self.announce('Server response (%s): %s' % (status, reason), 187 log.INFO) 188 if self.show_response: 189 text = self._read_pypi_response(result) 190 msg = '\n'.join(('-' * 75, text, '-' * 75)) 191 self.announce(msg, log.INFO) 192 else: 193 msg = 'Upload failed (%s): %s' % (status, reason) 194 self.announce(msg, log.ERROR) 195 raise DistutilsError(msg) 196