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