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