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