blob: df265c95912762f2cecdcc0c2517d4ac290e158b [file] [log] [blame]
Tarek Ziade1231a4e2011-05-19 13:07:25 +02001"""Upload a distribution to a project index."""
2
3import os
4import socket
5import logging
6import platform
7import urllib.parse
8from io import BytesIO
9from base64 import standard_b64encode
10from hashlib import md5
11from urllib.error import HTTPError
12from urllib.request import urlopen, Request
13
14from packaging import logger
15from packaging.errors import PackagingOptionError
16from packaging.util import (spawn, read_pypirc, DEFAULT_REPOSITORY,
17 DEFAULT_REALM)
18from packaging.command.cmd import Command
19
20
21class upload(Command):
22
23 description = "upload distribution to PyPI"
24
25 user_options = [
26 ('repository=', 'r',
27 "repository URL [default: %s]" % DEFAULT_REPOSITORY),
28 ('show-response', None,
29 "display full response text from server"),
30 ('sign', 's',
31 "sign files to upload using gpg"),
32 ('identity=', 'i',
33 "GPG identity used to sign files"),
34 ('upload-docs', None,
35 "upload documentation too"),
36 ]
37
38 boolean_options = ['show-response', 'sign']
39
40 def initialize_options(self):
41 self.repository = None
42 self.realm = None
43 self.show_response = False
44 self.username = ''
45 self.password = ''
46 self.show_response = False
47 self.sign = False
48 self.identity = None
49 self.upload_docs = False
50
51 def finalize_options(self):
52 if self.repository is None:
53 self.repository = DEFAULT_REPOSITORY
54 if self.realm is None:
55 self.realm = DEFAULT_REALM
56 if self.identity and not self.sign:
57 raise PackagingOptionError(
58 "Must use --sign for --identity to have meaning")
59 config = read_pypirc(self.repository, self.realm)
60 if config != {}:
61 self.username = config['username']
62 self.password = config['password']
63 self.repository = config['repository']
64 self.realm = config['realm']
65
66 # getting the password from the distribution
67 # if previously set by the register command
68 if not self.password and self.distribution.password:
69 self.password = self.distribution.password
70
71 def run(self):
72 if not self.distribution.dist_files:
73 raise PackagingOptionError(
74 "No dist file created in earlier command")
75 for command, pyversion, filename in self.distribution.dist_files:
76 self.upload_file(command, pyversion, filename)
77 if self.upload_docs:
78 upload_docs = self.get_finalized_command("upload_docs")
79 upload_docs.repository = self.repository
80 upload_docs.username = self.username
81 upload_docs.password = self.password
82 upload_docs.run()
83
84 # XXX to be refactored with register.post_to_server
85 def upload_file(self, command, pyversion, filename):
86 # Makes sure the repository URL is compliant
87 scheme, netloc, url, params, query, fragments = \
88 urllib.parse.urlparse(self.repository)
89 if params or query or fragments:
90 raise AssertionError("Incompatible url %s" % self.repository)
91
92 if scheme not in ('http', 'https'):
93 raise AssertionError("unsupported scheme " + scheme)
94
95 # Sign if requested
96 if self.sign:
97 gpg_args = ["gpg", "--detach-sign", "-a", filename]
98 if self.identity:
99 gpg_args[2:2] = ["--local-user", self.identity]
100 spawn(gpg_args,
101 dry_run=self.dry_run)
102
103 # Fill in the data - send all the metadata in case we need to
104 # register a new release
105 with open(filename, 'rb') as f:
106 content = f.read()
107
108 data = self.distribution.metadata.todict()
109
110 # extra upload infos
111 data[':action'] = 'file_upload'
112 data['protcol_version'] = '1'
113 data['content'] = (os.path.basename(filename), content)
114 data['filetype'] = command
115 data['pyversion'] = pyversion
116 data['md5_digest'] = md5(content).hexdigest()
117
118 if command == 'bdist_dumb':
119 data['comment'] = 'built for %s' % platform.platform(terse=True)
120
121 if self.sign:
122 with open(filename + '.asc') as fp:
123 sig = fp.read()
124 data['gpg_signature'] = [
125 (os.path.basename(filename) + ".asc", sig)]
126
127 # set up the authentication
128 # The exact encoding of the authentication string is debated.
129 # Anyway PyPI only accepts ascii for both username or password.
130 user_pass = (self.username + ":" + self.password).encode('ascii')
131 auth = b"Basic " + standard_b64encode(user_pass)
132
133 # Build up the MIME payload for the POST data
134 boundary = b'--------------GHSKFJDLGDS7543FJKLFHRE75642756743254'
135 sep_boundary = b'\n--' + boundary
136 end_boundary = sep_boundary + b'--'
137 body = BytesIO()
138
139 file_fields = ('content', 'gpg_signature')
140
141 for key, value in data.items():
142 # handle multiple entries for the same name
143 if not isinstance(value, tuple):
144 value = [value]
145
146 content_dispo = '\nContent-Disposition: form-data; name="%s"' % key
147
148 if key in file_fields:
149 filename_, content = value
150 filename_ = ';filename="%s"' % filename_
151 body.write(sep_boundary)
152 body.write(content_dispo.encode('utf-8'))
153 body.write(filename_.encode('utf-8'))
154 body.write(b"\n\n")
155 body.write(content)
156 else:
157 for value in value:
158 value = str(value).encode('utf-8')
159 body.write(sep_boundary)
160 body.write(content_dispo.encode('utf-8'))
161 body.write(b"\n\n")
162 body.write(value)
163 if value and value.endswith(b'\r'):
164 # write an extra newline (lurve Macs)
165 body.write(b'\n')
166
167 body.write(end_boundary)
168 body.write(b"\n")
169 body = body.getvalue()
170
171 logger.info("Submitting %s to %s", filename, self.repository)
172
173 # build the Request
174 headers = {'Content-type':
175 'multipart/form-data; boundary=%s' %
176 boundary.decode('ascii'),
177 'Content-length': str(len(body)),
178 'Authorization': auth}
179
180 request = Request(self.repository, data=body,
181 headers=headers)
182 # send the data
183 try:
184 result = urlopen(request)
185 status = result.code
186 reason = result.msg
187 except socket.error as e:
188 logger.error(e)
189 return
190 except HTTPError as e:
191 status = e.code
192 reason = e.msg
193
194 if status == 200:
195 logger.info('Server response (%s): %s', status, reason)
196 else:
197 logger.error('Upload failed (%s): %s', status, reason)
198
199 if self.show_response and logger.isEnabledFor(logging.INFO):
200 sep = '-' * 75
201 logger.info('%s\n%s\n%s', sep, result.read().decode(), sep)