upload_docs.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. # -*- coding: utf-8 -*-
  2. """upload_docs
  3. Implements a Distutils 'upload_docs' subcommand (upload documentation to
  4. PyPI's pythonhosted.org).
  5. """
  6. from base64 import standard_b64encode
  7. from distutils import log
  8. from distutils.errors import DistutilsOptionError
  9. import os
  10. import socket
  11. import zipfile
  12. import tempfile
  13. import shutil
  14. import itertools
  15. import functools
  16. from setuptools.extern import six
  17. from setuptools.extern.six.moves import http_client, urllib
  18. from pkg_resources import iter_entry_points
  19. from .upload import upload
  20. def _encode(s):
  21. errors = 'strict' if six.PY2 else 'surrogateescape'
  22. return s.encode('utf-8', errors)
  23. class upload_docs(upload):
  24. # override the default repository as upload_docs isn't
  25. # supported by Warehouse (and won't be).
  26. DEFAULT_REPOSITORY = 'https://pypi.python.org/pypi/'
  27. description = 'Upload documentation to PyPI'
  28. user_options = [
  29. ('repository=', 'r',
  30. "url of repository [default: %s]" % upload.DEFAULT_REPOSITORY),
  31. ('show-response', None,
  32. 'display full response text from server'),
  33. ('upload-dir=', None, 'directory to upload'),
  34. ]
  35. boolean_options = upload.boolean_options
  36. def has_sphinx(self):
  37. if self.upload_dir is None:
  38. for ep in iter_entry_points('distutils.commands', 'build_sphinx'):
  39. return True
  40. sub_commands = [('build_sphinx', has_sphinx)]
  41. def initialize_options(self):
  42. upload.initialize_options(self)
  43. self.upload_dir = None
  44. self.target_dir = None
  45. def finalize_options(self):
  46. upload.finalize_options(self)
  47. if self.upload_dir is None:
  48. if self.has_sphinx():
  49. build_sphinx = self.get_finalized_command('build_sphinx')
  50. self.target_dir = build_sphinx.builder_target_dir
  51. else:
  52. build = self.get_finalized_command('build')
  53. self.target_dir = os.path.join(build.build_base, 'docs')
  54. else:
  55. self.ensure_dirname('upload_dir')
  56. self.target_dir = self.upload_dir
  57. if 'pypi.python.org' in self.repository:
  58. log.warn("Upload_docs command is deprecated. Use RTD instead.")
  59. self.announce('Using upload directory %s' % self.target_dir)
  60. def create_zipfile(self, filename):
  61. zip_file = zipfile.ZipFile(filename, "w")
  62. try:
  63. self.mkpath(self.target_dir) # just in case
  64. for root, dirs, files in os.walk(self.target_dir):
  65. if root == self.target_dir and not files:
  66. tmpl = "no files found in upload directory '%s'"
  67. raise DistutilsOptionError(tmpl % self.target_dir)
  68. for name in files:
  69. full = os.path.join(root, name)
  70. relative = root[len(self.target_dir):].lstrip(os.path.sep)
  71. dest = os.path.join(relative, name)
  72. zip_file.write(full, dest)
  73. finally:
  74. zip_file.close()
  75. def run(self):
  76. # Run sub commands
  77. for cmd_name in self.get_sub_commands():
  78. self.run_command(cmd_name)
  79. tmp_dir = tempfile.mkdtemp()
  80. name = self.distribution.metadata.get_name()
  81. zip_file = os.path.join(tmp_dir, "%s.zip" % name)
  82. try:
  83. self.create_zipfile(zip_file)
  84. self.upload_file(zip_file)
  85. finally:
  86. shutil.rmtree(tmp_dir)
  87. @staticmethod
  88. def _build_part(item, sep_boundary):
  89. key, values = item
  90. title = '\nContent-Disposition: form-data; name="%s"' % key
  91. # handle multiple entries for the same name
  92. if not isinstance(values, list):
  93. values = [values]
  94. for value in values:
  95. if isinstance(value, tuple):
  96. title += '; filename="%s"' % value[0]
  97. value = value[1]
  98. else:
  99. value = _encode(value)
  100. yield sep_boundary
  101. yield _encode(title)
  102. yield b"\n\n"
  103. yield value
  104. if value and value[-1:] == b'\r':
  105. yield b'\n' # write an extra newline (lurve Macs)
  106. @classmethod
  107. def _build_multipart(cls, data):
  108. """
  109. Build up the MIME payload for the POST data
  110. """
  111. boundary = b'--------------GHSKFJDLGDS7543FJKLFHRE75642756743254'
  112. sep_boundary = b'\n--' + boundary
  113. end_boundary = sep_boundary + b'--'
  114. end_items = end_boundary, b"\n",
  115. builder = functools.partial(
  116. cls._build_part,
  117. sep_boundary=sep_boundary,
  118. )
  119. part_groups = map(builder, data.items())
  120. parts = itertools.chain.from_iterable(part_groups)
  121. body_items = itertools.chain(parts, end_items)
  122. content_type = 'multipart/form-data; boundary=%s' % boundary.decode('ascii')
  123. return b''.join(body_items), content_type
  124. def upload_file(self, filename):
  125. with open(filename, 'rb') as f:
  126. content = f.read()
  127. meta = self.distribution.metadata
  128. data = {
  129. ':action': 'doc_upload',
  130. 'name': meta.get_name(),
  131. 'content': (os.path.basename(filename), content),
  132. }
  133. # set up the authentication
  134. credentials = _encode(self.username + ':' + self.password)
  135. credentials = standard_b64encode(credentials)
  136. if not six.PY2:
  137. credentials = credentials.decode('ascii')
  138. auth = "Basic " + credentials
  139. body, ct = self._build_multipart(data)
  140. msg = "Submitting documentation to %s" % (self.repository)
  141. self.announce(msg, log.INFO)
  142. # build the Request
  143. # We can't use urllib2 since we need to send the Basic
  144. # auth right with the first request
  145. schema, netloc, url, params, query, fragments = \
  146. urllib.parse.urlparse(self.repository)
  147. assert not params and not query and not fragments
  148. if schema == 'http':
  149. conn = http_client.HTTPConnection(netloc)
  150. elif schema == 'https':
  151. conn = http_client.HTTPSConnection(netloc)
  152. else:
  153. raise AssertionError("unsupported schema " + schema)
  154. data = ''
  155. try:
  156. conn.connect()
  157. conn.putrequest("POST", url)
  158. content_type = ct
  159. conn.putheader('Content-type', content_type)
  160. conn.putheader('Content-length', str(len(body)))
  161. conn.putheader('Authorization', auth)
  162. conn.endheaders()
  163. conn.send(body)
  164. except socket.error as e:
  165. self.announce(str(e), log.ERROR)
  166. return
  167. r = conn.getresponse()
  168. if r.status == 200:
  169. msg = 'Server response (%s): %s' % (r.status, r.reason)
  170. self.announce(msg, log.INFO)
  171. elif r.status == 301:
  172. location = r.getheader('Location')
  173. if location is None:
  174. location = 'https://pythonhosted.org/%s/' % meta.get_name()
  175. msg = 'Upload successful. Visit %s' % location
  176. self.announce(msg, log.INFO)
  177. else:
  178. msg = 'Upload failed (%s): %s' % (r.status, r.reason)
  179. self.announce(msg, log.ERROR)
  180. if self.show_response:
  181. print('-' * 75, r.read(), '-' * 75)