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