comparison env/lib/python3.9/site-packages/distlib/index.py @ 0:4f3585e2f14b draft default tip

"planemo upload commit 60cee0fc7c0cda8592644e1aad72851dec82c959"
author shellac
date Mon, 22 Mar 2021 18:12:50 +0000
parents
children
comparison
equal deleted inserted replaced
-1:000000000000 0:4f3585e2f14b
1 # -*- coding: utf-8 -*-
2 #
3 # Copyright (C) 2013 Vinay Sajip.
4 # Licensed to the Python Software Foundation under a contributor agreement.
5 # See LICENSE.txt and CONTRIBUTORS.txt.
6 #
7 import hashlib
8 import logging
9 import os
10 import shutil
11 import subprocess
12 import tempfile
13 try:
14 from threading import Thread
15 except ImportError:
16 from dummy_threading import Thread
17
18 from . import DistlibException
19 from .compat import (HTTPBasicAuthHandler, Request, HTTPPasswordMgr,
20 urlparse, build_opener, string_types)
21 from .util import cached_property, zip_dir, ServerProxy
22
23 logger = logging.getLogger(__name__)
24
25 DEFAULT_INDEX = 'https://pypi.org/pypi'
26 DEFAULT_REALM = 'pypi'
27
28 class PackageIndex(object):
29 """
30 This class represents a package index compatible with PyPI, the Python
31 Package Index.
32 """
33
34 boundary = b'----------ThIs_Is_tHe_distlib_index_bouNdaRY_$'
35
36 def __init__(self, url=None):
37 """
38 Initialise an instance.
39
40 :param url: The URL of the index. If not specified, the URL for PyPI is
41 used.
42 """
43 self.url = url or DEFAULT_INDEX
44 self.read_configuration()
45 scheme, netloc, path, params, query, frag = urlparse(self.url)
46 if params or query or frag or scheme not in ('http', 'https'):
47 raise DistlibException('invalid repository: %s' % self.url)
48 self.password_handler = None
49 self.ssl_verifier = None
50 self.gpg = None
51 self.gpg_home = None
52 with open(os.devnull, 'w') as sink:
53 # Use gpg by default rather than gpg2, as gpg2 insists on
54 # prompting for passwords
55 for s in ('gpg', 'gpg2'):
56 try:
57 rc = subprocess.check_call([s, '--version'], stdout=sink,
58 stderr=sink)
59 if rc == 0:
60 self.gpg = s
61 break
62 except OSError:
63 pass
64
65 def _get_pypirc_command(self):
66 """
67 Get the distutils command for interacting with PyPI configurations.
68 :return: the command.
69 """
70 from distutils.core import Distribution
71 from distutils.config import PyPIRCCommand
72 d = Distribution()
73 return PyPIRCCommand(d)
74
75 def read_configuration(self):
76 """
77 Read the PyPI access configuration as supported by distutils, getting
78 PyPI to do the actual work. This populates ``username``, ``password``,
79 ``realm`` and ``url`` attributes from the configuration.
80 """
81 # get distutils to do the work
82 c = self._get_pypirc_command()
83 c.repository = self.url
84 cfg = c._read_pypirc()
85 self.username = cfg.get('username')
86 self.password = cfg.get('password')
87 self.realm = cfg.get('realm', 'pypi')
88 self.url = cfg.get('repository', self.url)
89
90 def save_configuration(self):
91 """
92 Save the PyPI access configuration. You must have set ``username`` and
93 ``password`` attributes before calling this method.
94
95 Again, distutils is used to do the actual work.
96 """
97 self.check_credentials()
98 # get distutils to do the work
99 c = self._get_pypirc_command()
100 c._store_pypirc(self.username, self.password)
101
102 def check_credentials(self):
103 """
104 Check that ``username`` and ``password`` have been set, and raise an
105 exception if not.
106 """
107 if self.username is None or self.password is None:
108 raise DistlibException('username and password must be set')
109 pm = HTTPPasswordMgr()
110 _, netloc, _, _, _, _ = urlparse(self.url)
111 pm.add_password(self.realm, netloc, self.username, self.password)
112 self.password_handler = HTTPBasicAuthHandler(pm)
113
114 def register(self, metadata):
115 """
116 Register a distribution on PyPI, using the provided metadata.
117
118 :param metadata: A :class:`Metadata` instance defining at least a name
119 and version number for the distribution to be
120 registered.
121 :return: The HTTP response received from PyPI upon submission of the
122 request.
123 """
124 self.check_credentials()
125 metadata.validate()
126 d = metadata.todict()
127 d[':action'] = 'verify'
128 request = self.encode_request(d.items(), [])
129 response = self.send_request(request)
130 d[':action'] = 'submit'
131 request = self.encode_request(d.items(), [])
132 return self.send_request(request)
133
134 def _reader(self, name, stream, outbuf):
135 """
136 Thread runner for reading lines of from a subprocess into a buffer.
137
138 :param name: The logical name of the stream (used for logging only).
139 :param stream: The stream to read from. This will typically a pipe
140 connected to the output stream of a subprocess.
141 :param outbuf: The list to append the read lines to.
142 """
143 while True:
144 s = stream.readline()
145 if not s:
146 break
147 s = s.decode('utf-8').rstrip()
148 outbuf.append(s)
149 logger.debug('%s: %s' % (name, s))
150 stream.close()
151
152 def get_sign_command(self, filename, signer, sign_password,
153 keystore=None):
154 """
155 Return a suitable command for signing a file.
156
157 :param filename: The pathname to the file to be signed.
158 :param signer: The identifier of the signer of the file.
159 :param sign_password: The passphrase for the signer's
160 private key used for signing.
161 :param keystore: The path to a directory which contains the keys
162 used in verification. If not specified, the
163 instance's ``gpg_home`` attribute is used instead.
164 :return: The signing command as a list suitable to be
165 passed to :class:`subprocess.Popen`.
166 """
167 cmd = [self.gpg, '--status-fd', '2', '--no-tty']
168 if keystore is None:
169 keystore = self.gpg_home
170 if keystore:
171 cmd.extend(['--homedir', keystore])
172 if sign_password is not None:
173 cmd.extend(['--batch', '--passphrase-fd', '0'])
174 td = tempfile.mkdtemp()
175 sf = os.path.join(td, os.path.basename(filename) + '.asc')
176 cmd.extend(['--detach-sign', '--armor', '--local-user',
177 signer, '--output', sf, filename])
178 logger.debug('invoking: %s', ' '.join(cmd))
179 return cmd, sf
180
181 def run_command(self, cmd, input_data=None):
182 """
183 Run a command in a child process , passing it any input data specified.
184
185 :param cmd: The command to run.
186 :param input_data: If specified, this must be a byte string containing
187 data to be sent to the child process.
188 :return: A tuple consisting of the subprocess' exit code, a list of
189 lines read from the subprocess' ``stdout``, and a list of
190 lines read from the subprocess' ``stderr``.
191 """
192 kwargs = {
193 'stdout': subprocess.PIPE,
194 'stderr': subprocess.PIPE,
195 }
196 if input_data is not None:
197 kwargs['stdin'] = subprocess.PIPE
198 stdout = []
199 stderr = []
200 p = subprocess.Popen(cmd, **kwargs)
201 # We don't use communicate() here because we may need to
202 # get clever with interacting with the command
203 t1 = Thread(target=self._reader, args=('stdout', p.stdout, stdout))
204 t1.start()
205 t2 = Thread(target=self._reader, args=('stderr', p.stderr, stderr))
206 t2.start()
207 if input_data is not None:
208 p.stdin.write(input_data)
209 p.stdin.close()
210
211 p.wait()
212 t1.join()
213 t2.join()
214 return p.returncode, stdout, stderr
215
216 def sign_file(self, filename, signer, sign_password, keystore=None):
217 """
218 Sign a file.
219
220 :param filename: The pathname to the file to be signed.
221 :param signer: The identifier of the signer of the file.
222 :param sign_password: The passphrase for the signer's
223 private key used for signing.
224 :param keystore: The path to a directory which contains the keys
225 used in signing. If not specified, the instance's
226 ``gpg_home`` attribute is used instead.
227 :return: The absolute pathname of the file where the signature is
228 stored.
229 """
230 cmd, sig_file = self.get_sign_command(filename, signer, sign_password,
231 keystore)
232 rc, stdout, stderr = self.run_command(cmd,
233 sign_password.encode('utf-8'))
234 if rc != 0:
235 raise DistlibException('sign command failed with error '
236 'code %s' % rc)
237 return sig_file
238
239 def upload_file(self, metadata, filename, signer=None, sign_password=None,
240 filetype='sdist', pyversion='source', keystore=None):
241 """
242 Upload a release file to the index.
243
244 :param metadata: A :class:`Metadata` instance defining at least a name
245 and version number for the file to be uploaded.
246 :param filename: The pathname of the file to be uploaded.
247 :param signer: The identifier of the signer of the file.
248 :param sign_password: The passphrase for the signer's
249 private key used for signing.
250 :param filetype: The type of the file being uploaded. This is the
251 distutils command which produced that file, e.g.
252 ``sdist`` or ``bdist_wheel``.
253 :param pyversion: The version of Python which the release relates
254 to. For code compatible with any Python, this would
255 be ``source``, otherwise it would be e.g. ``3.2``.
256 :param keystore: The path to a directory which contains the keys
257 used in signing. If not specified, the instance's
258 ``gpg_home`` attribute is used instead.
259 :return: The HTTP response received from PyPI upon submission of the
260 request.
261 """
262 self.check_credentials()
263 if not os.path.exists(filename):
264 raise DistlibException('not found: %s' % filename)
265 metadata.validate()
266 d = metadata.todict()
267 sig_file = None
268 if signer:
269 if not self.gpg:
270 logger.warning('no signing program available - not signed')
271 else:
272 sig_file = self.sign_file(filename, signer, sign_password,
273 keystore)
274 with open(filename, 'rb') as f:
275 file_data = f.read()
276 md5_digest = hashlib.md5(file_data).hexdigest()
277 sha256_digest = hashlib.sha256(file_data).hexdigest()
278 d.update({
279 ':action': 'file_upload',
280 'protocol_version': '1',
281 'filetype': filetype,
282 'pyversion': pyversion,
283 'md5_digest': md5_digest,
284 'sha256_digest': sha256_digest,
285 })
286 files = [('content', os.path.basename(filename), file_data)]
287 if sig_file:
288 with open(sig_file, 'rb') as f:
289 sig_data = f.read()
290 files.append(('gpg_signature', os.path.basename(sig_file),
291 sig_data))
292 shutil.rmtree(os.path.dirname(sig_file))
293 request = self.encode_request(d.items(), files)
294 return self.send_request(request)
295
296 def upload_documentation(self, metadata, doc_dir):
297 """
298 Upload documentation to the index.
299
300 :param metadata: A :class:`Metadata` instance defining at least a name
301 and version number for the documentation to be
302 uploaded.
303 :param doc_dir: The pathname of the directory which contains the
304 documentation. This should be the directory that
305 contains the ``index.html`` for the documentation.
306 :return: The HTTP response received from PyPI upon submission of the
307 request.
308 """
309 self.check_credentials()
310 if not os.path.isdir(doc_dir):
311 raise DistlibException('not a directory: %r' % doc_dir)
312 fn = os.path.join(doc_dir, 'index.html')
313 if not os.path.exists(fn):
314 raise DistlibException('not found: %r' % fn)
315 metadata.validate()
316 name, version = metadata.name, metadata.version
317 zip_data = zip_dir(doc_dir).getvalue()
318 fields = [(':action', 'doc_upload'),
319 ('name', name), ('version', version)]
320 files = [('content', name, zip_data)]
321 request = self.encode_request(fields, files)
322 return self.send_request(request)
323
324 def get_verify_command(self, signature_filename, data_filename,
325 keystore=None):
326 """
327 Return a suitable command for verifying a file.
328
329 :param signature_filename: The pathname to the file containing the
330 signature.
331 :param data_filename: The pathname to the file containing the
332 signed data.
333 :param keystore: The path to a directory which contains the keys
334 used in verification. If not specified, the
335 instance's ``gpg_home`` attribute is used instead.
336 :return: The verifying command as a list suitable to be
337 passed to :class:`subprocess.Popen`.
338 """
339 cmd = [self.gpg, '--status-fd', '2', '--no-tty']
340 if keystore is None:
341 keystore = self.gpg_home
342 if keystore:
343 cmd.extend(['--homedir', keystore])
344 cmd.extend(['--verify', signature_filename, data_filename])
345 logger.debug('invoking: %s', ' '.join(cmd))
346 return cmd
347
348 def verify_signature(self, signature_filename, data_filename,
349 keystore=None):
350 """
351 Verify a signature for a file.
352
353 :param signature_filename: The pathname to the file containing the
354 signature.
355 :param data_filename: The pathname to the file containing the
356 signed data.
357 :param keystore: The path to a directory which contains the keys
358 used in verification. If not specified, the
359 instance's ``gpg_home`` attribute is used instead.
360 :return: True if the signature was verified, else False.
361 """
362 if not self.gpg:
363 raise DistlibException('verification unavailable because gpg '
364 'unavailable')
365 cmd = self.get_verify_command(signature_filename, data_filename,
366 keystore)
367 rc, stdout, stderr = self.run_command(cmd)
368 if rc not in (0, 1):
369 raise DistlibException('verify command failed with error '
370 'code %s' % rc)
371 return rc == 0
372
373 def download_file(self, url, destfile, digest=None, reporthook=None):
374 """
375 This is a convenience method for downloading a file from an URL.
376 Normally, this will be a file from the index, though currently
377 no check is made for this (i.e. a file can be downloaded from
378 anywhere).
379
380 The method is just like the :func:`urlretrieve` function in the
381 standard library, except that it allows digest computation to be
382 done during download and checking that the downloaded data
383 matched any expected value.
384
385 :param url: The URL of the file to be downloaded (assumed to be
386 available via an HTTP GET request).
387 :param destfile: The pathname where the downloaded file is to be
388 saved.
389 :param digest: If specified, this must be a (hasher, value)
390 tuple, where hasher is the algorithm used (e.g.
391 ``'md5'``) and ``value`` is the expected value.
392 :param reporthook: The same as for :func:`urlretrieve` in the
393 standard library.
394 """
395 if digest is None:
396 digester = None
397 logger.debug('No digest specified')
398 else:
399 if isinstance(digest, (list, tuple)):
400 hasher, digest = digest
401 else:
402 hasher = 'md5'
403 digester = getattr(hashlib, hasher)()
404 logger.debug('Digest specified: %s' % digest)
405 # The following code is equivalent to urlretrieve.
406 # We need to do it this way so that we can compute the
407 # digest of the file as we go.
408 with open(destfile, 'wb') as dfp:
409 # addinfourl is not a context manager on 2.x
410 # so we have to use try/finally
411 sfp = self.send_request(Request(url))
412 try:
413 headers = sfp.info()
414 blocksize = 8192
415 size = -1
416 read = 0
417 blocknum = 0
418 if "content-length" in headers:
419 size = int(headers["Content-Length"])
420 if reporthook:
421 reporthook(blocknum, blocksize, size)
422 while True:
423 block = sfp.read(blocksize)
424 if not block:
425 break
426 read += len(block)
427 dfp.write(block)
428 if digester:
429 digester.update(block)
430 blocknum += 1
431 if reporthook:
432 reporthook(blocknum, blocksize, size)
433 finally:
434 sfp.close()
435
436 # check that we got the whole file, if we can
437 if size >= 0 and read < size:
438 raise DistlibException(
439 'retrieval incomplete: got only %d out of %d bytes'
440 % (read, size))
441 # if we have a digest, it must match.
442 if digester:
443 actual = digester.hexdigest()
444 if digest != actual:
445 raise DistlibException('%s digest mismatch for %s: expected '
446 '%s, got %s' % (hasher, destfile,
447 digest, actual))
448 logger.debug('Digest verified: %s', digest)
449
450 def send_request(self, req):
451 """
452 Send a standard library :class:`Request` to PyPI and return its
453 response.
454
455 :param req: The request to send.
456 :return: The HTTP response from PyPI (a standard library HTTPResponse).
457 """
458 handlers = []
459 if self.password_handler:
460 handlers.append(self.password_handler)
461 if self.ssl_verifier:
462 handlers.append(self.ssl_verifier)
463 opener = build_opener(*handlers)
464 return opener.open(req)
465
466 def encode_request(self, fields, files):
467 """
468 Encode fields and files for posting to an HTTP server.
469
470 :param fields: The fields to send as a list of (fieldname, value)
471 tuples.
472 :param files: The files to send as a list of (fieldname, filename,
473 file_bytes) tuple.
474 """
475 # Adapted from packaging, which in turn was adapted from
476 # http://code.activestate.com/recipes/146306
477
478 parts = []
479 boundary = self.boundary
480 for k, values in fields:
481 if not isinstance(values, (list, tuple)):
482 values = [values]
483
484 for v in values:
485 parts.extend((
486 b'--' + boundary,
487 ('Content-Disposition: form-data; name="%s"' %
488 k).encode('utf-8'),
489 b'',
490 v.encode('utf-8')))
491 for key, filename, value in files:
492 parts.extend((
493 b'--' + boundary,
494 ('Content-Disposition: form-data; name="%s"; filename="%s"' %
495 (key, filename)).encode('utf-8'),
496 b'',
497 value))
498
499 parts.extend((b'--' + boundary + b'--', b''))
500
501 body = b'\r\n'.join(parts)
502 ct = b'multipart/form-data; boundary=' + boundary
503 headers = {
504 'Content-type': ct,
505 'Content-length': str(len(body))
506 }
507 return Request(self.url, body, headers)
508
509 def search(self, terms, operator=None):
510 if isinstance(terms, string_types):
511 terms = {'name': terms}
512 rpc_proxy = ServerProxy(self.url, timeout=3.0)
513 try:
514 return rpc_proxy.search(terms, operator or 'and')
515 finally:
516 rpc_proxy('close')()