Repository: pypa/hatch

Issue:
How to make `hatch publish` use my pypi API token?
Having read Hatch's Publish documentation I did not figure out how to make it use my PyPI API token. For `test.pypi.org` and for `upload.pypi.org` I have these tokens defined in my `~/.pypirc` file but it seems that hatch is ignoring this:

```
hatch publish -r test
Enter your username:
```

I tried to carry the tokens over from `.pypirc` to my hatch's `config.toml`, like this:

```
[publish.index]
user = ""
auth = ""

[publish.index.repos.test]
user = "__token__"
auth = "pypi-<SECRET_PART>"

[publish.index.repos.main]
user = "__token__"
auth = "pypi-<SECRET_PART>"
```

but this has no effect. I'm still getting the interactive prompt for username and credentials. What am I missing?

Discussion:

Patch:
diff --git a/docs/history.md b/docs/history.md
index cb5382fa..d1ad35c7 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 ### Unreleased
 
+***Added:***
+
+- The `index` publisher now recognizes repository-specific options
+
 ***Fixed:***
 
 - Handle more edge cases in the `setuptools` migration script
diff --git a/docs/plugins/publisher/package-index.md b/docs/plugins/publisher/package-index.md
index 5f5213e5..a4021da4 100644
--- a/docs/plugins/publisher/package-index.md
+++ b/docs/plugins/publisher/package-index.md
@@ -24,4 +24,20 @@ The publisher plugin name is `index`.
 | `--ca-cert` | `ca-cert` | The path to a CA bundle |
 | `--client-cert` | `client-cert` | The path to a client certificate, optionally containing the private key |
 | `--client-key` | `client-key` | The path to the client certificate's private key |
-| | `repos` | A table of named repositories to their respective URLs |
+| | `repos` | A table of named [repositories](#repositories) to their respective options |
+
+## Repositories
+
+All top-level options can be overridden per repository using the `repos` table with a required `url` attribute for each repository. The following shows the default configuration:
+
+=== ":octicons-file-code-16: config.toml"
+
+    ```toml
+    [publish.index.repos.main]
+    url = "https://upload.pypi.org/legacy/"
+
+    [publish.index.repos.test]
+    url = "https://test.pypi.org/legacy/"
+    ```
+
+The `repo` and `repos` options have no effect.
diff --git a/docs/publish.md b/docs/publish.md
index 56b3cd7e..959b0eaf 100644
--- a/docs/publish.md
+++ b/docs/publish.md
@@ -36,8 +36,8 @@ Rather than specifying the full URL of a repository, you can use a named reposit
 === ":octicons-file-code-16: config.toml"
 
     ```toml
-    [publish.index.repos]
-    repo1 = "url1"
+    [publish.index.repos.private]
+    url = "..."
     ...
     ```
 
diff --git a/src/hatch/config/model.py b/src/hatch/config/model.py
index 886b3ad8..08a48747 100644
--- a/src/hatch/config/model.py
+++ b/src/hatch/config/model.py
@@ -181,7 +181,7 @@ class RootConfig(LazilyParsedConfig):
 
                 self._field_publish = publish
             else:
-                self._field_publish = self.raw_data['publish'] = {'index': {'user': '', 'auth': ''}}
+                self._field_publish = self.raw_data['publish'] = {'index': {'repo': 'main'}}
 
         return self._field_publish
 
diff --git a/src/hatch/publish/index.py b/src/hatch/publish/index.py
index d4953f20..fc77d41d 100644
--- a/src/hatch/publish/index.py
+++ b/src/hatch/publish/index.py
@@ -11,12 +11,33 @@ from hatchling.metadata.utils import normalize_project_name
 class IndexPublisher(PublisherInterface):
     PLUGIN_NAME = 'index'
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        self.repos = self.plugin_config.get('repos', {}).copy()
-        self.repos['main'] = 'https://upload.pypi.org/legacy/'
-        self.repos['test'] = 'https://test.pypi.org/legacy/'
+    def get_repos(self):
+        global_plugin_config = self.plugin_config.copy()
+        defined_repos = self.plugin_config.pop('repos', {})
+        self.plugin_config.pop('repo', None)
+
+        # Normalize type
+        repos = {}
+        for repo, data in defined_repos.items():
+            if isinstance(data, str):
+                data = {'url': data}
+            elif not isinstance(data, dict):
+                self.app.abort(f'Hatch config field `publish.index.repos.{repo}` must be a string or a mapping')
+            elif 'url' not in data:
+                self.app.abort(f'Hatch config field `publish.index.repos.{repo}` must define a `url` key')
+
+            repos[repo] = data
+
+        # Ensure PyPI correct
+        for repo, url in (('main', 'https://upload.pypi.org/legacy/'), ('test', 'https://test.pypi.org/legacy/')):
+            repos.setdefault(repo, {})['url'] = url
+
+        # Populate defaults
+        for config in repos.values():
+            for key, value in global_plugin_config.items():
+                config.setdefault(key, value)
+
+        return repos
 
     def publish(self, artifacts: list, options: dict):
         """
@@ -37,14 +58,18 @@ class IndexPublisher(PublisherInterface):
         else:
             repo = self.plugin_config.get('repo', 'main')
 
-        if repo in self.repos:
-            repo = self.repos[repo]
+        repos = self.get_repos()
+
+        if repo in repos:
+            repo_config = repos[repo]
+        else:
+            repo_config = {'url': repo}
 
         index = PackageIndex(
-            repo,
-            ca_cert=options.get('ca_cert', self.plugin_config.get('ca-cert')),
-            client_cert=options.get('client_cert', self.plugin_config.get('client-cert')),
-            client_key=options.get('client_key', self.plugin_config.get('client-key')),
+            repo_config['url'],
+            ca_cert=options.get('ca_cert', repo_config.get('ca-cert')),
+            client_cert=options.get('client_cert', repo_config.get('client-cert')),
+            client_key=options.get('client_key', repo_config.get('client-key')),
         )
 
         cached_user_file = CachedUserFile(self.cache_dir)
@@ -52,7 +77,7 @@ class IndexPublisher(PublisherInterface):
         if 'user' in options:
             user = options['user']
         else:
-            user = self.plugin_config.get('user', '')
+            user = repo_config.get('user', '')
             if not user:
                 user = cached_user_file.get_user(repo)
                 if user is None:
@@ -66,7 +91,7 @@ class IndexPublisher(PublisherInterface):
         if 'auth' in options:
             auth = options['auth']
         else:
-            auth = self.plugin_config.get('auth', '')
+            auth = repo_config.get('auth', '')
             if not auth:
                 import keyring
 


Test patch:
diff --git a/tests/cli/config/test_show.py b/tests/cli/config/test_show.py
index a5edef0d..abf28ef1 100644
--- a/tests/cli/config/test_show.py
+++ b/tests/cli/config/test_show.py
@@ -80,7 +80,7 @@ def test_reveal(hatch, config_file, helpers, default_cache_dir, default_data_dir
         [projects]
 
         [publish.index]
-        user = ""
+        repo = "main"
         auth = "bar"
 
         [template]
diff --git a/tests/cli/publish/test_publish.py b/tests/cli/publish/test_publish.py
index c49a0d40..a16dcffd 100644
--- a/tests/cli/publish/test_publish.py
+++ b/tests/cli/publish/test_publish.py
@@ -119,6 +119,44 @@ def test_disabled(hatch, temp_dir, config_file):
     assert result.output == 'Publisher is disabled: index\n'
 
 
+def test_repo_invalid_type(hatch, temp_dir, config_file):
+    config_file.model.publish['index']['repos'] = {'dev': 9000}
+    config_file.save()
+
+    project_name = 'My.App'
+
+    with temp_dir.as_cwd():
+        result = hatch('new', project_name)
+        assert result.exit_code == 0, result.output
+
+    path = temp_dir / 'my-app'
+
+    with path.as_cwd():
+        result = hatch('publish', '--user', 'foo', '--auth', 'bar')
+
+    assert result.exit_code == 1, result.output
+    assert result.output == 'Hatch config field `publish.index.repos.dev` must be a string or a mapping\n'
+
+
+def test_repo_missing_url(hatch, temp_dir, config_file):
+    config_file.model.publish['index']['repos'] = {'dev': {}}
+    config_file.save()
+
+    project_name = 'My.App'
+
+    with temp_dir.as_cwd():
+        result = hatch('new', project_name)
+        assert result.exit_code == 0, result.output
+
+    path = temp_dir / 'my-app'
+
+    with path.as_cwd():
+        result = hatch('publish', '--user', 'foo', '--auth', 'bar')
+
+    assert result.exit_code == 1, result.output
+    assert result.output == 'Hatch config field `publish.index.repos.dev` must define a `url` key\n'
+
+
 def test_missing_user(hatch, temp_dir):
     project_name = 'My.App'
 
@@ -226,6 +264,49 @@ def test_plugin_config(hatch, devpi, temp_dir_cache, helpers, published_project_
     )
 
 
+def test_plugin_config_repo_override(hatch, devpi, temp_dir_cache, helpers, published_project_name, config_file):
+    config_file.model.publish['index']['user'] = 'foo'
+    config_file.model.publish['index']['auth'] = 'bar'
+    config_file.model.publish['index']['ca-cert'] = 'cert'
+    config_file.model.publish['index']['repo'] = 'dev'
+    config_file.model.publish['index']['repos'] = {
+        'dev': {'url': devpi.repo, 'user': devpi.user, 'auth': devpi.auth, 'ca-cert': devpi.ca_cert},
+    }
+    config_file.save()
+
+    with temp_dir_cache.as_cwd():
+        result = hatch('new', published_project_name)
+        assert result.exit_code == 0, result.output
+
+    path = temp_dir_cache / published_project_name
+
+    with path.as_cwd():
+        del os.environ[PublishEnvVars.REPO]
+
+        current_version = timestamp_to_version(helpers.get_current_timestamp())
+        result = hatch('version', current_version)
+        assert result.exit_code == 0, result.output
+
+        result = hatch('build')
+        assert result.exit_code == 0, result.output
+
+        build_directory = path / 'dist'
+        artifacts = list(build_directory.iterdir())
+
+        result = hatch('publish')
+
+    assert result.exit_code == 0, result.output
+    assert result.output == helpers.dedent(
+        f"""
+        {artifacts[0].relative_to(path)} ... success
+        {artifacts[1].relative_to(path)} ... success
+
+        [{published_project_name}]
+        {devpi.repo}{published_project_name}/{current_version}/
+        """
+    )
+
+
 def test_prompt(hatch, devpi, temp_dir_cache, helpers, published_project_name, config_file):
     config_file.model.publish['index']['ca-cert'] = devpi.ca_cert
     config_file.model.publish['index']['repo'] = 'dev'
diff --git a/tests/config/test_model.py b/tests/config/test_model.py
index 778724de..c0d3ea2f 100644
--- a/tests/config/test_model.py
+++ b/tests/config/test_model.py
@@ -21,7 +21,7 @@ def test_default(default_cache_dir, default_data_dir):
             'cache': str(default_cache_dir),
         },
         'projects': {},
-        'publish': {'index': {'user': '', 'auth': ''}},
+        'publish': {'index': {'repo': 'main'}},
         'template': {
             'name': 'Foo Bar',
             'email': 'foo@bar.baz',
@@ -722,8 +722,8 @@ class TestPublish:
     def test_default(self):
         config = RootConfig({})
 
-        assert config.publish == config.publish == {'index': {'user': '', 'auth': ''}}
-        assert config.raw_data == {'publish': {'index': {'user': '', 'auth': ''}}}
+        assert config.publish == config.publish == {'index': {'repo': 'main'}}
+        assert config.raw_data == {'publish': {'index': {'repo': 'main'}}}
 
     def test_defined(self):
         config = RootConfig({'publish': {'foo': {'username': '', 'password': ''}}})