Repository: pypa/twine

Issue:
Helpful error message when failing to upload to an inaccurate PyPI URL
https://github.com/pypa/twine/issues/289#issuecomment-403766994 and #381 make me think of this enhancement idea:

If Twine fails to upload with an "HTTPError: 405 Client Error: Method Not Allowed for url", and the URL has "pypi.org/" in it (thus including both pypi.org and test.pypi.org), we'd give the user a helpful error message saying: "You probably want one of these two URLs ([link] and [link])"  and mentioning https://warehouse.readthedocs.io/api-reference/legacy/#upload-api .

Discussion:

Patch:
diff --git a/.codecov.yml b/.codecov.yml
deleted file mode 100644
index db24720..0000000
--- a/.codecov.yml
+++ /dev/null
@@ -1,1 +0,0 @@
-comment: off
diff --git a/.gitignore b/.gitignore
index 444c51b..6f7a6d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -109,4 +109,3 @@ venv.bak/
 .mypy_cache/
 .dmypy.json
 dmypy.json
-monkeytype.sqlite3
diff --git a/.travis.yml b/.travis.yml
index db0dfd3..b181df5 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -8,6 +8,9 @@ env:
 
 matrix:
   fast_finish: true
+  allow_failures:
+    - env:
+        TOXENV: lint-mypy
 
   include:
     - python: 3.7
@@ -26,7 +29,6 @@ matrix:
       name: Making sure that docs build is healthy
       env:
         TOXENV: docs
-    - python: 3.8
     - python: 3.7
     - python: 3.6
 
diff --git a/MANIFEST.in b/MANIFEST.in
index f751157..7212db9 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -2,7 +2,6 @@ include LICENSE
 include README.rst
 include AUTHORS
 include .coveragerc
-include mypy.ini
 
 recursive-include tests *.py *.whl *.gz deprecated-pypirc
 recursive-include docs *.bat *.empty *.py *.rst Makefile *.txt
diff --git a/README.rst b/README.rst
index 3293a79..90317fe 100644
--- a/README.rst
+++ b/README.rst
@@ -1,17 +1,5 @@
-.. image:: https://img.shields.io/pypi/v/twine.svg
-    :target: https://pypi.org/project/twine
-
-.. image:: https://img.shields.io/pypi/pyversions/twine.svg
-    :target: https://pypi.org/project/twine
-   
-.. image:: https://img.shields.io/readthedocs/twine
-    :target: https://twine.readthedocs.io
-
-.. image:: https://img.shields.io/travis/com/pypa/twine
-    :target: https://travis-ci.org/pypa/twine
-
-.. image:: https://img.shields.io/codecov/c/github/pypa/twine
-    :target: https://codecov.io/gh/pypa/twine
+.. image:: https://img.shields.io/travis/pypa/twine/master.svg?label=travis-ci
+   :target: https://travis-ci.org/pypa/twine
 
 twine
 =====
@@ -32,16 +20,18 @@ The goal of ``twine`` is to improve PyPI interaction by improving
 security and testability.
 
 The biggest reason to use ``twine`` is that it securely authenticates
-you to `PyPI`_ over HTTPS using a verified connection, regardless of
-the underlying Python version. Meanwhile, ``python setup.py upload``
-will only work correctly and securely if your build system, Python
-version, and underlying operating system are configured properly.
-
-Secondly, ``twine`` encourages you to build your distribution files. ``python
-setup.py upload`` only allows you to upload a package as a final step after
-building with ``distutils`` or ``setuptools``, within the same command
-invocation. This means that you cannot test the exact file you're going to
-upload to PyPI to ensure that it works before uploading it.
+you to `PyPI`_ over HTTPS using a verified connection regardless of
+the underlying Python version, while whether or not
+``python setup.py upload`` will work correctly and securely depends
+on your build system, your Python version and the underlying operating
+system.
+
+Secondly, it allows you to precreate your distribution files.
+``python setup.py upload`` only allows you to upload something that you're
+building with ``distutils`` or ``setuptools``, and created in the same
+command invocation. This means that you cannot test the
+exact file you're going to upload to PyPI to ensure that it works before
+uploading it.
 
 Finally, ``twine`` allows you to pre-sign your files and pass the
 ``.asc`` files into the command line invocation (``twine upload
@@ -78,8 +68,7 @@ Using Twine
 
        $ python setup.py sdist bdist_wheel
 
-2. Upload with ``twine`` to `Test PyPI`_ and verify things look right. Twine
-will automatically prompt for your username and password:
+2. Upload with ``twine`` to `Test PyPI`_ and verify things look right. Twine will automatically prompt for your username and password:
 
    .. code-block:: console
 
@@ -136,8 +125,7 @@ will prompt for the password:
     $ python3 -m keyring set https://upload.pypi.org/legacy/ your-username
 
 
-The next time you run ``twine`` it will prompt you for a username and will grab
-the appropriate password from the keyring.
+The next time you run ``twine`` it will prompt you for a username and will grab the appropriate password from the keyring.
 
 .. Note:: If you are using Linux in a headless environment (such as on a
     server) you'll need to do some additional steps to ensure that Keyring can
@@ -227,8 +215,7 @@ Uploads one or more distributions to a repository.
 ``twine check``
 ^^^^^^^^^^^^^^^
 
-Checks whether your distribution's long description will render correctly on
-PyPI.
+Checks whether your distribution's long description will render correctly on PyPI.
 
 .. code-block:: console
 
@@ -244,10 +231,10 @@ PyPI.
 ``twine register``
 ^^^^^^^^^^^^^^^^^^
 
-**WARNING**: The ``register`` command is `no longer necessary if you are
-uploading to pypi.org`_.  As such, it is `no longer supported`_ in `Warehouse`_
-(the new PyPI software running on pypi.org). However, you may need this if you
-are using a different package index.
+**WARNING**: The ``register`` command is `no longer necessary if you are uploading to
+pypi.org`_.  As such, it is `no longer supported`_ in `Warehouse`_ (the new
+PyPI software running on pypi.org). However, you may need this if you are using
+a different package index.
 
 For completeness, its usage:
 
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000..f2ef5bf
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,30 @@
+codecov:
+  branch: master
+  bot: null
+
+coverage:
+  precision: 2
+  round: down
+  range: "60...100"
+
+  status:
+    project:
+      default:
+        target: auto
+        threshold: null
+        branches: null
+
+    patch:
+      default:
+        target: auto
+        branches: null
+
+    changes:
+      default:
+        branches: null
+
+  ignore: null
+  fixes:
+    - .tox
+
+comment: off
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 2995e18..fc543da 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -3,10 +3,8 @@
 =========
 Changelog
 =========
-* :feature:`518` Add Python 3.8 to classifiers.
-* :bug:`332` More robust handling of server response in ``--skip-existing``
 * :release:`2.0.0 <2019-09-24>`
-* :feature:`437` Twine now requires Python 3.6 or later. Use pip
+* :feature:`437`: Twine now requires Python 3.6 or later. Use pip
   9 or pin to "twine<2" to install twine on older Python versions.
 * :bug:`491` Require requests 2.20 or later to avoid reported security
   vulnerabilities in earlier releases.
diff --git a/docs/contributing.rst b/docs/contributing.rst
index 4dafa20..799e0f7 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -18,12 +18,12 @@ machine.  You can use `virtualenv`_ or `pipenv`_ to isolate your
 development environment.
 
 Clone the twine repository from GitHub, and then make and activate a
-virtual environment that uses Python 3.6 or newer as the default
+virtual environment that uses Python 3.6 as the default
 Python. Example:
 
 .. code-block:: console
 
-  mkvirtualenv -p /usr/bin/python3.7 twine
+  mkvirtualenv -p /usr/bin/python3.6 twine
 
 Then, run the following command:
 
@@ -65,9 +65,9 @@ The HTML of the docs will be visible in :file:`twine/docs/_build/`.
 Testing
 ^^^^^^^
 
-Tests with twine are run using `tox`_, and tested against Python versions 3.6,
-3.7, and 3.8. To run these tests locally, you will need to have these versions
-of Python installed on your machine.
+Tests with twine are run using `tox`_, and tested against the following Python
+versions: 3.6 and 3.7. To run these tests locally, you will need to have these
+versions of Python installed on your machine.
 
 Either use ``tox`` to build against all supported Python versions (if you have
 them installed) or use ``tox -e py{version}`` to test against a specific
@@ -100,7 +100,7 @@ HTTPS. Its three purposes are to be:
    for publishing on any Python package index
 
 
-Currently, twine has two principle functions: uploading new packages
+Currently, twine has two principal functions: uploading new packages
 and registering new `projects`_ (``register`` is no longer supported
 on PyPI, and is in Twine for use with other package indexes).
 
@@ -129,10 +129,10 @@ Adding a maintainer
 
 A checklist for adding a new maintainer to the project.
 
-#. Add them as a Member in the GitHub repo settings. (This will also
-   give them privileges on the `Travis CI project
+#. Add her as a Member in the GitHub repo settings. (This will also
+   give her privileges on the `Travis CI project
    <https://travis-ci.org/pypa/twine>`_.)
-#. Get them Test PyPI and canon PyPI usernames and add them as a
+#. Get her Test PyPI and canon PyPI usernames and add her as a
    Maintainer on `our Test PyPI project
    <https://test.pypi.org/manage/project/twine/collaboration/>`_ and
    `canon PyPI
@@ -156,7 +156,7 @@ A checklist for creating, testing, and distributing a new version.
 
 #. Run Twine tests:
 
-   #. ``tox -e py{36,37,38}``
+   #. ``tox -e py{27,34,35,36,py}``
    #. ``tox -e lint`` for the linter
    #. ``tox -e docs`` (this checks the Sphinx docs and uses
       ``readme_renderer`` to check that the ``long_description`` and other
diff --git a/mypy.ini b/mypy.ini
deleted file mode 100644
index 3fc80f4..0000000
--- a/mypy.ini
+++ /dev/null
@@ -1,9 +0,0 @@
-[mypy]
-; TODO: check_untyped_defs = True
-; TODO: Make this more granular; docs recommend it as a "last resort"
-; https://mypy.readthedocs.io/en/latest/existing_code.html#start-small
-ignore_missing_imports = True
-; NOTE: Docs recommend `normal` or `silent` for an existing codebase
-; https://mypy.readthedocs.io/en/latest/running_mypy.html#following-imports
-; follow_imports = skip
-show_traceback = True
diff --git a/setup.py b/setup.py
index 4830ace..def9f9d 100644
--- a/setup.py
+++ b/setup.py
@@ -48,7 +48,6 @@ setup(
         "Programming Language :: Python :: 3 :: Only",
         "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
-        "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: Implementation :: CPython",
     ],
 
diff --git a/tox.ini b/tox.ini
index 04b5562..f89f3ae 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,6 @@
 [tox]
 minversion = 2.4
-envlist = lint,docs,py38,py37,py36
+envlist = lint,docs,py37,py36
 
 [testenv]
 deps =
@@ -48,7 +48,7 @@ commands =
 deps =
     mypy
 commands =
-    mypy twine tests
+    mypy --ignore-missing-imports --follow-imports=skip twine/ tests/
 
 [testenv:lint]
 deps =
@@ -58,4 +58,4 @@ deps =
 commands =
     {[testenv:lint-code-style]commands}
     {[testenv:lint-dist-meta]commands}
-    {[testenv:lint-mypy]commands}
+    -{[testenv:lint-mypy]commands}
diff --git a/twine/commands/__init__.py b/twine/commands/__init__.py
index 316bdf5..2671362 100644
--- a/twine/commands/__init__.py
+++ b/twine/commands/__init__.py
@@ -11,14 +11,12 @@
 # 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.
-from typing import List
-
 import glob
 import os.path
 
 from twine import exceptions
 
-__all__: List[str] = []
+__all__ = []
 
 
 def _group_wheel_files_first(files):
diff --git a/twine/commands/upload.py b/twine/commands/upload.py
index 43b3b4f..8057dca 100644
--- a/twine/commands/upload.py
+++ b/twine/commands/upload.py
@@ -22,26 +22,30 @@ from twine import utils
 
 
 def skip_upload(response, skip_existing, package):
-    if not skip_existing:
-        return False
-
-    status = response.status_code
-    reason = getattr(response, 'reason', '').lower()
-    text = getattr(response, 'text', '').lower()
-
+    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
-    # 403 or 409 status code.
-    return (
-        # pypiserver (https://pypi.org/project/pypiserver)
-        status == 409
-        # PyPI / TestPyPI
-        or (status == 400 and 'already exist' in reason)
-        # Nexus Repository OSS (https://www.sonatype.com/nexus-repository-oss)
-        or (status == 400 and 'updating asset' in reason)
-        # Artifactory (https://jfrog.com/artifactory/)
-        or (status == 403 and 'overwrite artifact' in text)
-    )
+    # 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):
diff --git a/twine/exceptions.py b/twine/exceptions.py
index 47a12ee..59e978e 100644
--- a/twine/exceptions.py
+++ b/twine/exceptions.py
@@ -101,3 +101,12 @@ class InvalidDistribution(TwineException):
     """Raised when a distribution is invalid."""
 
     pass
+
+
+class InvalidPyPIUploadURL(TwineException):
+    """Repository configuration tries to use PyPI with an incorrect URL.
+
+    For example, https://pypi.org instead of https://upload.pypi.org/legacy.
+    """
+
+    pass
diff --git a/twine/package.py b/twine/package.py
index 903903e..b61d36a 100644
--- a/twine/package.py
+++ b/twine/package.py
@@ -11,8 +11,6 @@
 # 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.
-from typing import Dict, IO, Optional, Union, Sequence, Tuple
-
 import collections
 import hashlib
 import io
@@ -27,6 +25,12 @@ from twine.wheel import Wheel
 from twine.wininst import WinInst
 from twine import exceptions
 
+try:
+    FileNotFoundError = FileNotFoundError
+except NameError:
+    FileNotFoundError = IOError  # Py2
+
+
 DIST_TYPES = {
     "bdist_wheel": Wheel,
     "bdist_wininst": WinInst,
@@ -43,18 +47,9 @@ DIST_EXTENSIONS = {
     ".zip": "sdist",
 }
 
-MetadataValue = Union[str, Sequence[str], Tuple[str, IO, str]]
-
 
 class PackageFile:
-    def __init__(
-        self,
-        filename: str,
-        comment: Optional[str],
-        metadata: pkginfo.Distribution,
-        python_version: Optional[str],
-        filetype: Optional[str],
-    ) -> None:
+    def __init__(self, filename, comment, metadata, python_version, filetype):
         self.filename = filename
         self.basefilename = os.path.basename(filename)
         self.comment = comment
@@ -64,7 +59,7 @@ class PackageFile:
         self.safe_name = pkg_resources.safe_name(metadata.name)
         self.signed_filename = self.filename + '.asc'
         self.signed_basefilename = self.basefilename + '.asc'
-        self.gpg_signature: Optional[Tuple[str, bytes]] = None
+        self.gpg_signature = None
 
         hasher = HashManager(filename)
         hasher.hash()
@@ -75,7 +70,7 @@ class PackageFile:
         self.blake2_256_digest = hexdigest.blake2
 
     @classmethod
-    def from_filename(cls, filename: str, comment: None) -> 'PackageFile':
+    def from_filename(cls, filename, comment):
         # Extract the metadata from the package
         for ext, dtype in DIST_EXTENSIONS.items():
             if filename.endswith(ext):
@@ -96,7 +91,6 @@ class PackageFile:
                 "possible."
             )
 
-        py_version: Optional[str]
         if dtype == "bdist_egg":
             pkgd = pkg_resources.Distribution.from_filename(filename)
             py_version = pkgd.py_version
@@ -109,7 +103,7 @@ class PackageFile:
 
         return cls(filename, comment, meta, py_version, dtype)
 
-    def metadata_dictionary(self) -> Dict[str, MetadataValue]:
+    def metadata_dictionary(self):
         meta = self.metadata
         data = {
             # identify release
@@ -163,11 +157,7 @@ class PackageFile:
 
         return data
 
-    def add_gpg_signature(
-        self,
-        signature_filepath: str,
-        signature_filename: str
-    ):
+    def add_gpg_signature(self, signature_filepath, signature_filename):
         if self.gpg_signature is not None:
             raise exceptions.InvalidDistribution(
                 'GPG Signature can only be added once'
@@ -176,9 +166,9 @@ class PackageFile:
         with open(signature_filepath, "rb") as gpg:
             self.gpg_signature = (signature_filename, gpg.read())
 
-    def sign(self, sign_with: str, identity: Optional[str]):
+    def sign(self, sign_with, identity):
         print(f"Signing {self.basefilename}")
-        gpg_args: Tuple[str, ...] = (sign_with, "--detach-sign")
+        gpg_args = (sign_with, "--detach-sign")
         if identity:
             gpg_args += ("--local-user", identity)
         gpg_args += ("-a", self.filename)
@@ -217,51 +207,48 @@ class HashManager:
     This will also allow us to better test this logic.
     """
 
-    def __init__(self, filename: str) -> None:
+    def __init__(self, filename):
         """Initialize our manager and hasher objects."""
         self.filename = filename
-
-        self._md5_hasher = None
         try:
             self._md5_hasher = hashlib.md5()
         except ValueError:
             # FIPs mode disables MD5
-            pass
+            self._md5_hasher = None
 
         self._sha2_hasher = hashlib.sha256()
-
         self._blake_hasher = None
         if blake2b is not None:
             self._blake_hasher = blake2b(digest_size=256 // 8)
 
-    def _md5_update(self, content: bytes) -> None:
+    def _md5_update(self, content):
         if self._md5_hasher is not None:
             self._md5_hasher.update(content)
 
-    def _md5_hexdigest(self) -> Optional[str]:
+    def _md5_hexdigest(self):
         if self._md5_hasher is not None:
             return self._md5_hasher.hexdigest()
         return None
 
-    def _sha2_update(self, content: bytes) -> None:
+    def _sha2_update(self, content):
         if self._sha2_hasher is not None:
             self._sha2_hasher.update(content)
 
-    def _sha2_hexdigest(self) -> Optional[str]:
+    def _sha2_hexdigest(self):
         if self._sha2_hasher is not None:
             return self._sha2_hasher.hexdigest()
         return None
 
-    def _blake_update(self, content: bytes) -> None:
+    def _blake_update(self, content):
         if self._blake_hasher is not None:
             self._blake_hasher.update(content)
 
-    def _blake_hexdigest(self) -> Optional[str]:
+    def _blake_hexdigest(self):
         if self._blake_hasher is not None:
             return self._blake_hasher.hexdigest()
         return None
 
-    def hash(self) -> None:
+    def hash(self):
         """Hash the file contents."""
         with open(self.filename, "rb") as fp:
             for content in iter(lambda: fp.read(io.DEFAULT_BUFFER_SIZE), b''):
@@ -269,7 +256,7 @@ class HashManager:
                 self._sha2_update(content)
                 self._blake_update(content)
 
-    def hexdigest(self) -> Hexdigest:
+    def hexdigest(self):
         """Return the hexdigest for the file."""
         return Hexdigest(
             self._md5_hexdigest(),
diff --git a/twine/repository.py b/twine/repository.py
index dbe071b..f32e444 100644
--- a/twine/repository.py
+++ b/twine/repository.py
@@ -11,22 +11,19 @@
 # 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.
-from typing import Dict, List, Tuple
 import sys
 
 from tqdm import tqdm
 
 import requests
+from requests import adapters
 from requests import codes
-from requests.adapters import HTTPAdapter
-from requests.models import Response
 from requests.packages.urllib3 import util
 from requests_toolbelt.multipart import (
     MultipartEncoder, MultipartEncoderMonitor
 )
 from requests_toolbelt.utils import user_agent
 
-from twine.package import PackageFile, MetadataValue
 import twine
 
 KEYWORDS_TO_NOT_FLATTEN = {"gpg_signature", "content"}
@@ -50,34 +47,29 @@ class ProgressBar(tqdm):
 
 
 class Repository:
-    def __init__(
-        self,
-        repository_url: str,
-        username: str,
-        password: str,
-        disable_progress_bar: bool = False
-    ) -> None:
+    def __init__(self, repository_url, username, password,
+                 disable_progress_bar=False):
         self.url = repository_url
         self.session = requests.session()
         self.session.auth = (username, password)
         self.session.headers['User-Agent'] = self._make_user_agent_string()
         for scheme in ('http://', 'https://'):
             self.session.mount(scheme, self._make_adapter_with_retries())
-        self._releases_json_data: Dict[str, Dict] = {}
+        self._releases_json_data = {}
         self.disable_progress_bar = disable_progress_bar
 
     @staticmethod
-    def _make_adapter_with_retries() -> HTTPAdapter:
+    def _make_adapter_with_retries():
         retry = util.Retry(
             connect=5,
             total=10,
             method_whitelist=['GET'],
             status_forcelist=[500, 501, 502, 503],
         )
-        return HTTPAdapter(max_retries=retry)
+        return adapters.HTTPAdapter(max_retries=retry)
 
     @staticmethod
-    def _make_user_agent_string() -> str:
+    def _make_user_agent_string():
         from twine import cli
         dependencies = cli.list_dependencies_and_versions()
         return user_agent.UserAgentBuilder(
@@ -90,9 +82,7 @@ class Repository:
         self.session.close()
 
     @staticmethod
-    def _convert_data_to_list_of_tuples(
-        data: Dict[str, MetadataValue]
-    ) -> List[Tuple[str, MetadataValue]]:
+    def _convert_data_to_list_of_tuples(data):
         data_to_send = []
         for key, value in data.items():
             if (key in KEYWORDS_TO_NOT_FLATTEN or
@@ -103,11 +93,11 @@ class Repository:
                     data_to_send.append((key, item))
         return data_to_send
 
-    def set_certificate_authority(self, cacert: str) -> None:
+    def set_certificate_authority(self, cacert):
         if cacert:
             self.session.verify = cacert
 
-    def set_client_certificate(self, clientcert: Tuple[str, str]) -> None:
+    def set_client_certificate(self, clientcert):
         if clientcert:
             self.session.cert = clientcert
 
@@ -132,7 +122,7 @@ class Repository:
         resp.close()
         return resp
 
-    def _upload(self, package: PackageFile) -> Response:
+    def _upload(self, package):
         data = package.metadata_dictionary()
         data.update({
             # action
@@ -167,11 +157,7 @@ class Repository:
 
         return resp
 
-    def upload(
-        self,
-        package: PackageFile,
-        max_redirects: int = 5
-    ) -> Response:
+    def upload(self, package, max_redirects=5):
         number_of_redirects = 0
         while number_of_redirects < max_redirects:
             resp = self._upload(package)
@@ -191,11 +177,7 @@ class Repository:
 
         return resp
 
-    def package_is_uploaded(
-        self,
-        package: PackageFile,
-        bypass_cache: bool = False
-    ) -> bool:
+    def package_is_uploaded(self, package, bypass_cache=False):
         # NOTE(sigmavirus24): Not all indices are PyPI and pypi.io doesn't
         # have a similar interface for finding the package versions.
         if not self.url.startswith((LEGACY_PYPI, WAREHOUSE, OLD_WAREHOUSE)):
@@ -241,7 +223,7 @@ class Repository:
             for package in packages
         }
 
-    def verify_package_integrity(self, package: PackageFile):
+    def verify_package_integrity(self, package):
         # TODO(sigmavirus24): Add a way for users to download the package and
         # check it's hash against what it has locally.
         pass
diff --git a/twine/utils.py b/twine/utils.py
index 1fd30ca..9df0aa1 100644
--- a/twine/utils.py
+++ b/twine/utils.py
@@ -22,7 +22,7 @@ import collections
 import configparser
 from urllib.parse import urlparse, urlunparse
 
-from requests.exceptions import HTTPError
+import requests
 
 try:
     import keyring  # noqa
@@ -130,24 +130,31 @@ def normalize_repository_url(url):
 
 
 def check_status_code(response, verbose):
+    """Generate a helpful message based on the response from the repository.
+
+    Raise a custom exception for recognized errors. Otherwise, print the
+    response content (based on the verbose option) before re-raising the
+    HTTPError.
     """
-    Shouldn't happen, thanks to the UploadToDeprecatedPyPIDetected
-    exception, but this is in case that breaks and it does.
-    """
-    if (response.status_code == 410 and
-            response.url.startswith(("https://pypi.python.org",
-                                     "https://testpypi.python.org"))):
-        print("It appears you're uploading to pypi.python.org (or "
-              "testpypi.python.org). You've received a 410 error response. "
-              "Uploading to those sites is deprecated. The new sites are "
-              "pypi.org and test.pypi.org. Try using "
-              "https://upload.pypi.org/legacy/ "
-              "(or https://test.pypi.org/legacy/) to upload your packages "
-              "instead. These are the default URLs for Twine now. More at "
-              "https://packaging.python.org/guides/migrating-to-pypi-org/ ")
+    if response.status_code == 410 and "pypi.python.org" in response.url:
+        raise exceptions.UploadToDeprecatedPyPIDetected(
+            f"It appears you're uploading to pypi.python.org (or "
+            f"testpypi.python.org). You've received a 410 error response. "
+            f"Uploading to those sites is deprecated. The new sites are "
+            f"pypi.org and test.pypi.org. Try using {DEFAULT_REPOSITORY} (or "
+            f"{TEST_REPOSITORY}) to upload your packages instead. These are "
+            f"the default URLs for Twine now. More at "
+            f"https://packaging.python.org/guides/migrating-to-pypi-org/.")
+    elif response.status_code == 405 and "pypi.org" in response.url:
+        raise exceptions.InvalidPyPIUploadURL(
+            f"It appears you're trying to upload to pypi.org but have an "
+            f"invalid URL. You probably want one of these two URLs: "
+            f"{DEFAULT_REPOSITORY} or {TEST_REPOSITORY}. Check your "
+            f"--repository-url value.")
+
     try:
         response.raise_for_status()
-    except HTTPError as err:
+    except requests.HTTPError as err:
         if response.text:
             if verbose:
                 print('Content received from server:\n{}'.format(


Test patch:
diff --git a/tests/test_package.py b/tests/test_package.py
index a03753f..f14a944 100644
--- a/tests/test_package.py
+++ b/tests/test_package.py
@@ -58,7 +58,9 @@ def test_sign_file_with_identity(monkeypatch):
 
 
 def test_run_gpg_raises_exception_if_no_gpgs(monkeypatch):
-    replaced_check_call = pretend.raiser(FileNotFoundError('not found'))
+    replaced_check_call = pretend.raiser(
+        package.FileNotFoundError('not found')
+    )
     monkeypatch.setattr(package.subprocess, 'check_call', replaced_check_call)
     gpg_args = ('gpg', '--detach-sign', '-a', 'pypircfile')
 
@@ -69,7 +71,9 @@ def test_run_gpg_raises_exception_if_no_gpgs(monkeypatch):
 
 
 def test_run_gpg_raises_exception_if_not_using_gpg(monkeypatch):
-    replaced_check_call = pretend.raiser(FileNotFoundError('not found'))
+    replaced_check_call = pretend.raiser(
+        package.FileNotFoundError('not found')
+    )
     monkeypatch.setattr(package.subprocess, 'check_call', replaced_check_call)
     gpg_args = ('not_gpg', '--detach-sign', '-a', 'pypircfile')
 
@@ -83,7 +87,7 @@ def test_run_gpg_falls_back_to_gpg2(monkeypatch):
 
     def check_call(arg_list):
         if arg_list[0] == 'gpg':
-            raise FileNotFoundError('gpg not found')
+            raise package.FileNotFoundError('gpg not found')
 
     replaced_check_call = pretend.call_recorder(check_call)
     monkeypatch.setattr(package.subprocess, 'check_call', replaced_check_call)
diff --git a/tests/test_upload.py b/tests/test_upload.py
index 6885b65..cc68ba9 100644
--- a/tests/test_upload.py
+++ b/tests/test_upload.py
@@ -203,77 +203,71 @@ def test_prints_skip_message_for_response(make_settings, capsys):
     assert RELEASE_URL not in captured.out
 
 
-@pytest.mark.parametrize('response_kwargs', [
-    pytest.param(
-        dict(status_code=400, reason=(
-            'A file named "twine-1.5.0-py2.py3-none-any.whl" already '
-            'exists for twine-1.5.0.'
-        )),
-        id='pypi'
-    ),
-    pytest.param(
-        dict(status_code=400, reason=(
-            'Repository does not allow updating assets: pypi for url: '
-            'http://www.foo.bar'
-        )),
-        id='nexus'
-    ),
-    pytest.param(
-        dict(status_code=409, reason=(
-            'A file named "twine-1.5.0-py2.py3-none-any.whl" already '
-            'exists for twine-1.5.0.'
-        )),
-        id='pypiserver'
-    ),
-    pytest.param(
-        dict(status_code=403, text=(
-            "Not enough permissions to overwrite artifact "
-            "'pypi-local:twine/1.5.0/twine-1.5.0-py2.py3-none-any.whl'"
-            "(user 'twine-deployer' needs DELETE permission)."
-        )),
-        id='artifactory_old'
-    ),
-    pytest.param(
-        dict(status_code=403, text=(
-            "Not enough permissions to delete/overwrite artifact "
-            "'pypi-local:twine/1.5.0/twine-1.5.0-py2.py3-none-any.whl'"
-            "(user 'twine-deployer' needs DELETE permission)."
-        )),
-        id='artifactory_new'
-    ),
-])
-def test_skip_existing_skips_files_on_repository(response_kwargs):
-    assert upload.skip_upload(
-        response=pretend.stub(**response_kwargs),
-        skip_existing=True,
-        package=package.PackageFile.from_filename(WHEEL_FIXTURE, None),
-    )
+def test_skip_existing_skips_files_already_on_PyPI(monkeypatch):
+    response = pretend.stub(
+        status_code=400,
+        reason='A file named "twine-1.5.0-py2.py3-none-any.whl" already '
+               'exists for twine-1.5.0.')
+
+    pkg = package.PackageFile.from_filename(WHEEL_FIXTURE, None)
+    assert upload.skip_upload(response=response,
+                              skip_existing=True,
+                              package=pkg) is True
+
+
+def test_skip_existing_skips_files_already_on_nexus(monkeypatch):
+    # Nexus Repository Manager (https://www.sonatype.com/nexus-repository-oss)
+    # responds with 400 when the file already exists
+    response = pretend.stub(
+        status_code=400,
+        reason="Repository does not allow updating assets: pypi for url: "
+               "http://www.foo.bar")
+
+    pkg = package.PackageFile.from_filename(WHEEL_FIXTURE, None)
+    assert upload.skip_upload(response=response,
+                              skip_existing=True,
+                              package=pkg) is True
+
+
+def test_skip_existing_skips_files_already_on_pypiserver(monkeypatch):
+    # pypiserver (https://pypi.org/project/pypiserver) responds with a
+    # 409 when the file already exists.
+    response = pretend.stub(
+        status_code=409,
+        reason='A file named "twine-1.5.0-py2.py3-none-any.whl" already '
+               'exists for twine-1.5.0.')
 
+    pkg = package.PackageFile.from_filename(WHEEL_FIXTURE, None)
+    assert upload.skip_upload(response=response,
+                              skip_existing=True,
+                              package=pkg) is True
 
-@pytest.mark.parametrize('response_kwargs', [
-    pytest.param(
-        dict(status_code=400, reason='Invalid credentials'),
-        id='wrong_reason'
-    ),
-    pytest.param(
-        dict(status_code=404),
-        id='wrong_code'
-    ),
-])
-def test_skip_upload_doesnt_match(response_kwargs):
-    assert not upload.skip_upload(
-        response=pretend.stub(**response_kwargs),
-        skip_existing=True,
-        package=package.PackageFile.from_filename(WHEEL_FIXTURE, None)
-    )
 
+def test_skip_existing_skips_files_already_on_artifactory(monkeypatch):
+    # Artifactory (https://jfrog.com/artifactory/) responds with 403
+    # when the file already exists.
+    response = pretend.stub(
+        status_code=403,
+        text="Not enough permissions to overwrite artifact "
+             "'pypi-local:twine/1.5.0/twine-1.5.0-py2.py3-none-any.whl'"
+             "(user 'twine-deployer' needs DELETE permission).")
 
-def test_skip_upload_respects_skip_existing():
-    assert not upload.skip_upload(
-        response=pretend.stub(),
-        skip_existing=False,
-        package=package.PackageFile.from_filename(WHEEL_FIXTURE, None),
-    )
+    pkg = package.PackageFile.from_filename(WHEEL_FIXTURE, None)
+    assert upload.skip_upload(response=response,
+                              skip_existing=True,
+                              package=pkg) is True
+
+
+def test_skip_upload_respects_skip_existing(monkeypatch):
+    response = pretend.stub(
+        status_code=400,
+        reason='A file named "twine-1.5.0-py2.py3-none-any.whl" already '
+               'exists for twine-1.5.0.')
+
+    pkg = package.PackageFile.from_filename(WHEEL_FIXTURE, None)
+    assert upload.skip_upload(response=response,
+                              skip_existing=False,
+                              package=pkg) is False
 
 
 def test_values_from_env(monkeypatch):
@@ -291,3 +285,20 @@ def test_values_from_env(monkeypatch):
     assert "pypipassword" == upload_settings.password
     assert "pypiuser" == upload_settings.username
     assert "/foo/bar.crt" == upload_settings.cacert
+
+
+@pytest.mark.parametrize('repo_url', [
+    "https://upload.pypi.org/",
+    "https://test.pypi.org/",
+    "https://pypi.org/"
+])
+def test_check_status_code_for_wrong_repo_url(repo_url, make_settings):
+    upload_settings = make_settings()
+
+    # override defaults to use incorrect URL
+    upload_settings.repository_config['repository'] = repo_url
+
+    with pytest.raises(twine.exceptions.InvalidPyPIUploadURL):
+        upload.upload(upload_settings, [
+            WHEEL_FIXTURE, SDIST_FIXTURE, NEW_SDIST_FIXTURE, NEW_WHEEL_FIXTURE
+        ])
diff --git a/tests/test_utils.py b/tests/test_utils.py
index a76d36b..dd33787 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -16,8 +16,9 @@ import os.path
 import textwrap
 
 import pytest
+import pretend
 
-from twine import utils
+from twine import utils, exceptions
 
 import helpers
 
@@ -389,3 +390,18 @@ def test_no_positional_on_function():
         t(1)
 
     assert t(foo=True)
+
+
+@pytest.mark.parametrize('repo_url', [
+    "https://pypi.python.org",
+    "https://testpypi.python.org"
+])
+def test_check_status_code_for_deprecated_pypi_url(repo_url):
+    response = pretend.stub(
+        status_code=410,
+        url=repo_url
+    )
+
+    # value of Verbose doesn't matter for this check
+    with pytest.raises(exceptions.UploadToDeprecatedPyPIDetected):
+        utils.check_status_code(response, False)