upload_docs.py 7.6 KB

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