# Copyright 2013 Donald Stufft # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import argparse import os.path from twine.commands import _find_dists from twine.package import PackageFile from twine import exceptions from twine import settings from twine import utils def skip_upload(response, skip_existing, package): filename = package.basefilename msg_400 = ( # Old PyPI message: f'A file named "{filename}" already exists for', # Warehouse message: 'File already exists', # Nexus Repository OSS message: 'Repository does not allow updating assets', ) msg_403 = 'Not enough permissions to overwrite artifact' # NOTE(sigmavirus24): PyPI presently returns a 400 status code with the # error message in the reason attribute. Other implementations return a # 409 or 403 status code. We only want to skip an upload if: # 1. The user has told us to skip existing packages (skip_existing is # True) AND # 2. a) The response status code is 409 OR # 2. b) The response status code is 400 AND it has a reason that matches # what we expect PyPI or Nexus OSS to return to us. OR # 2. c) The response status code is 403 AND the text matches what we # expect Artifactory to return to us. return (skip_existing and (response.status_code == 409 or (response.status_code == 400 and response.reason.startswith(msg_400)) or (response.status_code == 403 and msg_403 in response.text))) def upload(upload_settings, dists): dists = _find_dists(dists) # Determine if the user has passed in pre-signed distributions signatures = {os.path.basename(d): d for d in dists if d.endswith(".asc")} uploads = [i for i in dists if not i.endswith(".asc")] upload_settings.check_repository_url() repository_url = upload_settings.repository_config['repository'] print(f"Uploading distributions to {repository_url}") repository = upload_settings.create_repository() uploaded_packages = [] for filename in uploads: package = PackageFile.from_filename(filename, upload_settings.comment) skip_message = ( " Skipping {} because it appears to already exist".format( package.basefilename) ) # Note: The skip_existing check *needs* to be first, because otherwise # we're going to generate extra HTTP requests against a hardcoded # URL for no reason. if (upload_settings.skip_existing and repository.package_is_uploaded(package)): print(skip_message) continue signed_name = package.signed_basefilename if signed_name in signatures: package.add_gpg_signature(signatures[signed_name], signed_name) elif upload_settings.sign: package.sign(upload_settings.sign_with, upload_settings.identity) resp = repository.upload(package) # Bug 92. If we get a redirect we should abort because something seems # funky. The behaviour is not well defined and redirects being issued # by PyPI should never happen in reality. This should catch malicious # redirects as well. if resp.is_redirect: raise exceptions.RedirectDetected.from_args( repository_url, resp.headers["location"], ) if skip_upload(resp, upload_settings.skip_existing, package): print(skip_message) continue utils.check_status_code(resp, upload_settings.verbose) uploaded_packages.append(package) release_urls = repository.release_urls(uploaded_packages) if release_urls: print('\nView at:') for url in release_urls: print(url) # Bug 28. Try to silence a ResourceWarning by clearing the connection # pool. repository.close() def main(args): parser = argparse.ArgumentParser(prog="twine upload") settings.Settings.register_argparse_arguments(parser) parser.add_argument( "dists", nargs="+", metavar="dist", help="The distribution files to upload to the repository " "(package index). Usually dist/* . May additionally contain " "a .asc file to include an existing signature with the " "file upload.", ) args = parser.parse_args(args) upload_settings = settings.Settings.from_argparse(args) # Call the upload function with the arguments from the command line return upload(upload_settings, args.dists)