Repository: pypa/twine

Issue:
Make twine work without a config file
I wanted to add twine to a CI script but it wasn't trivial to find the pypirc file, so I had to regenerate via echo "..." calls (windows). 

It would be nice of twine could work with https://testpypi.python.org/pypi without first adding a config file .

Discussion:

Patch:
diff --git a/docs/changelog.rst b/docs/changelog.rst
index fde2ef9..a4dafdd 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -16,6 +16,10 @@ Changelog
 
     - Password will default to ``TWINE_PASSWORD``
 
+  * :feature:`166` Allow the Repository URL to be provided on the command-line
+    (``--repository-url``) or via an environment variable
+    (``TWINE_REPOSITORY_URL``).
+
 * :release:`1.7.4 <2016-07-09>`
 
   * Correct a packaging error.
diff --git a/twine/commands/register.py b/twine/commands/register.py
index ea6732e..29744d1 100644
--- a/twine/commands/register.py
+++ b/twine/commands/register.py
@@ -24,8 +24,12 @@ from twine import utils
 
 
 def register(package, repository, username, password, comment, config_file,
-             cert, client_cert):
-    config = utils.get_repository_from_config(config_file, repository)
+             cert, client_cert, repository_url):
+    config = utils.get_repository_from_config(
+        config_file,
+        repository,
+        repository_url,
+    )
     config["repository"] = utils.normalize_repository_url(
         config["repository"]
     )
@@ -63,15 +67,26 @@ def main(args):
     parser.add_argument(
         "-r", "--repository",
         action=utils.EnvironmentDefault,
-        env='TWINE_REPOSITORY',
+        env="TWINE_REPOSITORY",
         default="pypi",
-        help="The repository to register the package to (default: "
+        help="The repository to register the package to. Can be a section in "
+             "the config file or a full URL to the repository (default: "
              "%(default)s)",
     )
+    parser.add_argument(
+        "--repository-url",
+        action=utils.EnvironmentDefault,
+        env="TWINE_REPOSITORY_URL",
+        default=None,
+        required=False,
+        help="The repository URL to upload the package to. This can be "
+             "specified with --repository because it will be used if there is "
+             "no configuration for the value passed to --repository."
+    )
     parser.add_argument(
         "-u", "--username",
         action=utils.EnvironmentDefault,
-        env='TWINE_USERNAME',
+        env="TWINE_USERNAME",
         required=False, help="The username to authenticate to the repository "
                              "as (can also be set via %(env)s environment "
                              "variable)",
@@ -79,7 +94,7 @@ def main(args):
     parser.add_argument(
         "-p", "--password",
         action=utils.EnvironmentDefault,
-        env='TWINE_PASSWORD',
+        env="TWINE_PASSWORD",
         required=False, help="The password to authenticate to the repository "
                              "with (can also be set via %(env)s environment "
                              "variable)",
diff --git a/twine/commands/upload.py b/twine/commands/upload.py
index 7f7335e..e78f4af 100644
--- a/twine/commands/upload.py
+++ b/twine/commands/upload.py
@@ -72,7 +72,8 @@ def skip_upload(response, skip_existing, package):
 
 
 def upload(dists, repository, sign, identity, username, password, comment,
-           sign_with, config_file, skip_existing, cert, client_cert):
+           sign_with, config_file, skip_existing, cert, client_cert,
+           repository_url):
     # Check that a nonsensical option wasn't given
     if not sign and identity:
         raise ValueError("sign must be given along with identity")
@@ -85,7 +86,11 @@ def upload(dists, repository, sign, identity, username, password, comment,
     )
     uploads = [i for i in dists if not i.endswith(".asc")]
 
-    config = utils.get_repository_from_config(config_file, repository)
+    config = utils.get_repository_from_config(
+        config_file,
+        repository,
+        repository_url,
+    )
 
     config["repository"] = utils.normalize_repository_url(
         config["repository"]
@@ -152,7 +157,19 @@ def main(args):
         action=utils.EnvironmentDefault,
         env="TWINE_REPOSITORY",
         default="pypi",
-        help="The repository to upload the files to (default: %(default)s)",
+        help="The repository to register the package to. Can be a section in "
+             "the config file or a full URL to the repository (default: "
+             "%(default)s)",
+    )
+    parser.add_argument(
+        "--repository-url",
+        action=utils.EnvironmentDefault,
+        env="TWINE_REPOSITORY_URL",
+        default=None,
+        required=False,
+        help="The repository URL to upload the package to. This can be "
+             "specified with --repository because it will be used if there is "
+             "no configuration for the value passed to --repository."
     )
     parser.add_argument(
         "-s", "--sign",
diff --git a/twine/utils.py b/twine/utils.py
index 3e21fde..9b2e8d6 100644
--- a/twine/utils.py
+++ b/twine/utils.py
@@ -40,6 +40,7 @@ else:
 
 
 DEFAULT_REPOSITORY = "https://upload.pypi.org/legacy/"
+TEST_REPOSITORY = "https://test.pypi.org/legacy/"
 
 
 def get_config(path="~/.pypirc"):
@@ -50,7 +51,11 @@ def get_config(path="~/.pypirc"):
         return {"pypi": {"repository": DEFAULT_REPOSITORY,
                          "username": None,
                          "password": None
-                         }
+                         },
+                "pypitest": {"repository": TEST_REPOSITORY,
+                             "username": None,
+                             "password": None
+                             },
                 }
 
     # Parse the rc file
@@ -94,13 +99,22 @@ def get_config(path="~/.pypirc"):
     return config
 
 
-def get_repository_from_config(config_file, repository):
+def get_repository_from_config(config_file, repository, repository_url=None):
     # Get our config from the .pypirc file
     try:
         return get_config(config_file)[repository]
     except KeyError:
+        if repository_url and "://" in repository_url:
+            # assume that the repsoitory is actually an URL and just sent
+            # them a dummy with the repo set
+            return {
+                "repository": repository_url,
+                "username": None,
+                "password": None,
+            }
         msg = (
-            "Missing '{repo}' section from the configuration file.\n"
+            "Missing '{repo}' section from the configuration file\n"
+            "or not a complete URL in --repository.\n"
             "Maybe you have a out-dated '{cfg}' format?\n"
             "more info: "
             "https://docs.python.org/distutils/packageindex.html#pypirc\n"


Test patch:
diff --git a/tests/test_upload.py b/tests/test_upload.py
index 2649fe4..e134f82 100644
--- a/tests/test_upload.py
+++ b/tests/test_upload.py
@@ -82,10 +82,13 @@ def test_get_config_old_format(tmpdir):
         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)
+                      sign_with=None, config_file=pypirc, skip_existing=False,
+                      repository_url=None,
+                      )
     except KeyError as err:
         assert err.args[0] == (
-            "Missing 'pypi' section from the configuration file.\n"
+            "Missing 'pypi' section from the configuration file\n"
+            "or not a complete URL in --repository.\n"
             "Maybe you have a out-dated '{0}' format?\n"
             "more info: "
             "https://docs.python.org/distutils/packageindex.html#pypirc\n"
@@ -130,7 +133,9 @@ def test_skip_upload_respects_skip_existing(monkeypatch):
 
 
 def test_password_and_username_from_env(monkeypatch):
-    def none_upload(*args, **kwargs): pass
+    def none_upload(*args, **kwargs):
+        pass
+
     replaced_upload = pretend.call_recorder(none_upload)
     monkeypatch.setattr(twine.commands.upload, "upload", replaced_upload)
     testenv = {"TWINE_USERNAME": "pypiuser",
diff --git a/tests/test_utils.py b/tests/test_utils.py
index cb763b7..4c4d116 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -19,7 +19,6 @@ import textwrap
 
 import pytest
 
-from twine.utils import DEFAULT_REPOSITORY, get_config, get_userpass_value
 from twine import utils
 
 import helpers
@@ -38,9 +37,9 @@ def test_get_config(tmpdir):
             password = testpassword
         """))
 
-    assert get_config(pypirc) == {
+    assert utils.get_config(pypirc) == {
         "pypi": {
-            "repository": DEFAULT_REPOSITORY,
+            "repository": utils.DEFAULT_REPOSITORY,
             "username": "testuser",
             "password": "testpassword",
         },
@@ -57,9 +56,9 @@ def test_get_config_no_distutils(tmpdir):
             password = testpassword
         """))
 
-    assert get_config(pypirc) == {
+    assert utils.get_config(pypirc) == {
         "pypi": {
-            "repository": DEFAULT_REPOSITORY,
+            "repository": utils.DEFAULT_REPOSITORY,
             "username": "testuser",
             "password": "testpassword",
         },
@@ -79,9 +78,9 @@ def test_get_config_no_section(tmpdir):
             password = testpassword
         """))
 
-    assert get_config(pypirc) == {
+    assert utils.get_config(pypirc) == {
         "pypi": {
-            "repository": DEFAULT_REPOSITORY,
+            "repository": utils.DEFAULT_REPOSITORY,
             "username": "testuser",
             "password": "testpassword",
         },
@@ -91,23 +90,47 @@ def test_get_config_no_section(tmpdir):
 def test_get_config_missing(tmpdir):
     pypirc = os.path.join(str(tmpdir), ".pypirc")
 
-    assert get_config(pypirc) == {
+    assert utils.get_config(pypirc) == {
         "pypi": {
-            "repository": DEFAULT_REPOSITORY,
+            "repository": utils.DEFAULT_REPOSITORY,
             "username": None,
             "password": None,
         },
+        "pypitest": {
+            "repository": utils.TEST_REPOSITORY,
+            "username": None,
+            "password": None
+        },
     }
 
 
+def test_get_repository_config_missing(tmpdir):
+    pypirc = os.path.join(str(tmpdir), ".pypirc")
+
+    repository_url = "https://notexisting.python.org/pypi"
+    exp = {
+        "repository": repository_url,
+        "username": None,
+        "password": None,
+    }
+    assert (utils.get_repository_from_config(pypirc, 'foo', repository_url) ==
+            exp)
+    exp = {
+            "repository": utils.DEFAULT_REPOSITORY,
+            "username": None,
+            "password": None,
+        }
+    assert utils.get_repository_from_config(pypirc, "pypi") == exp
+
+
 def test_get_config_deprecated_pypirc():
     tests_dir = os.path.dirname(os.path.abspath(__file__))
     deprecated_pypirc_path = os.path.join(tests_dir, 'fixtures',
                                           'deprecated-pypirc')
 
-    assert get_config(deprecated_pypirc_path) == {
+    assert utils.get_config(deprecated_pypirc_path) == {
         "pypi": {
-            "repository": DEFAULT_REPOSITORY,
+            "repository": utils.DEFAULT_REPOSITORY,
             "username": 'testusername',
             "password": 'testpassword',
         },
@@ -123,7 +146,7 @@ def test_get_config_deprecated_pypirc():
     ),
 )
 def test_get_userpass_value(cli_value, config, key, strategy, expected):
-    ret = get_userpass_value(cli_value, config, key, strategy)
+    ret = utils.get_userpass_value(cli_value, config, key, strategy)
     assert ret == expected