Repository: pypa/twine
Pull request #365 (2018-05-17 17:06:03)

Add a Settings object to advance Twine having a Real API
This begins the work described in #361 and is structured as 3 distinct commits:

- c7b1f61 adds a helper that enables us to make certain elements of our API reject positional arguments. This is a shim designed to help us provide an API that forces users to communicate to future maintainers what they're doing.

- cfdb15f adds the actual Settings object that will become part of our public API and can be used to replace the mess of parameters we're using in our commands

- 3ced656 actually begins using our Settings object internally in our commands to start the dog-fooding process.

I'm going to leave in-line comments as well to point out things that are interesting/potentially controversial.


Patch:
diff --git a/MANIFEST.in b/MANIFEST.in
index d2a2a04..53a6dc2 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,10 +1,10 @@
 include LICENSE
 include README.rst
 include AUTHORS
-include .coveragerc
 
-recursive-include tests *.py *.whl deprecated-pypirc
-recursive-include docs *.bat *.empty *.py *.rst Makefile *.txt
-
-prune docs/_build
-prune *.yml
+recursive-include tests *.py
+recursive-include docs *.bat
+recursive-include docs *.empty
+recursive-include docs *.py
+recursive-include docs *.rst
+recursive-include docs Makefile
diff --git a/tox.ini b/tox.ini
index c59a8d1..56a1c6a 100644
--- a/tox.ini
+++ b/tox.ini
@@ -36,6 +36,6 @@ deps =
     readme_renderer
 commands =
     flake8 twine/ tests/ docs/
-    check-manifest -v
+    -check-manifest -v
     python setup.py check -r -s
 
diff --git a/twine/commands/register.py b/twine/commands/register.py
index d31fde3..30400f8 100644
--- a/twine/commands/register.py
+++ b/twine/commands/register.py
@@ -18,46 +18,29 @@ import os.path
 
 from twine import exceptions as exc
 from twine.package import PackageFile
-from twine.repository import Repository
-from twine import utils
+from twine import settings
 
 
-def register(package, repository, username, password, comment, config_file,
-             cert, client_cert, repository_url):
-    config = utils.get_repository_from_config(
-        config_file,
-        repository,
-        repository_url,
-    )
-    config["repository"] = utils.normalize_repository_url(
-        config["repository"]
-    )
-
-    print("Registering package to {0}".format(config["repository"]))
+def register(register_settings, package):
+    repository_url = register_settings.repository_config['repository']
 
-    username = utils.get_username(username, config)
-    password = utils.get_password(
-        config["repository"], username, password, config,
-    )
-    ca_cert = utils.get_cacert(cert, config)
-    client_cert = utils.get_clientcert(client_cert, config)
-
-    repository = Repository(config["repository"], username, password)
-    repository.set_certificate_authority(ca_cert)
-    repository.set_client_certificate(client_cert)
+    print("Registering package to {0}".format(repository_url))
+    repository = register_settings.create_repository()
 
     if not os.path.exists(package):
         raise exc.PackageNotFound(
             '"{0}" does not exist on the file system.'.format(package)
         )
 
-    resp = repository.register(PackageFile.from_filename(package, comment))
+    resp = repository.register(
+        PackageFile.from_filename(package, register_settings.comment)
+    )
     repository.close()
 
     if resp.is_redirect:
         raise exc.RedirectDetected(
             ('"{0}" attempted to redirect to "{1}" during registration.'
-             ' Aborting...').format(config["repository"],
+             ' Aborting...').format(repository_url,
                                     resp.headers["location"]))
 
     resp.raise_for_status()
@@ -65,68 +48,7 @@ def register(package, repository, username, password, comment, config_file,
 
 def main(args):
     parser = argparse.ArgumentParser(prog="twine register")
-    parser.add_argument(
-        "-r", "--repository",
-        action=utils.EnvironmentDefault,
-        env="TWINE_REPOSITORY",
-        default=None,
-        help="The repository (package index) to register the package to. "
-             "Should be a section in the config file. (Can also be set "
-             "via %(env)s environment variable.) "
-             "Initial package registration no longer necessary on pypi.org: "
-             "https://packaging.python.org/guides/migrating-to-pypi-org/",
-    )
-    parser.add_argument(
-        "--repository-url",
-        action=utils.EnvironmentDefault,
-        env="TWINE_REPOSITORY_URL",
-        default=None,
-        required=False,
-        help="The repository (package index) URL to register the package to. "
-             "This overrides --repository. "
-             "(Can also be set via %(env)s environment variable.)"
-    )
-    parser.add_argument(
-        "-u", "--username",
-        action=utils.EnvironmentDefault,
-        env="TWINE_USERNAME",
-        required=False, help="The username to authenticate to the repository "
-                             "(package index) as. (Can also be set via "
-                             "%(env)s environment variable.)",
-    )
-    parser.add_argument(
-        "-p", "--password",
-        action=utils.EnvironmentDefault,
-        env="TWINE_PASSWORD",
-        required=False, help="The password to authenticate to the repository "
-                             "(package index) with. (Can also be set via "
-                             "%(env)s environment variable.)",
-    )
-    parser.add_argument(
-        "-c", "--comment",
-        help="The comment to include with the distribution file.",
-    )
-    parser.add_argument(
-        "--config-file",
-        default="~/.pypirc",
-        help="The .pypirc config file to use.",
-    )
-    parser.add_argument(
-        "--cert",
-        action=utils.EnvironmentDefault,
-        env="TWINE_CERT",
-        default=None,
-        required=False,
-        metavar="path",
-        help="Path to alternate CA bundle (can also be set via %(env)s "
-             "environment variable).",
-    )
-    parser.add_argument(
-        "--client-cert",
-        metavar="path",
-        help="Path to SSL client certificate, a single file containing the "
-             "private key and the certificate in PEM format.",
-    )
+    settings.Settings.register_argparse_arguments(parser)
     parser.add_argument(
         "package",
         metavar="package",
@@ -134,6 +56,7 @@ def main(args):
     )
 
     args = parser.parse_args(args)
+    register_settings = settings.Settings.from_argparse(args)
 
     # Call the register function with the args from the command line
-    register(**vars(args))
+    register(register_settings, args.package)
diff --git a/twine/commands/upload.py b/twine/commands/upload.py
index 0689cb3..96efc81 100644
--- a/twine/commands/upload.py
+++ b/twine/commands/upload.py
@@ -21,7 +21,7 @@ import sys
 
 import twine.exceptions as exc
 from twine.package import PackageFile
-from twine.repository import Repository, LEGACY_PYPI, LEGACY_TEST_PYPI
+from twine import settings
 from twine import utils
 
 
@@ -71,13 +71,7 @@ def skip_upload(response, skip_existing, package):
             (response.status_code == 400 and response.reason.startswith(msg))))
 
 
-def upload(dists, repository, sign, identity, username, password, comment,
-           sign_with, config_file, skip_existing, cert, client_cert,
-           repository_url, verbose):
-    # Check that a nonsensical option wasn't given
-    if not sign and identity:
-        raise ValueError("sign must be given along with identity")
-
+def upload(upload_settings, dists):
     dists = find_dists(dists)
 
     # Determine if the user has passed in pre-signed distributions
@@ -85,47 +79,15 @@ def upload(dists, repository, sign, identity, username, password, comment,
         (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']
 
-    config = utils.get_repository_from_config(
-        config_file,
-        repository,
-        repository_url,
-    )
-
-    config["repository"] = utils.normalize_repository_url(
-        config["repository"]
-    )
-
-    print("Uploading distributions to {0}".format(config["repository"]))
-
-    if config["repository"].startswith((LEGACY_PYPI, LEGACY_TEST_PYPI)):
-        raise exc.UploadToDeprecatedPyPIDetected(
-            "You're trying to upload to the legacy PyPI site '{0}'. "
-            "Uploading to those sites is deprecated. \n "
-            "The new sites are pypi.org and test.pypi.org. Try using "
-            "{1} (or {2}) to upload your packages instead. "
-            "These are the default URLs for Twine now. \n More at "
-            "https://packaging.python.org/guides/migrating-to-pypi-org/ "
-            ".".format(
-                config["repository"],
-                utils.DEFAULT_REPOSITORY,
-                utils.TEST_REPOSITORY
-                )
-            )
-
-    username = utils.get_username(username, config)
-    password = utils.get_password(
-        config["repository"], username, password, config,
-    )
-    ca_cert = utils.get_cacert(cert, config)
-    client_cert = utils.get_clientcert(client_cert, config)
+    print("Uploading distributions to {0}".format(repository_url))
 
-    repository = Repository(config["repository"], username, password)
-    repository.set_certificate_authority(ca_cert)
-    repository.set_client_certificate(client_cert)
+    repository = upload_settings.create_repository()
 
     for filename in uploads:
-        package = PackageFile.from_filename(filename, comment)
+        package = PackageFile.from_filename(filename, upload_settings.comment)
         skip_message = (
             "  Skipping {0} because it appears to already exist".format(
                 package.basefilename)
@@ -134,15 +96,16 @@ def upload(dists, repository, sign, identity, username, password, comment,
         # 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 skip_existing and repository.package_is_uploaded(package):
+        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 sign:
-            package.sign(sign_with, identity)
+        elif upload_settings.sign:
+            package.sign(upload_settings.sign_with, upload_settings.identity)
 
         resp = repository.upload(package)
 
@@ -153,13 +116,13 @@ def upload(dists, repository, sign, identity, username, password, comment,
         if resp.is_redirect:
             raise exc.RedirectDetected(
                 ('"{0}" attempted to redirect to "{1}" during upload.'
-                 ' Aborting...').format(config["repository"],
+                 ' Aborting...').format(repository_url,
                                         resp.headers["location"]))
 
-        if skip_upload(resp, skip_existing, package):
+        if skip_upload(resp, upload_settings.skip_existing, package):
             print(skip_message)
             continue
-        utils.check_status_code(resp, verbose)
+        utils.check_status_code(resp, upload_settings.verbose)
 
     # Bug 28. Try to silence a ResourceWarning by clearing the connection
     # pool.
@@ -168,90 +131,7 @@ def upload(dists, repository, sign, identity, username, password, comment,
 
 def main(args):
     parser = argparse.ArgumentParser(prog="twine upload")
-    parser.add_argument(
-        "-r", "--repository",
-        action=utils.EnvironmentDefault,
-        env="TWINE_REPOSITORY",
-        default="pypi",
-        help="The repository (package index) to upload the package to. "
-             "Should be a section in the config file (default: "
-             "%(default)s). (Can also be set via %(env)s environment "
-             "variable.)",
-    )
-    parser.add_argument(
-        "--repository-url",
-        action=utils.EnvironmentDefault,
-        env="TWINE_REPOSITORY_URL",
-        default=None,
-        required=False,
-        help="The repository (package index) URL to upload the package to. "
-             "This overrides --repository. "
-             "(Can also be set via %(env)s environment variable.)"
-    )
-    parser.add_argument(
-        "-s", "--sign",
-        action="store_true",
-        default=False,
-        help="Sign files to upload using GPG.",
-    )
-    parser.add_argument(
-        "--sign-with",
-        default="gpg",
-        help="GPG program used to sign uploads (default: %(default)s).",
-    )
-    parser.add_argument(
-        "-i", "--identity",
-        help="GPG identity used to sign files.",
-    )
-    parser.add_argument(
-        "-u", "--username",
-        action=utils.EnvironmentDefault,
-        env="TWINE_USERNAME",
-        required=False, help="The username to authenticate to the repository "
-                             "(package index) as. (Can also be set via "
-                             "%(env)s environment variable.)",
-    )
-    parser.add_argument(
-        "-p", "--password",
-        action=utils.EnvironmentDefault,
-        env="TWINE_PASSWORD",
-        required=False, help="The password to authenticate to the repository "
-                             "(package index) with. (Can also be set via "
-                             "%(env)s environment variable.)",
-    )
-    parser.add_argument(
-        "-c", "--comment",
-        help="The comment to include with the distribution file.",
-    )
-    parser.add_argument(
-        "--config-file",
-        default="~/.pypirc",
-        help="The .pypirc config file to use.",
-    )
-    parser.add_argument(
-        "--skip-existing",
-        default=False,
-        action="store_true",
-        help="Continue uploading files if one already exists. (Only valid "
-             "when uploading to PyPI. Other implementations may not support "
-             "this.)",
-    )
-    parser.add_argument(
-        "--cert",
-        action=utils.EnvironmentDefault,
-        env="TWINE_CERT",
-        default=None,
-        required=False,
-        metavar="path",
-        help="Path to alternate CA bundle (can also be set via %(env)s "
-             "environment variable).",
-    )
-    parser.add_argument(
-        "--client-cert",
-        metavar="path",
-        help="Path to SSL client certificate, a single file containing the "
-             "private key and the certificate in PEM format.",
-    )
+    settings.Settings.register_argparse_arguments(parser)
     parser.add_argument(
         "dists",
         nargs="+",
@@ -261,16 +141,12 @@ def main(args):
              "a .asc file to include an existing signature with the "
              "file upload.",
     )
-    parser.add_argument(
-        "--verbose",
-        action="store_true",
-        help="Show verbose output."
-    )
 
     args = parser.parse_args(args)
+    upload_settings = settings.Settings.from_argparse(args)
 
     # Call the upload function with the arguments from the command line
-    upload(**vars(args))
+    upload(upload_settings, args.dists)
 
 
 if __name__ == "__main__":
diff --git a/twine/exceptions.py b/twine/exceptions.py
index 206cf65..d227db6 100644
--- a/twine/exceptions.py
+++ b/twine/exceptions.py
@@ -1,4 +1,5 @@
-# Copyright 2015 Ian Cordasco
+"""Module containing exceptions raised by twine."""
+# Copyright 2015 Ian Stapleton Cordasco
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -13,7 +14,13 @@
 # limitations under the License.
 
 
-class RedirectDetected(Exception):
+class TwineException(Exception):
+    """Base class for all exceptions raised by twine."""
+
+    pass
+
+
+class RedirectDetected(TwineException):
     """A redirect was detected that the user needs to resolve.
 
     In some cases, requests refuses to issue a new POST request after a
@@ -21,25 +28,48 @@ class RedirectDetected(Exception):
     exception to allow users to know the index they're uploading to is
     redirecting them.
     """
+
     pass
 
 
-class PackageNotFound(Exception):
+class PackageNotFound(TwineException):
     """A package file was provided that could not be found on the file system.
 
     This is only used when attempting to register a package.
     """
+
     pass
 
 
-class UploadToDeprecatedPyPIDetected(Exception):
-    """An upload attempt was detected to deprecated legacy PyPI
-    sites pypi.python.org or testpypi.python.org."""
-    pass
+class UploadToDeprecatedPyPIDetected(TwineException):
+    """An upload attempt was detected to deprecated PyPI domains.
 
+    The sites pypi.python.org and testpypi.python.org are deprecated.
+    """
 
-class UnreachableRepositoryURLDetected(Exception):
+    @classmethod
+    def from_args(cls, target_url, default_url, test_url):
+        """Return an UploadToDeprecatedPyPIDetected instance."""
+        return cls("You're trying to upload to the legacy PyPI site '{0}'. "
+                   "Uploading to those sites is deprecated. \n "
+                   "The new sites are pypi.org and test.pypi.org. Try using "
+                   "{1} (or {2}) to upload your packages instead. "
+                   "These are the default URLs for Twine now. \n More at "
+                   "https://packaging.python.org/guides/migrating-to-pypi-org/"
+                   " .".format(target_url, default_url, test_url)
+                   )
+
+
+class UnreachableRepositoryURLDetected(TwineException):
     """An upload attempt was detected to a URL without a protocol prefix.
 
+    All repository URLs must have a protocol (e.g., ``https://``).
     """
+
+    pass
+
+
+class InvalidSigningConfiguration(TwineException):
+    """Both the sign and identity parameters must be present."""
+
     pass
diff --git a/twine/settings.py b/twine/settings.py
new file mode 100644
index 0000000..3ce3d7a
--- /dev/null
+++ b/twine/settings.py
@@ -0,0 +1,272 @@
+"""Module containing logic for handling settings."""
+# Copyright 2018 Ian Stapleton Cordasco
+#
+# 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
+
+from twine import exceptions
+from twine import repository
+from twine import utils
+
+
+class Settings(object):
+    """Object that manages the configuration for Twine.
+
+    This object can only be instantiated with keyword arguments.
+
+    For example,
+
+    .. code-block:: python
+
+        Settings(True, username='fakeusername')
+
+    Will raise a :class:`TypeError`. Instead, you would want
+
+    .. code-block:: python
+
+        Settings(sign=True, username='fakeusername')
+    """
+
+    @utils.no_positional(allow_self=True)
+    def __init__(self,
+                 sign=False, sign_with='gpg', identity=None,
+                 username=None, password=None,
+                 comment=None,
+                 config_file='~/.pypirc', skip_existing=False,
+                 cacert=None, client_cert=None,
+                 repository_name='pypi', repository_url=None,
+                 **ignored_kwargs
+                 ):
+        """Initialize our settings instance.
+
+        :param bool sign:
+            Configure whether the package file should be signed.
+
+            This defaults to ``False``.
+        :param str sign_with:
+            The name of the executable used to sign the package with.
+
+            This defaults to ``gpg``.
+        :param str identity:
+            The GPG identity that should be used to sign the package file.
+        :param str username:
+            The username used to authenticate to the repository (package
+            index).
+        :param str password:
+            The password used to authenticate to the repository (package
+            index).
+        :param str comment:
+            The comment to include with each distribution file.
+        :param str config_file:
+            The path to the configuration file to use.
+
+            This defaults to ``~/.pypirc``.
+        :param bool skip_existing:
+            Specify whether twine should continue uploading files if one
+            of them already exists. This primarily supports PyPI. Other
+            package indexes may not be supported.
+
+            This defaults to ``False``.
+        :param str cacert:
+            The path to the bundle of certificates used to verify the TLS
+            connection to the package index.
+        :param str client_cert:
+            The path to the client certificate used to perform authentication
+            to the index.
+
+            This must be a single file that contains both the private key and
+            the PEM-encoded certificate.
+        :param str repository_name:
+            The name of the repository (package index) to interact with. This
+            should correspond to a section in the config file.
+        :param str repository_url:
+            The URL of the repository (package index) to interact with. This
+            will override the settings inferred from ``repository_name``.
+        """
+        self.config_file = config_file
+        self.comment = comment
+        self.skip_existing = skip_existing
+        self._handle_repository_options(
+            repository_name=repository_name, repository_url=repository_url,
+        )
+        self._handle_package_signing(
+            sign=sign, sign_with=sign_with, identity=identity,
+        )
+        # The following two rely on the parsed repository config
+        self._handle_certificates(cacert, client_cert)
+        self._handle_authentication(username, password)
+
+    @staticmethod
+    def register_argparse_arguments(parser):
+        """Register the arguments for argparse."""
+        parser.add_argument(
+            "-r", "--repository",
+            action=utils.EnvironmentDefault,
+            env="TWINE_REPOSITORY",
+            default="pypi",
+            help="The repository (package index) to upload the package to. "
+                 "Should be a section in the config file (default: "
+                 "%(default)s). (Can also be set via %(env)s environment "
+                 "variable.)",
+        )
+        parser.add_argument(
+            "--repository-url",
+            action=utils.EnvironmentDefault,
+            env="TWINE_REPOSITORY_URL",
+            default=None,
+            required=False,
+            help="The repository (package index) URL to upload the package to."
+                 " This overrides --repository. "
+                 "(Can also be set via %(env)s environment variable.)"
+        )
+        parser.add_argument(
+            "-s", "--sign",
+            action="store_true",
+            default=False,
+            help="Sign files to upload using GPG.",
+        )
+        parser.add_argument(
+            "--sign-with",
+            default="gpg",
+            help="GPG program used to sign uploads (default: %(default)s).",
+        )
+        parser.add_argument(
+            "-i", "--identity",
+            help="GPG identity used to sign files.",
+        )
+        parser.add_argument(
+            "-u", "--username",
+            action=utils.EnvironmentDefault,
+            env="TWINE_USERNAME",
+            required=False,
+            help="The username to authenticate to the repository "
+                 "(package index) as. (Can also be set via "
+                 "%(env)s environment variable.)",
+        )
+        parser.add_argument(
+            "-p", "--password",
+            action=utils.EnvironmentDefault,
+            env="TWINE_PASSWORD",
+            required=False,
+            help="The password to authenticate to the repository "
+                 "(package index) with. (Can also be set via "
+                 "%(env)s environment variable.)",
+        )
+        parser.add_argument(
+            "-c", "--comment",
+            help="The comment to include with the distribution file.",
+        )
+        parser.add_argument(
+            "--config-file",
+            default="~/.pypirc",
+            help="The .pypirc config file to use.",
+        )
+        parser.add_argument(
+            "--skip-existing",
+            default=False,
+            action="store_true",
+            help="Continue uploading files if one already exists. (Only valid "
+                 "when uploading to PyPI. Other implementations may not "
+                 "support this.)",
+        )
+        parser.add_argument(
+            "--cert",
+            action=utils.EnvironmentDefault,
+            env="TWINE_CERT",
+            default=None,
+            required=False,
+            metavar="path",
+            help="Path to alternate CA bundle (can also be set via %(env)s "
+                 "environment variable).",
+        )
+        parser.add_argument(
+            "--client-cert",
+            metavar="path",
+            help="Path to SSL client certificate, a single file containing the"
+                 " private key and the certificate in PEM format.",
+        )
+        parser.add_argument(
+            "--verbose",
+            action="store_true",
+            help="Show verbose output."
+        )
+
+    @classmethod
+    def from_argparse(cls, args):
+        """Generate the Settings from parsed arguments."""
+        settings = vars(args)
+        settings['repository_name'] = settings.pop('repository')
+        settings['cacert'] = settings.pop('cert')
+        return cls(**settings)
+
+    def _handle_package_signing(self, sign, sign_with, identity):
+        if not sign and identity:
+            raise exceptions.InvalidSigningConfiguration(
+                "sign must be given along with identity"
+            )
+        self.sign = sign
+        self.sign_with = sign_with
+        self.identity = identity
+
+    def _handle_repository_options(self, repository_name, repository_url):
+        self.repository_config = utils.get_repository_from_config(
+            self.config_file,
+            repository_name,
+            repository_url,
+        )
+        self.repository_config['repository'] = utils.normalize_repository_url(
+            self.repository_config['repository'],
+        )
+
+    def _handle_authentication(self, username, password):
+        self.username = utils.get_username(username, self.repository_config)
+        self.password = utils.get_password(
+            self.repository_config['repository'],
+            self.username,
+            password,
+            self.repository_config,
+        )
+
+    def _handle_certificates(self, cacert, client_cert):
+        self.cacert = utils.get_cacert(cacert, self.repository_config)
+        self.client_cert = utils.get_clientcert(
+            client_cert,
+            self.repository_config,
+        )
+
+    def check_repository_url(self):
+        """Verify we are not using legacy PyPI.
+
+        :raises:
+            :class:`~twine.exceptions.UploadToDeprecatedPyPIDetected`
+        """
+        repository_url = self.repository_config['repository']
+
+        if repository_url.startswith((repository.LEGACY_PYPI,
+                                      repository.LEGACY_TEST_PYPI)):
+            raise exceptions.UploadToDeprecatedPyPIDetected.from_args(
+                repository_url,
+                utils.DEFAULT_REPOSITORY,
+                utils.TEST_REPOSITORY
+            )
+
+    def create_repository(self):
+        """Create a new repository for uploading."""
+        repo = repository.Repository(
+            self.repository_config['repository'],
+            self.username,
+            self.password,
+        )
+        repo.set_certificate_authority(self.cacert)
+        repo.set_client_certificate(self.client_cert)
+        return repo
diff --git a/twine/utils.py b/twine/utils.py
index 4cd1a18..d83e080 100644
--- a/twine/utils.py
+++ b/twine/utils.py
@@ -283,3 +283,31 @@ def get_password(system, username, cli_value, config):
             username,
         ),
     )
+
+
+def no_positional(allow_self=False):
+    """A decorator that doesn't allow for positional arguments.
+
+    :param bool allow_self:
+        Whether to allow ``self`` as a positional argument.
+    """
+    def reject_positional_args(function):
+        @functools.wraps(function)
+        def wrapper(*args, **kwargs):
+            allowed_positional_args = 0
+            if allow_self:
+                allowed_positional_args = 1
+            received_positional_args = len(args)
+            if received_positional_args > allowed_positional_args:
+                function_name = function.__name__
+                verb = 'were' if received_positional_args > 1 else 'was'
+                raise TypeError(('{}() takes {} positional arguments but {} '
+                                 '{} given').format(
+                                     function_name,
+                                     allowed_positional_args,
+                                     received_positional_args,
+                                     verb,
+                                ))
+            return function(*args, **kwargs)
+        return wrapper
+    return reject_positional_args


Test patch:
diff --git a/tests/test_settings.py b/tests/test_settings.py
new file mode 100644
index 0000000..ae9b6db
--- /dev/null
+++ b/tests/test_settings.py
@@ -0,0 +1,57 @@
+"""Tests for the Settings class and module."""
+# Copyright 2018 Ian Stapleton Cordasco
+#
+# 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 os.path
+import textwrap
+
+from twine import exceptions
+from twine import settings
+
+import pytest
+
+
+def test_settings_takes_no_positional_arguments():
+    """Verify that the Settings initialization is kw-only."""
+    with pytest.raises(TypeError):
+        settings.Settings('a', 'b', 'c')
+
+
+def test_settings_transforms_config(tmpdir):
+    """Verify that the settings object transforms the passed in options."""
+    pypirc = os.path.join(str(tmpdir), ".pypirc")
+
+    with open(pypirc, "w") as fp:
+        fp.write(textwrap.dedent("""
+            [pypi]
+            repository: https://upload.pypi.org/legacy/
+            username:username
+            password:password
+        """))
+    s = settings.Settings(config_file=pypirc)
+    assert (s.repository_config['repository'] ==
+            'https://upload.pypi.org/legacy/')
+    assert s.sign is False
+    assert s.sign_with == 'gpg'
+    assert s.identity is None
+    assert s.username == 'username'
+    assert s.password == 'password'
+    assert s.cacert is None
+    assert s.client_cert is None
+
+
+def test_identity_requires_sign():
+    """Verify that if a user passes identity, we require sign=True."""
+    with pytest.raises(exceptions.InvalidSigningConfiguration):
+        settings.Settings(sign=False, identity='fakeid')
diff --git a/tests/test_upload.py b/tests/test_upload.py
index e58ffd4..0e10157 100644
--- a/tests/test_upload.py
+++ b/tests/test_upload.py
@@ -20,7 +20,7 @@ import pretend
 import pytest
 
 from twine.commands import upload
-from twine import package, cli, exceptions
+from twine import package, cli, exceptions, settings
 import twine
 
 import helpers
@@ -69,7 +69,6 @@ def test_find_dists_handles_real_files():
 
 def test_get_config_old_format(tmpdir):
     pypirc = os.path.join(str(tmpdir), ".pypirc")
-    dists = ["tests/fixtures/twine-1.5.0-py2.py3-none-any.whl"]
 
     with open(pypirc, "w") as fp:
         fp.write(textwrap.dedent("""
@@ -79,12 +78,12 @@ def test_get_config_old_format(tmpdir):
         """))
 
     try:
-        upload.upload(dists=dists, repository="pypi", sign=None, identity=None,
-                      username=None, password=None, comment=None,
-                      cert=None, client_cert=None,
-                      sign_with=None, config_file=pypirc, skip_existing=False,
-                      repository_url=None, verbose=None,
-                      )
+        settings.Settings(
+            repository="pypi", sign=None, identity=None, username=None,
+            password=None, comment=None, cert=None, client_cert=None,
+            sign_with=None, config_file=pypirc, skip_existing=False,
+            repository_url=None, verbose=None,
+        )
     except KeyError as err:
         assert err.args[0] == (
             "Missing 'pypi' section from the configuration file\n"
@@ -108,12 +107,14 @@ def test_deprecated_repo(tmpdir):
                 password:bar
             """))
 
-        upload.upload(dists=dists, repository="pypi", sign=None, identity=None,
-                      username=None, password=None, comment=None,
-                      cert=None, client_cert=None,
-                      sign_with=None, config_file=pypirc, skip_existing=False,
-                      repository_url=None, verbose=None,
-                      )
+        upload_settings = settings.Settings(
+            repository="pypi", sign=None, identity=None, username=None,
+            password=None, comment=None, cert=None, client_cert=None,
+            sign_with=None, config_file=pypirc, skip_existing=False,
+            repository_url=None, verbose=None,
+        )
+
+        upload.upload(upload_settings, dists)
 
         assert err.args[0] == (
             "You're trying to upload to the legacy PyPI site "
@@ -178,12 +179,7 @@ def test_values_from_env(monkeypatch):
                "TWINE_CERT": "/foo/bar.crt"}
     with helpers.set_env(**testenv):
         cli.dispatch(["upload", "path/to/file"])
-    cli.dispatch(["upload", "path/to/file"])
-    result_kwargs = replaced_upload.calls[0].kwargs
-    assert "pypipassword" == result_kwargs["password"]
-    assert "pypiuser" == result_kwargs["username"]
-    assert "/foo/bar.crt" == result_kwargs["cert"]
-    result_kwargs = replaced_upload.calls[1].kwargs
-    assert None is result_kwargs["password"]
-    assert None is result_kwargs["username"]
-    assert None is result_kwargs["cert"]
+    upload_settings = replaced_upload.calls[0].args[0]
+    assert "pypipassword" == upload_settings.password
+    assert "pypiuser" == upload_settings.username
+    assert "/foo/bar.crt" == upload_settings.cacert
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 7338ccb..4cd45a6 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -256,3 +256,27 @@ def test_get_password_runtime_error_suppressed(
     assert len(recwarn) == 1
     warning = recwarn.pop(UserWarning)
     assert 'fail!' in str(warning)
+
+
+def test_no_positional_on_method():
+    class T(object):
+        @utils.no_positional(allow_self=True)
+        def __init__(self, foo=False):
+            self.foo = foo
+
+    with pytest.raises(TypeError):
+        T(1)
+
+    t = T(foo=True)
+    assert t.foo
+
+
+def test_no_positional_on_function():
+    @utils.no_positional()
+    def t(foo=False):
+        return foo
+
+    with pytest.raises(TypeError):
+        t(1)
+
+    assert t(foo=True)