diff --git a/docs/history.md b/docs/history.md index cb5382fab..d1ad35c79 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 5f5213e5f..a4021da46 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 56b3cd7e4..959b0eaf8 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 886b3ad8d..08a48747f 100644 --- a/src/hatch/config/model.py +++ b/src/hatch/config/model.py @@ -181,7 +181,7 @@ def publish(self): 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 d4953f209..fc77d41de 100644 --- a/src/hatch/publish/index.py +++ b/src/hatch/publish/index.py @@ -11,12 +11,33 @@ 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 @@ def publish(self, artifacts: list, options: dict): 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 @@ def publish(self, artifacts: list, options: dict): 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 @@ def publish(self, artifacts: list, options: dict): if 'auth' in options: auth = options['auth'] else: - auth = self.plugin_config.get('auth', '') + auth = repo_config.get('auth', '') if not auth: import keyring diff --git a/tests/cli/config/test_show.py b/tests/cli/config/test_show.py index a5edef0d4..abf28ef1a 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 c49a0d404..a16dcffdf 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 778724de6..c0d3ea2f7 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': ''}}})