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