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