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