• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1"""
2distutils.command.upload
3
4Implements the Distutils 'upload' subcommand (upload package to a package
5index).
6"""
7
8import os
9import io
10import hashlib
11from base64 import standard_b64encode
12from urllib.request import urlopen, Request, HTTPError
13from urllib.parse import urlparse
14from distutils.errors import DistutilsError, DistutilsOptionError
15from distutils.core import PyPIRCCommand
16from distutils.spawn import spawn
17from distutils import log
18
19
20# PyPI Warehouse supports MD5, SHA256, and Blake2 (blake2-256)
21# https://bugs.python.org/issue40698
22_FILE_CONTENT_DIGESTS = {
23    "md5_digest": getattr(hashlib, "md5", None),
24    "sha256_digest": getattr(hashlib, "sha256", None),
25    "blake2_256_digest": getattr(hashlib, "blake2b", None),
26}
27
28
29class upload(PyPIRCCommand):
30
31    description = "upload binary package to PyPI"
32
33    user_options = PyPIRCCommand.user_options + [
34        ('sign', 's',
35         'sign files to upload using gpg'),
36        ('identity=', 'i', 'GPG identity used to sign files'),
37        ]
38
39    boolean_options = PyPIRCCommand.boolean_options + ['sign']
40
41    def initialize_options(self):
42        PyPIRCCommand.initialize_options(self)
43        self.username = ''
44        self.password = ''
45        self.show_response = 0
46        self.sign = False
47        self.identity = None
48
49    def finalize_options(self):
50        PyPIRCCommand.finalize_options(self)
51        if self.identity and not self.sign:
52            raise DistutilsOptionError(
53                "Must use --sign for --identity to have meaning"
54            )
55        config = self._read_pypirc()
56        if config != {}:
57            self.username = config['username']
58            self.password = config['password']
59            self.repository = config['repository']
60            self.realm = config['realm']
61
62        # getting the password from the distribution
63        # if previously set by the register command
64        if not self.password and self.distribution.password:
65            self.password = self.distribution.password
66
67    def run(self):
68        if not self.distribution.dist_files:
69            msg = ("Must create and upload files in one command "
70                   "(e.g. setup.py sdist upload)")
71            raise DistutilsOptionError(msg)
72        for command, pyversion, filename in self.distribution.dist_files:
73            self.upload_file(command, pyversion, filename)
74
75    def upload_file(self, command, pyversion, filename):
76        # Makes sure the repository URL is compliant
77        schema, netloc, url, params, query, fragments = \
78            urlparse(self.repository)
79        if params or query or fragments:
80            raise AssertionError("Incompatible url %s" % self.repository)
81
82        if schema not in ('http', 'https'):
83            raise AssertionError("unsupported schema " + schema)
84
85        # Sign if requested
86        if self.sign:
87            gpg_args = ["gpg", "--detach-sign", "-a", filename]
88            if self.identity:
89                gpg_args[2:2] = ["--local-user", self.identity]
90            spawn(gpg_args,
91                  dry_run=self.dry_run)
92
93        # Fill in the data - send all the meta-data in case we need to
94        # register a new release
95        f = open(filename,'rb')
96        try:
97            content = f.read()
98        finally:
99            f.close()
100
101        meta = self.distribution.metadata
102        data = {
103            # action
104            ':action': 'file_upload',
105            'protocol_version': '1',
106
107            # identify release
108            'name': meta.get_name(),
109            'version': meta.get_version(),
110
111            # file content
112            'content': (os.path.basename(filename),content),
113            'filetype': command,
114            'pyversion': pyversion,
115
116            # additional meta-data
117            'metadata_version': '1.0',
118            'summary': meta.get_description(),
119            'home_page': meta.get_url(),
120            'author': meta.get_contact(),
121            'author_email': meta.get_contact_email(),
122            'license': meta.get_licence(),
123            'description': meta.get_long_description(),
124            'keywords': meta.get_keywords(),
125            'platform': meta.get_platforms(),
126            'classifiers': meta.get_classifiers(),
127            'download_url': meta.get_download_url(),
128            # PEP 314
129            'provides': meta.get_provides(),
130            'requires': meta.get_requires(),
131            'obsoletes': meta.get_obsoletes(),
132            }
133
134        data['comment'] = ''
135
136        # file content digests
137        for digest_name, digest_cons in _FILE_CONTENT_DIGESTS.items():
138            if digest_cons is None:
139                continue
140            try:
141                data[digest_name] = digest_cons(content).hexdigest()
142            except ValueError:
143                # hash digest not available or blocked by security policy
144                pass
145
146        if self.sign:
147            with open(filename + ".asc", "rb") as f:
148                data['gpg_signature'] = (os.path.basename(filename) + ".asc",
149                                         f.read())
150
151        # set up the authentication
152        user_pass = (self.username + ":" + self.password).encode('ascii')
153        # The exact encoding of the authentication string is debated.
154        # Anyway PyPI only accepts ascii for both username or password.
155        auth = "Basic " + standard_b64encode(user_pass).decode('ascii')
156
157        # Build up the MIME payload for the POST data
158        boundary = '--------------GHSKFJDLGDS7543FJKLFHRE75642756743254'
159        sep_boundary = b'\r\n--' + boundary.encode('ascii')
160        end_boundary = sep_boundary + b'--\r\n'
161        body = io.BytesIO()
162        for key, value in data.items():
163            title = '\r\nContent-Disposition: form-data; name="%s"' % key
164            # handle multiple entries for the same name
165            if not isinstance(value, list):
166                value = [value]
167            for value in value:
168                if type(value) is tuple:
169                    title += '; filename="%s"' % value[0]
170                    value = value[1]
171                else:
172                    value = str(value).encode('utf-8')
173                body.write(sep_boundary)
174                body.write(title.encode('utf-8'))
175                body.write(b"\r\n\r\n")
176                body.write(value)
177        body.write(end_boundary)
178        body = body.getvalue()
179
180        msg = "Submitting %s to %s" % (filename, self.repository)
181        self.announce(msg, log.INFO)
182
183        # build the Request
184        headers = {
185            'Content-type': 'multipart/form-data; boundary=%s' % boundary,
186            'Content-length': str(len(body)),
187            'Authorization': auth,
188        }
189
190        request = Request(self.repository, data=body,
191                          headers=headers)
192        # send the data
193        try:
194            result = urlopen(request)
195            status = result.getcode()
196            reason = result.msg
197        except HTTPError as e:
198            status = e.code
199            reason = e.msg
200        except OSError as e:
201            self.announce(str(e), log.ERROR)
202            raise
203
204        if status == 200:
205            self.announce('Server response (%s): %s' % (status, reason),
206                          log.INFO)
207            if self.show_response:
208                text = self._read_pypi_response(result)
209                msg = '\n'.join(('-' * 75, text, '-' * 75))
210                self.announce(msg, log.INFO)
211        else:
212            msg = 'Upload failed (%s): %s' % (status, reason)
213            self.announce(msg, log.ERROR)
214            raise DistutilsError(msg)
215