codekingpro/portable-devtools
114k
1"""2distutils.command.upload3 4Implements the Distutils 'upload' subcommand (upload package to a package5index).6"""7 8import hashlib9import io10import logging11import os12from base64 import standard_b64encode13from urllib.parse import urlparse14from urllib.request import HTTPError, Request, urlopen15 16from .._itertools import always_iterable17from ..core import PyPIRCCommand18from ..errors import DistutilsError, DistutilsOptionError19from ..spawn import spawn20 21# PyPI Warehouse supports MD5, SHA256, and Blake2 (blake2-256)22# https://bugs.python.org/issue4069823_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 description = "upload binary package to PyPI"32 33 user_options = PyPIRCCommand.user_options + [34 ('sign', 's', 'sign files to upload using gpg'),35 ('identity=', 'i', 'GPG identity used to sign files'),36 ]37 38 boolean_options = PyPIRCCommand.boolean_options + ['sign']39 40 def initialize_options(self):41 PyPIRCCommand.initialize_options(self)42 self.username = ''43 self.password = ''44 self.show_response = 045 self.sign = False46 self.identity = None47 48 def finalize_options(self):49 PyPIRCCommand.finalize_options(self)50 if self.identity and not self.sign:51 raise DistutilsOptionError("Must use --sign for --identity to have meaning")52 config = self._read_pypirc()53 if config != {}:54 self.username = config['username']55 self.password = config['password']56 self.repository = config['repository']57 self.realm = config['realm']58 59 # getting the password from the distribution60 # if previously set by the register command61 if not self.password and self.distribution.password:62 self.password = self.distribution.password63 64 def run(self):65 if not self.distribution.dist_files:66 msg = (67 "Must create and upload files in one command "68 "(e.g. setup.py sdist upload)"69 )70 raise DistutilsOptionError(msg)71 for command, pyversion, filename in self.distribution.dist_files:72 self.upload_file(command, pyversion, filename)73 74 def upload_file(self, command, pyversion, filename): # noqa: C90175 # Makes sure the repository URL is compliant76 schema, netloc, url, params, query, fragments = urlparse(self.repository)77 if params or query or fragments:78 raise AssertionError("Incompatible url %s" % self.repository)79 80 if schema not in ('http', 'https'):81 raise AssertionError("unsupported schema " + schema)82 83 # Sign if requested84 if self.sign:85 gpg_args = ["gpg", "--detach-sign", "-a", filename]86 if self.identity:87 gpg_args[2:2] = ["--local-user", self.identity]88 spawn(gpg_args, dry_run=self.dry_run)89 90 # Fill in the data - send all the meta-data in case we need to91 # register a new release92 f = open(filename, 'rb')93 try:94 content = f.read()95 finally:96 f.close()97 98 meta = self.distribution.metadata99 data = {100 # action101 ':action': 'file_upload',102 'protocol_version': '1',103 # identify release104 'name': meta.get_name(),105 'version': meta.get_version(),106 # file content107 'content': (os.path.basename(filename), content),108 'filetype': command,109 'pyversion': pyversion,110 # additional meta-data111 'metadata_version': '1.0',112 'summary': meta.get_description(),113 'home_page': meta.get_url(),114 'author': meta.get_contact(),115 'author_email': meta.get_contact_email(),116 'license': meta.get_licence(),117 'description': meta.get_long_description(),118 'keywords': meta.get_keywords(),119 'platform': meta.get_platforms(),120 'classifiers': meta.get_classifiers(),121 'download_url': meta.get_download_url(),122 # PEP 314123 'provides': meta.get_provides(),124 'requires': meta.get_requires(),125 'obsoletes': meta.get_obsoletes(),126 }127 128 data['comment'] = ''129 130 # file content digests131 for digest_name, digest_cons in _FILE_CONTENT_DIGESTS.items():132 if digest_cons is None:133 continue134 try:135 data[digest_name] = digest_cons(content).hexdigest()136 except ValueError:137 # hash digest not available or blocked by security policy138 pass139 140 if self.sign:141 with open(filename + ".asc", "rb") as f:142 data['gpg_signature'] = (os.path.basename(filename) + ".asc", f.read())143 144 # set up the authentication145 user_pass = (self.username + ":" + self.password).encode('ascii')146 # The exact encoding of the authentication string is debated.147 # Anyway PyPI only accepts ascii for both username or password.148 auth = "Basic " + standard_b64encode(user_pass).decode('ascii')149 150 # Build up the MIME payload for the POST data151 boundary = '--------------GHSKFJDLGDS7543FJKLFHRE75642756743254'152 sep_boundary = b'\r\n--' + boundary.encode('ascii')153 end_boundary = sep_boundary + b'--\r\n'154 body = io.BytesIO()155 for key, values in data.items():156 title = '\r\nContent-Disposition: form-data; name="%s"' % key157 for value in make_iterable(values):158 if type(value) is tuple:159 title += '; filename="%s"' % value[0]160 value = value[1]161 else:162 value = str(value).encode('utf-8')163 body.write(sep_boundary)164 body.write(title.encode('utf-8'))165 body.write(b"\r\n\r\n")166 body.write(value)167 body.write(end_boundary)168 body = body.getvalue()169 170 msg = f"Submitting {filename} to {self.repository}"171 self.announce(msg, logging.INFO)172 173 # build the Request174 headers = {175 'Content-type': 'multipart/form-data; boundary=%s' % boundary,176 'Content-length': str(len(body)),177 'Authorization': auth,178 }179 180 request = Request(self.repository, data=body, headers=headers)181 # send the data182 try:183 result = urlopen(request)184 status = result.getcode()185 reason = result.msg186 except HTTPError as e:187 status = e.code188 reason = e.msg189 except OSError as e:190 self.announce(str(e), logging.ERROR)191 raise192 193 if status == 200:194 self.announce(f'Server response ({status}): {reason}', logging.INFO)195 if self.show_response:196 text = self._read_pypi_response(result)197 msg = '\n'.join(('-' * 75, text, '-' * 75))198 self.announce(msg, logging.INFO)199 else:200 msg = f'Upload failed ({status}): {reason}'201 self.announce(msg, logging.ERROR)202 raise DistutilsError(msg)203 204 205def make_iterable(values):206 if values is None:207 return [None]208 return always_iterable(values, base_type=(bytes, str, tuple))209 