theacodes: @jaraco this LGTM, but it's failing on `python setup.py check -r`.
diff --git a/README.rst b/README.rst
index 2d3bec5..4893582 100644
--- a/README.rst
+++ b/README.rst
@@ -134,10 +134,24 @@ The next time you run ``twine`` it will prompt you for a username and will grab
 .. _`Using Keyring on headless systems`:
     https://keyring.readthedocs.io/en/latest/#using-keyring-on-headless-linux-systems
 
+Disabling Keyring
+^^^^^^^^^^^^^^^^^
+
+In some cases, the presence of keyring may be problemmatic. To disable
+keyring and defer to a prompt for passwords, uninstall ``keyring``
+or if that's not an option, you can also configure keyring to be disabled.
+
+See `twine 338 <https://github.com/pypa/twine/issues/338>`_ for a
+discussion on ways to do that.
 
 Options
 -------
 
+``twine upload``
+^^^^^^^^^^^^^^^^
+
+Uploads one or more distributions to a repository.
+
 .. code-block:: console
 
     $ twine upload -h
@@ -191,16 +205,29 @@ Options
                             containing the private key and the certificate in PEM
                             format.
 
-Twine also includes a ``register`` command.
+``twine check``
+^^^^^^^^^^^^^^^
+
+Checks whether your distributions long description will render correctly on PyPI.
+
+.. code-block:: console
+
+    $ twine check -h
+    usage: twine check [-h] dist [dist ...]
+
+    positional arguments:
+    dist        The distribution files to check, usually dist/*
+
+    optional arguments:
+    -h, --help  show this help message and exit
+
+``twine register``
+^^^^^^^^^^^^^^^^^^
 
-.. WARNING::
-   ``register`` is `no longer necessary if you are
-   uploading to pypi.org
-   <https://packaging.python.org/guides/migrating-to-pypi-org/#registering-package-names-metadata>`_. As
-   such, it is `no longer supported
-   <https://github.com/pypa/warehouse/issues/1627>`_ 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:
 
@@ -300,3 +327,5 @@ trackers, chat rooms, and mailing lists is expected to follow the
 .. _`PyPA Code of Conduct`: https://www.pypa.io/en/latest/code-of-conduct/
 .. _`Warehouse`: https://github.com/pypa/warehouse
 .. _`wheels`: https://packaging.python.org/glossary/#term-wheel
+.. _`no longer necessary if you are uploading to pypi.org`: https://packaging.python.org/guides/migrating-to-pypi-org/#registering-package-names-metadata
+.. _`no longer supported`: https://github.com/pypa/warehouse/issues/1627
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 8100cfd..6e9fc0a 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -4,6 +4,7 @@
 Changelog
 =========
 
+* :feature:`395 major` Add ``twine check`` command to check long description
 * :feature:`392 major` Drop support for Python 3.3
 * :release:`1.11.0 <2018-03-19>`
 * :bug:`269 major` Avoid uploading to PyPI when given alternate
diff --git a/setup.cfg b/setup.cfg
index 2102387..925c448 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,4 +1,4 @@
-[wheel]
+[bdist_wheel]
 universal = 1
 
 [check-manifest]
@@ -9,12 +9,12 @@ ignore =
     .github/*
 
 [metadata]
+license_file = LICENSE
 requires-dist =
     tqdm >= 4.14
     requests >= 2.5.0, != 2.15, != 2.16
     requests-toolbelt >= 0.8.0
     pkginfo >= 1.4.2
     setuptools >= 0.7.0
-    argparse; python_version == '2.6'
     pyblake2; extra == 'with-blake2' and python_version < '3.6'
     keyring; extra == 'keyring'
diff --git a/setup.py b/setup.py
index bda1c1c..b96202a 100644
--- a/setup.py
+++ b/setup.py
@@ -18,20 +18,6 @@ import sys
 import twine
 
 
-install_requires = [
-    "tqdm >= 4.14",
-    "pkginfo >= 1.4.2",
-    "requests >= 2.5.0, != 2.15, != 2.16",
-    "requests-toolbelt >= 0.8.0",
-    "setuptools >= 0.7.0",
-]
-
-if sys.version_info[:2] < (2, 7):
-    install_requires += [
-        "argparse",
-    ]
-
-
 blake2_requires = []
 
 if sys.version_info[:2] < (3, 6):
@@ -81,6 +67,7 @@ setup(
 
     entry_points={
         "twine.registered_commands": [
+            "check = twine.commands.check:main",
             "upload = twine.commands.upload:main",
             "register = twine.commands.register:main",
         ],
@@ -89,7 +76,14 @@ setup(
         ],
     },
 
-    install_requires=install_requires,
+    install_requires=[
+        "pkginfo >= 1.4.2",
+        "readme_renderer >= 21.0",
+        "requests >= 2.5.0, != 2.15, != 2.16",
+        "requests-toolbelt >= 0.8.0",
+        "setuptools >= 0.7.0",
+        "tqdm >= 4.14",
+    ],
     extras_require={
         'with-blake2': blake2_requires,
         'keyring': [
diff --git a/tox.ini b/tox.ini
index 26b3d49..af3174d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -7,19 +7,23 @@ deps =
     pretend
     pytest
     py27,py34,py35: pyblake2
+    readme_renderer
 commands =
     coverage run --source twine -m pytest {posargs:tests}
     coverage report -m
 
 [testenv:docs]
 basepython = python3.6
-deps = -rdocs/requirements.txt
+deps =
+    .
+    -rdocs/requirements.txt
 commands =
     sphinx-build -W -b html -d {envtmpdir}/doctrees docs docs/_build/html
     sphinx-build -W -b doctest -d {envtmpdir}/doctrees docs docs/_build/html
     doc8 docs
     sphinx-build -W -b linkcheck -d {envtmpdir}/doctrees docs docs/_build/linkcheck
-    python setup.py check -r -s
+    python setup.py sdist
+    twine check dist/*
 
 [testenv:release]
 deps =
@@ -32,12 +36,13 @@ commands =
 [testenv:lint]
 basepython = python3.6
 deps =
+    .
     flake8
     check-manifest
-    readme_renderer
     mypy
 commands =
     flake8 twine/ tests/
     check-manifest -v
-    python setup.py check -r -s
+    python setup.py sdist
+    twine check dist/*
     -mypy -s twine/ tests/
diff --git a/twine/__main__.py b/twine/__main__.py
index 9eed3aa..fbf276d 100644
--- a/twine/__main__.py
+++ b/twine/__main__.py
@@ -17,13 +17,16 @@ from __future__ import unicode_literals
 
 import sys
 
+import requests
+
+from twine import exceptions
 from twine.cli import dispatch
 
 
 def main():
     try:
         return dispatch(sys.argv[1:])
-    except Exception as exc:
+    except (exceptions.TwineException, requests.exceptions.HTTPError) as exc:
         return '{0}: {1}'.format(
             exc.__class__.__name__,
             exc.args[0],
diff --git a/twine/cli.py b/twine/cli.py
index 68f568c..173ad51 100644
--- a/twine/cli.py
+++ b/twine/cli.py
@@ -72,4 +72,4 @@ def dispatch(argv):
 
     main = registered_commands[args.command].load()
 
-    main(args.args)
+    return main(args.args)
diff --git a/twine/commands/__init__.py b/twine/commands/__init__.py
index 3048666..1bb61ae 100644
--- a/twine/commands/__init__.py
+++ b/twine/commands/__init__.py
@@ -13,3 +13,38 @@
 # limitations under the License.
 from __future__ import absolute_import, division, print_function
 from __future__ import unicode_literals
+
+import glob
+import os.path
+
+from twine import exceptions
+
+__all__ = []
+
+
+def _group_wheel_files_first(files):
+    if not any(fname for fname in files if fname.endswith(".whl")):
+        # Return early if there's no wheel files
+        return files
+
+    files.sort(key=lambda x: -1 if x.endswith(".whl") else 0)
+
+    return files
+
+
+def _find_dists(dists):
+    uploads = []
+    for filename in dists:
+        if os.path.exists(filename):
+            uploads.append(filename)
+            continue
+        # The filename didn't exist so it may be a glob
+        files = glob.glob(filename)
+        # If nothing matches, files is []
+        if not files:
+            raise exceptions.InvalidDistribution(
+                "Cannot find file (or expand pattern): '%s'" % filename
+            )
+        # Otherwise, files will be filenames that exist
+        uploads.extend(files)
+    return _group_wheel_files_first(uploads)
diff --git a/twine/commands/check.py b/twine/commands/check.py
new file mode 100644
index 0000000..2037761
--- /dev/null
+++ b/twine/commands/check.py
@@ -0,0 +1,126 @@
+# Copyright 2018 Dustin Ingram
+#
+# 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
+#
+# http://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.
+from __future__ import absolute_import, division, print_function
+from __future__ import unicode_literals
+
+import argparse
+import cgi
+import re
+import sys
+
+try:
+    from StringIO import StringIO
+except ImportError:
+    from _io import StringIO
+
+import readme_renderer.markdown
+import readme_renderer.rst
+import readme_renderer.txt
+
+from twine.commands import _find_dists
+from twine.package import PackageFile
+
+_RENDERERS = {
+    None: readme_renderer.rst,  # Default if description_content_type is None
+    "": readme_renderer.rst,  # Default if description_content_type is None
+    "text/plain": readme_renderer.txt,
+    "text/x-rst": readme_renderer.rst,
+    "text/markdown": readme_renderer.markdown,
+}
+
+
+# Regular expression used to capture and reformat doctuils warnings into
+# something that a human can understand. This is loosely borrowed from
+# Sphinx: https://github.com/sphinx-doc/sphinx/blob
+# /c35eb6fade7a3b4a6de4183d1dd4196f04a5edaf/sphinx/util/docutils.py#L199
+_REPORT_RE = re.compile(
+    r"^<string>:(?P<line>(?:\d+)?): "
+    r"\((?P<level>DEBUG|INFO|WARNING|ERROR|SEVERE)/(\d+)?\) "
+    r"(?P<message>.*)",
+    re.DOTALL | re.MULTILINE,
+)
+
+
+class _WarningStream(object):
+    def __init__(self):
+        self.output = StringIO()
+
+    def write(self, text):
+        matched = _REPORT_RE.search(text)
+
+        if not matched:
+            self.output.write(text)
+            return
+
+        self.output.write(
+            "line {line}: {level_text}: {message}\n".format(
+                level_text=matched.group("level").capitalize(),
+                line=matched.group("line"),
+                message=matched.group("message").rstrip("\r\n"),
+            )
+        )
+
+    def __str__(self):
+        return self.output.getvalue()
+
+
+def check(dists, output_stream=sys.stdout):
+    uploads = [i for i in _find_dists(dists) if not i.endswith(".asc")]
+    stream = _WarningStream()
+    failure = False
+
+    for filename in uploads:
+        output_stream.write("Checking distribution %s: " % filename)
+        package = PackageFile.from_filename(filename, comment=None)
+
+        metadata = package.metadata_dictionary()
+        content_type, parameters = cgi.parse_header(
+            metadata.get("description_content_type") or ""
+        )
+
+        # Get the appropriate renderer
+        renderer = _RENDERERS.get(content_type, readme_renderer.txt)
+
+        # Actually render the given value
+        rendered = renderer.render(
+            metadata.get("description"), stream=stream, **parameters
+        )
+
+        if rendered is None:
+            failure = True
+            output_stream.write("Failed\n")
+            output_stream.write(
+                "The project's long_description has invalid markup which will "
+                "not be rendered on PyPI. The following syntax errors were "
+                "detected:\n%s" % stream
+            )
+        else:
+            output_stream.write("Passed\n")
+
+    return failure
+
+
+def main(args):
+    parser = argparse.ArgumentParser(prog="twine check")
+    parser.add_argument(
+        "dists",
+        nargs="+",
+        metavar="dist",
+        help="The distribution files to check, usually dist/*",
+    )
+
+    args = parser.parse_args(args)
+
+    # Call the check function with the arguments from the command line
+    return check(args.dists)
diff --git a/twine/commands/register.py b/twine/commands/register.py
index 30400f8..11e551a 100644
--- a/twine/commands/register.py
+++ b/twine/commands/register.py
@@ -16,8 +16,8 @@ from __future__ import absolute_import, unicode_literals, print_function
 import argparse
 import os.path
 
-from twine import exceptions as exc
 from twine.package import PackageFile
+from twine import exceptions
 from twine import settings
 
 
@@ -28,7 +28,7 @@ def register(register_settings, package):
     repository = register_settings.create_repository()
 
     if not os.path.exists(package):
-        raise exc.PackageNotFound(
+        raise exceptions.PackageNotFound(
             '"{0}" does not exist on the file system.'.format(package)
         )
 
@@ -38,7 +38,7 @@ def register(register_settings, package):
     repository.close()
 
     if resp.is_redirect:
-        raise exc.RedirectDetected(
+        raise exceptions.RedirectDetected(
             ('"{0}" attempted to redirect to "{1}" during registration.'
              ' Aborting...').format(repository_url,
                                     resp.headers["location"]))
diff --git a/twine/commands/upload.py b/twine/commands/upload.py
index 96efc81..01eea0f 100644
--- a/twine/commands/upload.py
+++ b/twine/commands/upload.py
@@ -15,64 +15,40 @@ from __future__ import absolute_import, division, print_function
 from __future__ import unicode_literals
 
 import argparse
-import glob
 import os.path
-import sys
 
-import twine.exceptions as exc
+from twine.commands import _find_dists
 from twine.package import PackageFile
+from twine import exceptions
 from twine import settings
 from twine import utils
 
 
-def group_wheel_files_first(files):
-    if not any(fname for fname in files if fname.endswith(".whl")):
-        # Return early if there's no wheel files
-        return files
-
-    files.sort(key=lambda x: -1 if x.endswith(".whl") else 0)
-
-    return files
-
-
-def find_dists(dists):
-    uploads = []
-    for filename in dists:
-        if os.path.exists(filename):
-            uploads.append(filename)
-            continue
-        # The filename didn't exist so it may be a glob
-        files = glob.glob(filename)
-        # If nothing matches, files is []
-        if not files:
-            raise ValueError(
-                "Cannot find file (or expand pattern): '%s'" % filename
-                )
-        # Otherwise, files will be filenames that exist
-        uploads.extend(files)
-    return group_wheel_files_first(uploads)
-
-
 def skip_upload(response, skip_existing, package):
     filename = package.basefilename
     # NOTE(sigmavirus24): Old PyPI returns the first message while Warehouse
     # returns the latter. This papers over the differences.
-    msg = ('A file named "{0}" already exists for'.format(filename),
-           'File already exists')
+    msg_400 = ('A file named "{0}" already exists for'.format(filename),
+               'File already exists')
+    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 status code. We only want to skip an upload if:
+    # 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 to return to us.
+    #       what we expect PyPI 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))))
+            (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)
+    dists = _find_dists(dists)
 
     # Determine if the user has passed in pre-signed distributions
     signatures = dict(
@@ -114,7 +90,7 @@ def upload(upload_settings, dists):
         # by PyPI should never happen in reality. This should catch malicious
         # redirects as well.
         if resp.is_redirect:
-            raise exc.RedirectDetected(
+            raise exceptions.RedirectDetected(
                 ('"{0}" attempted to redirect to "{1}" during upload.'
                  ' Aborting...').format(repository_url,
                                         resp.headers["location"]))
@@ -128,6 +104,8 @@ def upload(upload_settings, dists):
     # pool.
     repository.close()
 
+    return True
+
 
 def main(args):
     parser = argparse.ArgumentParser(prog="twine upload")
@@ -146,8 +124,4 @@ def main(args):
     upload_settings = settings.Settings.from_argparse(args)
 
     # Call the upload function with the arguments from the command line
-    upload(upload_settings, args.dists)
-
-
-if __name__ == "__main__":
-    sys.exit(main())
+    return upload(upload_settings, args.dists)
diff --git a/twine/exceptions.py b/twine/exceptions.py
index d227db6..9e35ea0 100644
--- a/twine/exceptions.py
+++ b/twine/exceptions.py
@@ -73,3 +73,15 @@ class InvalidSigningConfiguration(TwineException):
     """Both the sign and identity parameters must be present."""
 
     pass
+
+
+class InvalidConfiguration(TwineException):
+    """Raised when configuration is invalid."""
+
+    pass
+
+
+class InvalidDistribution(TwineException):
+    """Raised when a distribution is invalid."""
+
+    pass
diff --git a/twine/package.py b/twine/package.py
index 4f07361..c9e5197 100644
--- a/twine/package.py
+++ b/twine/package.py
@@ -31,6 +31,7 @@ except ImportError:
 
 from twine.wheel import Wheel
 from twine.wininst import WinInst
+from twine import exceptions
 
 DIST_TYPES = {
     "bdist_wheel": Wheel,
@@ -78,7 +79,7 @@ class PackageFile(object):
                 meta = DIST_TYPES[dtype](filename)
                 break
         else:
-            raise ValueError(
+            raise exceptions.InvalidDistribution(
                 "Unknown distribution format: '%s'" %
                 os.path.basename(filename)
             )
@@ -151,7 +152,9 @@ class PackageFile(object):
 
     def add_gpg_signature(self, signature_filepath, signature_filename):
         if self.gpg_signature is not None:
-            raise ValueError('GPG Signature can only be added once')
+            raise exceptions.InvalidDistribution(
+                'GPG Signature can only be added once'
+            )
 
         with open(signature_filepath, "rb") as gpg:
             self.gpg_signature = (signature_filename, gpg.read())
diff --git a/twine/utils.py b/twine/utils.py
index e00d145..83a1867 100644
--- a/twine/utils.py
+++ b/twine/utils.py
@@ -35,7 +35,7 @@ try:
 except ImportError:
     from urllib.parse import urlparse, urlunparse
 
-import twine.exceptions
+from twine import exceptions
 
 # Shim for raw_input in python3
 if sys.version_info > (3,):
@@ -109,7 +109,7 @@ def get_repository_from_config(config_file, repository, repository_url=None):
             "password": None,
         }
     if repository_url and "://" not in repository_url:
-        raise twine.exceptions.UnreachableRepositoryURLDetected(
+        raise exceptions.UnreachableRepositoryURLDetected(
             "Repository URL {0} has no protocol. Please add "
             "'https://'. \n".format(repository_url))
     try:
@@ -125,7 +125,7 @@ def get_repository_from_config(config_file, repository, repository_url=None):
             repo=repository,
             cfg=config_file
         )
-        raise KeyError(msg)
+        raise exceptions.InvalidConfiguration(msg)
 
 
 _HOSTNAMES = set(["pypi.python.org", "testpypi.python.org", "upload.pypi.org",
@@ -207,12 +207,11 @@ def password_prompt(prompt_text):  # Always expects unicode for our own sanity
 
 
 def get_password_from_keyring(system, username):
-    try:
-        import keyring
-    except ImportError:
+    if 'keyring' not in sys.modules:
         return
 
     try:
+        import keyring
         return keyring.get_password(system, username)
     except Exception as exc:
         warnings.warn(str(exc))
diff --git a/twine/wheel.py b/twine/wheel.py
index cf6155d..8a52d6e 100644
--- a/twine/wheel.py
+++ b/twine/wheel.py
@@ -26,6 +26,8 @@ except ImportError:
 from pkginfo import distribution
 from pkginfo.distribution import Distribution
 
+from twine import exceptions
+
 # Monkeypatch Metadata 2.0 support
 distribution.HEADER_ATTRS_2_0 = distribution.HEADER_ATTRS_1_2
 distribution.HEADER_ATTRS.update({"2.0": distribution.HEADER_ATTRS_2_0})
@@ -69,7 +71,9 @@ class Wheel(Distribution):
     def read(self):
         fqn = os.path.abspath(os.path.normpath(self.filename))
         if not os.path.exists(fqn):
-            raise ValueError('No such file: %s' % fqn)
+            raise exceptions.InvalidDistribution(
+                'No such file: %s' % fqn
+            )
 
         if fqn.endswith('.whl'):
             archive = zipfile.ZipFile(fqn)
@@ -78,7 +82,9 @@ class Wheel(Distribution):
             def read_file(name):
                 return archive.read(name)
         else:
-            raise ValueError('Not a known archive format: %s' % fqn)
+            raise exceptions.InvalidDistribution(
+                'Not a known archive format: %s' % fqn
+            )
 
         try:
             for path in self.find_candidate_metadata_files(names):
@@ -89,7 +95,9 @@ class Wheel(Distribution):
         finally:
             archive.close()
 
-        raise ValueError('No METADATA in archive: %s' % fqn)
+        raise exceptions.InvalidDistribution(
+            'No METADATA in archive: %s' % fqn
+        )
 
     def parse(self, data):
         super(Wheel, self).parse(data)
diff --git a/twine/wininst.py b/twine/wininst.py
index 6951070..5e8932a 100644
--- a/twine/wininst.py
+++ b/twine/wininst.py
@@ -7,6 +7,8 @@ import zipfile
 
 from pkginfo.distribution import Distribution
 
+from twine import exceptions
+
 wininst_file_re = re.compile(r".*py(?P<pyver>\d+\.\d+)\.exe$")
 
 
@@ -28,7 +30,9 @@ class WinInst(Distribution):
     def read(self):
         fqn = os.path.abspath(os.path.normpath(self.filename))
         if not os.path.exists(fqn):
-            raise ValueError('No such file: %s' % fqn)
+            raise exceptions.InvalidDistribution(
+                'No such file: %s' % fqn
+            )
 
         if fqn.endswith('.exe'):
             archive = zipfile.ZipFile(fqn)
@@ -37,7 +41,9 @@ class WinInst(Distribution):
             def read_file(name):
                 return archive.read(name)
         else:
-            raise ValueError('Not a known archive format: %s' % fqn)
+            raise exceptions.InvalidDistribution(
+                'Not a known archive format: %s' % fqn
+            )
 
         try:
             tuples = [x.split('/') for x in names
@@ -51,4 +57,6 @@ class WinInst(Distribution):
         finally:
             archive.close()
 
-        raise ValueError('No PKG-INFO/.egg-info in archive: %s' % fqn)
+        raise exceptions.InvalidDistribution(
+            'No PKG-INFO/.egg-info in archive: %s' % fqn
+        )

How to disable keyring
Twine [supports keyring](https://github.com/pypa/twine/issues/277), but it's not clear to me how to  use twine without this support. Is this implemented or do we just miss documentation?
diff --git a/tests/test_check.py b/tests/test_check.py
new file mode 100644
index 0000000..58bfbdf
--- /dev/null
+++ b/tests/test_check.py
@@ -0,0 +1,119 @@
+# Copyright 2018 Dustin Ingram
+#
+# 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
+#
+# http://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.
+from __future__ import unicode_literals
+
+import pretend
+
+from twine.commands import check
+
+
+def test_warningstream_write_match():
+    stream = check._WarningStream()
+    stream.output = pretend.stub(write=pretend.call_recorder(lambda a: None))
+
+    stream.write("<string>:2: (WARNING/2) Title underline too short.")
+
+    assert stream.output.write.calls == [
+        pretend.call("line 2: Warning: Title underline too short.\n")
+    ]
+
+
+def test_warningstream_write_nomatch():
+    stream = check._WarningStream()
+    stream.output = pretend.stub(write=pretend.call_recorder(lambda a: None))
+
+    stream.write("this does not match")
+
+    assert stream.output.write.calls == [pretend.call("this does not match")]
+
+
+def test_warningstream_str():
+    stream = check._WarningStream()
+    stream.output = pretend.stub(getvalue=lambda: "result")
+
+    assert str(stream) == "result"
+
+
+def test_check_no_distributions(monkeypatch):
+    stream = check.StringIO()
+
+    monkeypatch.setattr(check, "_find_dists", lambda a: [])
+
+    assert not check.check("dist/*", output_stream=stream)
+    assert stream.getvalue() == ""
+
+
+def test_check_passing_distribution(monkeypatch):
+    renderer = pretend.stub(
+        render=pretend.call_recorder(lambda *a, **kw: "valid")
+    )
+    package = pretend.stub(metadata_dictionary=lambda: {"description": "blah"})
+    output_stream = check.StringIO()
+    warning_stream = ""
+
+    monkeypatch.setattr(check, "_RENDERERS", {"": renderer})
+    monkeypatch.setattr(check, "_find_dists", lambda a: ["dist/dist.tar.gz"])
+    monkeypatch.setattr(
+        check,
+        "PackageFile",
+        pretend.stub(from_filename=lambda *a, **kw: package),
+    )
+    monkeypatch.setattr(check, "_WarningStream", lambda: warning_stream)
+
+    assert not check.check("dist/*", output_stream=output_stream)
+    assert (
+        output_stream.getvalue()
+        == "Checking distribution dist/dist.tar.gz: Passed\n"
+    )
+    assert renderer.render.calls == [
+        pretend.call("blah", stream=warning_stream)
+    ]
+
+
+def test_check_failing_distribution(monkeypatch):
+    renderer = pretend.stub(
+        render=pretend.call_recorder(lambda *a, **kw: None)
+    )
+    package = pretend.stub(metadata_dictionary=lambda: {"description": "blah"})
+    output_stream = check.StringIO()
+    warning_stream = "WARNING"
+
+    monkeypatch.setattr(check, "_RENDERERS", {"": renderer})
+    monkeypatch.setattr(check, "_find_dists", lambda a: ["dist/dist.tar.gz"])
+    monkeypatch.setattr(
+        check,
+        "PackageFile",
+        pretend.stub(from_filename=lambda *a, **kw: package),
+    )
+    monkeypatch.setattr(check, "_WarningStream", lambda: warning_stream)
+
+    assert check.check("dist/*", output_stream=output_stream)
+    assert output_stream.getvalue() == (
+        "Checking distribution dist/dist.tar.gz: Failed\n"
+        "The project's long_description has invalid markup which will not be "
+        "rendered on PyPI. The following syntax errors were detected:\n"
+        "WARNING"
+    )
+    assert renderer.render.calls == [
+        pretend.call("blah", stream=warning_stream)
+    ]
+
+
+def test_main(monkeypatch):
+    check_result = pretend.stub()
+    check_stub = pretend.call_recorder(lambda a: check_result)
+    monkeypatch.setattr(check, "check", check_stub)
+
+    assert check.main(["dist/*"]) == check_result
+    assert check_stub.calls == [pretend.call(["dist/*"])]
diff --git a/tests/test_commands.py b/tests/test_commands.py
new file mode 100644
index 0000000..af556af
--- /dev/null
+++ b/tests/test_commands.py
@@ -0,0 +1,51 @@
+import os
+
+import pytest
+
+from twine.commands import _find_dists, _group_wheel_files_first
+from twine import exceptions
+
+
+def test_ensure_wheel_files_uploaded_first():
+    files = _group_wheel_files_first(
+        ["twine/foo.py", "twine/first.whl", "twine/bar.py", "twine/second.whl"]
+    )
+    expected = [
+        "twine/first.whl",
+        "twine/second.whl",
+        "twine/foo.py",
+        "twine/bar.py",
+    ]
+    assert expected == files
+
+
+def test_ensure_if_no_wheel_files():
+    files = _group_wheel_files_first(["twine/foo.py", "twine/bar.py"])
+    expected = ["twine/foo.py", "twine/bar.py"]
+    assert expected == files
+
+
+def test_find_dists_expands_globs():
+    files = sorted(_find_dists(["twine/__*.py"]))
+    expected = [
+        os.path.join("twine", "__init__.py"),
+        os.path.join("twine", "__main__.py"),
+    ]
+    assert expected == files
+
+
+def test_find_dists_errors_on_invalid_globs():
+    with pytest.raises(exceptions.InvalidDistribution):
+        _find_dists(["twine/*.rb"])
+
+
+def test_find_dists_handles_real_files():
+    expected = [
+        "twine/__init__.py",
+        "twine/__main__.py",
+        "twine/cli.py",
+        "twine/utils.py",
+        "twine/wheel.py",
+    ]
+    files = _find_dists(expected)
+    assert expected == files
diff --git a/tests/test_main.py b/tests/test_main.py
index e7c6037..9c8d20c 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -11,11 +11,14 @@
 # limitations under the License.
 
 from twine import __main__ as dunder_main
+from twine import exceptions
 
 import pretend
 
 
 def test_exception_handling(monkeypatch):
-    replaced_dispatch = pretend.raiser(KeyError('foo'))
+    replaced_dispatch = pretend.raiser(
+        exceptions.InvalidConfiguration('foo')
+    )
     monkeypatch.setattr(dunder_main, 'dispatch', replaced_dispatch)
-    assert dunder_main.main() == 'KeyError: foo'
+    assert dunder_main.main() == 'InvalidConfiguration: foo'
diff --git a/tests/test_upload.py b/tests/test_upload.py
index 3d32aa0..181f613 100644
--- a/tests/test_upload.py
+++ b/tests/test_upload.py
@@ -28,45 +28,6 @@ import helpers
 WHEEL_FIXTURE = 'tests/fixtures/twine-1.5.0-py2.py3-none-any.whl'
 
 
-def test_ensure_wheel_files_uploaded_first():
-    files = upload.group_wheel_files_first(["twine/foo.py",
-                                            "twine/first.whl",
-                                            "twine/bar.py",
-                                            "twine/second.whl"])
-    expected = ["twine/first.whl",
-                "twine/second.whl",
-                "twine/foo.py",
-                "twine/bar.py"]
-    assert expected == files
-
-
-def test_ensure_if_no_wheel_files():
-    files = upload.group_wheel_files_first(["twine/foo.py",
-                                            "twine/bar.py"])
-    expected = ["twine/foo.py",
-                "twine/bar.py"]
-    assert expected == files
-
-
-def test_find_dists_expands_globs():
-    files = sorted(upload.find_dists(['twine/__*.py']))
-    expected = [os.path.join('twine', '__init__.py'),
-                os.path.join('twine', '__main__.py')]
-    assert expected == files
-
-
-def test_find_dists_errors_on_invalid_globs():
-    with pytest.raises(ValueError):
-        upload.find_dists(['twine/*.rb'])
-
-
-def test_find_dists_handles_real_files():
-    expected = ['twine/__init__.py', 'twine/__main__.py', 'twine/cli.py',
-                'twine/utils.py', 'twine/wheel.py']
-    files = upload.find_dists(expected)
-    assert expected == files
-
-
 def test_get_config_old_format(tmpdir):
     pypirc = os.path.join(str(tmpdir), ".pypirc")
 
@@ -156,6 +117,21 @@ def test_skip_existing_skips_files_already_on_pypiserver(monkeypatch):
                               package=pkg) is True
 
 
+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).")
+
+    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,
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 4d60e04..520b78f 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -18,11 +18,6 @@ import sys
 import os.path
 import textwrap
 
-try:
-    import builtins
-except ImportError:
-    import __builtin__ as builtins
-
 import pytest
 
 from twine import utils
@@ -239,13 +234,7 @@ def keyring_missing(monkeypatch):
     """
     Simulate that 'import keyring' raises an ImportError
     """
-    real_import = builtins.__import__
-
-    def my_import(name, *args, **kwargs):
-        if name == 'keyring':
-            raise ImportError
-        return real_import(name, *args, **kwargs)
-    monkeypatch.setattr(builtins, '__import__', my_import)
+    monkeypatch.delitem(sys.modules, 'keyring', raising=False)
 
 
 @pytest.fixture

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest coverage pretend pyblake2
apt-get update apt-get install -y gcc
3.6
docs/requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.13
attrs==22.2.0
Babel==2.11.0
bleach==4.1.0
certifi==2021.5.30
cffi==1.15.1
charset-normalizer==2.0.12
colorama==0.4.5
coverage==6.2
cryptography==40.0.2
distlib==0.3.9
doc8==0.11.2
docutils==0.18.1
filelock==3.4.1
idna==3.10
imagesize==1.4.1
importlib-metadata==4.8.3
importlib-resources==5.4.0
iniconfig==1.1.1
jeepney==0.7.1
Jinja2==3.0.3
keyring==23.4.1
MarkupSafe==2.0.1
packaging==21.3
pbr==6.1.1
pkginfo==1.10.0
platformdirs==2.4.0
pluggy==1.0.0
pretend==1.0.9
py==1.11.0
pyblake2==1.1.2
pycparser==2.21
Pygments==2.14.0
pyparsing==3.1.4
pytest==7.0.1
pytz==2025.2
readme-renderer==34.0
releases==2.1.1
requests==2.27.1
requests-toolbelt==1.0.0
restructuredtext-lint==1.4.0
rfc3986==1.5.0
SecretStorage==3.3.3
semantic-version==2.6.0
six==1.17.0
snowballstemmer==2.2.0
Sphinx==5.3.0
sphinx-rtd-theme==2.0.0
sphinxcontrib-applehelp==1.0.2
sphinxcontrib-devhelp==1.0.2
sphinxcontrib-htmlhelp==2.0.0
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==1.0.3
sphinxcontrib-serializinghtml==1.1.5
stevedore==3.5.2
toml==0.10.2
tomli==1.2.3
tox==3.28.0
tqdm==4.64.1
-e git+https://github.com/pypa/twine.git@4504db8b12cff0a289d939b9e369b390b9085fde#egg=twine
typing_extensions==4.1.1
urllib3==1.26.20
virtualenv==20.17.1
webencodings==0.5.1
zipp==3.6.0

name: twine
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.13
      - attrs==22.2.0
      - babel==2.11.0
      - bleach==4.1.0
      - cffi==1.15.1
      - charset-normalizer==2.0.12
      - colorama==0.4.5
      - coverage==6.2
      - cryptography==40.0.2
      - distlib==0.3.9
      - doc8==0.11.2
      - docutils==0.18.1
      - filelock==3.4.1
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==4.8.3
      - importlib-resources==5.4.0
      - iniconfig==1.1.1
      - jeepney==0.7.1
      - jinja2==3.0.3
      - keyring==23.4.1
      - markupsafe==2.0.1
      - packaging==21.3
      - pbr==6.1.1
      - pkginfo==1.10.0
      - platformdirs==2.4.0
      - pluggy==1.0.0
      - pretend==1.0.9
      - py==1.11.0
      - pyblake2==1.1.2
      - pycparser==2.21
      - pygments==2.14.0
      - pyparsing==3.1.4
      - pytest==7.0.1
      - pytz==2025.2
      - readme-renderer==34.0
      - releases==2.1.1
      - requests==2.27.1
      - requests-toolbelt==1.0.0
      - restructuredtext-lint==1.4.0
      - rfc3986==1.5.0
      - secretstorage==3.3.3
      - semantic-version==2.6.0
      - six==1.17.0
      - snowballstemmer==2.2.0
      - sphinx==5.3.0
      - sphinx-rtd-theme==2.0.0
      - sphinxcontrib-applehelp==1.0.2
      - sphinxcontrib-devhelp==1.0.2
      - sphinxcontrib-htmlhelp==2.0.0
      - sphinxcontrib-jquery==4.1
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==1.0.3
      - sphinxcontrib-serializinghtml==1.1.5
      - stevedore==3.5.2
      - toml==0.10.2
      - tomli==1.2.3
      - tox==3.28.0
      - tqdm==4.64.1
      - typing-extensions==4.1.1
      - urllib3==1.26.20
      - virtualenv==20.17.1
      - webencodings==0.5.1
      - zipp==3.6.0
prefix: /opt/conda/envs/twine

tests/test_check.py::test_warningstream_write_match tests/test_check.py::test_warningstream_write_nomatch tests/test_check.py::test_warningstream_str tests/test_check.py::test_check_no_distributions tests/test_check.py::test_check_passing_distribution tests/test_check.py::test_check_failing_distribution tests/test_check.py::test_main tests/test_commands.py::test_ensure_wheel_files_uploaded_first tests/test_commands.py::test_ensure_if_no_wheel_files tests/test_commands.py::test_find_dists_expands_globs tests/test_commands.py::test_find_dists_errors_on_invalid_globs tests/test_commands.py::test_find_dists_handles_real_files tests/test_main.py::test_exception_handling tests/test_upload.py::test_get_config_old_format tests/test_upload.py::test_deprecated_repo tests/test_upload.py::test_skip_existing_skips_files_already_on_PyPI tests/test_upload.py::test_skip_existing_skips_files_already_on_pypiserver tests/test_upload.py::test_skip_existing_skips_files_already_on_artifactory tests/test_upload.py::test_skip_upload_respects_skip_existing tests/test_upload.py::test_values_from_env tests/test_utils.py::test_get_config tests/test_utils.py::test_get_config_no_distutils tests/test_utils.py::test_get_config_no_section tests/test_utils.py::test_get_config_override_pypi_url tests/test_utils.py::test_get_config_missing tests/test_utils.py::test_get_repository_config_missing tests/test_utils.py::test_get_config_deprecated_pypirc tests/test_utils.py::test_get_userpass_value[cli-config0-key-<lambda>-cli] tests/test_utils.py::test_get_userpass_value[None-config1-key-<lambda>-value] tests/test_utils.py::test_get_userpass_value[None-config2-key-<lambda>-fallback] tests/test_utils.py::test_default_to_environment_action[MY_PASSWORD-None-environ0-None] tests/test_utils.py::test_default_to_environment_action[MY_PASSWORD-None-environ1-foo] tests/test_utils.py::test_default_to_environment_action[URL-https://example.org-environ2-https://example.org] tests/test_utils.py::test_default_to_environment_action[URL-https://example.org-environ3-https://pypi.org] tests/test_utils.py::test_get_password_keyring_overrides_prompt tests/test_utils.py::test_get_password_keyring_defers_to_prompt tests/test_utils.py::test_get_password_keyring_missing_prompts tests/test_utils.py::test_get_password_runtime_error_suppressed tests/test_utils.py::test_no_positional_on_method tests/test_utils.py::test_no_positional_on_function
README.rst twine/wininst.py twine/commands/upload.py setup.py twine/commands/register.py docs/changelog.rst twine/utils.py twine/wheel.py twine/cli.py tox.ini setup.cfg twine/exceptions.py twine/commands/__init__.py twine/__main__.py twine/package.py twine/commands/check.py
README.rst twine/wininst.py twine/commands/upload.py setup.py twine/commands/register.py docs/changelog.rst twine/utils.py twine/wheel.py twine/cli.py tox.ini setup.cfg twine/exceptions.py twine/commands/__init__.py twine/__main__.py twine/package.py twine/commands/check.py