• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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