upload.py 7.3 KB

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