codecov[bot]: # [Codecov](https://codecov.io/gh/pypa/twine/pull/369?src=pr&el=h1) Report
> Merging [#369](https://codecov.io/gh/pypa/twine/pull/369?src=pr&el=desc) into [master](https://codecov.io/gh/pypa/twine/commit/34c08ef97d05d219ae018f041cd37e1d409b7a4d?src=pr&el=desc) will **decrease** coverage by `0.45%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/pypa/twine/pull/369/graphs/tree.svg?width=650&token=NrvN4iFbj6&height=150&src=pr)](https://codecov.io/gh/pypa/twine/pull/369?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##           master    #369      +/-   ##
=========================================
- Coverage   74.55%   74.1%   -0.46%     
=========================================
  Files          13      13              
  Lines         672     668       -4     
  Branches      101     100       -1     
=========================================
- Hits          501     495       -6     
- Misses        143     145       +2     
  Partials       28      28
```


| [Impacted Files](https://codecov.io/gh/pypa/twine/pull/369?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [twine/utils.py](https://codecov.io/gh/pypa/twine/pull/369/diff?src=pr&el=tree#diff-dHdpbmUvdXRpbHMucHk=) | `82.25% <100%> (-2.12%)` | :arrow_down: |
| [twine/wininst.py](https://codecov.io/gh/pypa/twine/pull/369/diff?src=pr&el=tree#diff-dHdpbmUvd2luaW5zdC5weQ==) | `29.72% <0%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pypa/twine/pull/369?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pypa/twine/pull/369?src=pr&el=footer). Last update [34c08ef...6e1a1ea](https://codecov.io/gh/pypa/twine/pull/369?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

anlutro: I wonder if I could remove this line and just always configure `testpypi`, regardless of `index-servers`? I left it in to stay consistent with old behaviour. https://github.com/pypa/twine/pull/369/files#diff-547bab308763f89cacec226151fcbb80R83
anlutro: docs failed in Travis - unrelated, I guess?
theacodes: Yes, unrelated and up to me to fix. Can you get lint passing?
anlutro: Amended, lint should pass now.
diff --git a/twine/utils.py b/twine/utils.py
index d83e080..4feca1b 100644
--- a/twine/utils.py
+++ b/twine/utils.py
@@ -21,6 +21,7 @@ import getpass
 import sys
 import argparse
 import warnings
+import collections
 
 from requests.exceptions import HTTPError
 
@@ -48,68 +49,52 @@ TEST_REPOSITORY = "https://test.pypi.org/legacy/"
 
 
 def get_config(path="~/.pypirc"):
+    # even if the config file does not exist, set up the parser
+    # variable to reduce the number of if/else statements
+    parser = configparser.RawConfigParser()
+
+    # this list will only be used if index-servers
+    # is not defined in the config file
+    index_servers = ["pypi", "testpypi"]
+
+    # default configuration for each repository
+    defaults = {"username": None, "password": None}
+
     # Expand user strings in the path
     path = os.path.expanduser(path)
 
-    if not os.path.isfile(path):
-        return {"pypi": {"repository": DEFAULT_REPOSITORY,
-                         "username": None,
-                         "password": None
-                         },
-                "pypitest": {"repository": TEST_REPOSITORY,
-                             "username": None,
-                             "password": None
-                             },
-                }
-
     # Parse the rc file
-    parser = configparser.RawConfigParser()
-    parser.read(path)
-
-    # Get a list of repositories from the config file
-    # format: https://docs.python.org/3/distutils/packageindex.html#pypirc
-    if (parser.has_section("distutils") and
-            parser.has_option("distutils", "index-servers")):
-        repositories = parser.get("distutils", "index-servers").split()
-    elif parser.has_section("pypi"):
-        # Special case: if the .pypirc file has a 'pypi' section,
-        # even if there's no list of index servers,
-        # be lenient and include that in our list of repositories.
-        repositories = ['pypi']
-    else:
-        repositories = []
+    if os.path.isfile(path):
+        parser.read(path)
 
-    config = {}
+        # Get a list of index_servers from the config file
+        # format: https://docs.python.org/3/distutils/packageindex.html#pypirc
+        if parser.has_option("distutils", "index-servers"):
+            index_servers = parser.get("distutils", "index-servers").split()
 
-    defaults = {"username": None, "password": None}
-    if parser.has_section("server-login"):
         for key in ["username", "password"]:
             if parser.has_option("server-login", key):
                 defaults[key] = parser.get("server-login", key)
 
-    for repository in repositories:
-        # Skip this repository if it doesn't exist in the config file
-        if not parser.has_section(repository):
-            continue
+    config = collections.defaultdict(lambda: defaults.copy())
 
-        # Mandatory configuration and defaults
-        config[repository] = {
-            "repository": DEFAULT_REPOSITORY,
-            "username": None,
-            "password": None,
-        }
+    # don't require users to manually configure URLs for these repositories
+    config["pypi"]["repository"] = DEFAULT_REPOSITORY
+    if "testpypi" in index_servers:
+        config["testpypi"]["repository"] = TEST_REPOSITORY
 
-        # Optional configuration values
+    # optional configuration values for individual repositories
+    for repository in index_servers:
         for key in [
             "username", "repository", "password",
             "ca_cert", "client_cert",
         ]:
             if parser.has_option(repository, key):
                 config[repository][key] = parser.get(repository, key)
-            elif defaults.get(key):
-                config[repository][key] = defaults[key]
 
-    return config
+    # convert the defaultdict to a regular dict at this point
+    # to prevent surprising behavior later on
+    return dict(config)
 
 
 def get_repository_from_config(config_file, repository, repository_url=None):

Twine should have a built-in alias for testpypi
Instead of needing to specify the full upload URL for Test PyPI we should always have an alias ready, for example:

```
twine upload --repository=testpypi dist/*
```

Should work even without a `-/.pypirc`. If `testpypi` is defined in `~/.pypic`, it should take precedence.
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 4cd45a6..4d60e04 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -72,6 +72,11 @@ def test_get_config_no_distutils(tmpdir):
             "username": "testuser",
             "password": "testpassword",
         },
+        "testpypi": {
+            "repository": utils.TEST_REPOSITORY,
+            "username": None,
+            "password": None,
+        },
     }
 
 
@@ -97,6 +102,18 @@ def test_get_config_no_section(tmpdir):
     }
 
 
+def test_get_config_override_pypi_url(tmpdir):
+    pypirc = os.path.join(str(tmpdir), ".pypirc")
+
+    with open(pypirc, "w") as fp:
+        fp.write(textwrap.dedent("""
+            [pypi]
+            repository = http://pypiproxy
+        """))
+
+    assert utils.get_config(pypirc)['pypi']['repository'] == 'http://pypiproxy'
+
+
 def test_get_config_missing(tmpdir):
     pypirc = os.path.join(str(tmpdir), ".pypirc")
 
@@ -106,7 +123,7 @@ def test_get_config_missing(tmpdir):
             "username": None,
             "password": None,
         },
-        "pypitest": {
+        "testpypi": {
             "repository": utils.TEST_REPOSITORY,
             "username": None,
             "password": None
@@ -143,8 +160,13 @@ def test_get_config_deprecated_pypirc():
     assert utils.get_config(deprecated_pypirc_path) == {
         "pypi": {
             "repository": utils.DEFAULT_REPOSITORY,
-            "username": 'testusername',
-            "password": 'testpassword',
+            "username": "testusername",
+            "password": "testpassword",
+        },
+        "testpypi": {
+            "repository": utils.TEST_REPOSITORY,
+            "username": "testusername",
+            "password": "testpassword",
         },
     }
 

pip install -e .[keyring]
parse_log_pytest
requirements.txt
pytest
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
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
py==1.11.0
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@34c08ef97d05d219ae018f041cd37e1d409b7a4d#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
      - 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
      - py==1.11.0
      - 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_utils.py::test_get_config_no_distutils tests/test_utils.py::test_get_config_missing tests/test_utils.py::test_get_config_deprecated_pypirc
tests/test_utils.py::test_get_config 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_repository_config_missing 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
twine/utils.py
twine/utils.py