upload.py 7.4 KB

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