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