From 2a6323e2204e439473e23c2435b655441434e365 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Sun, 10 Jul 2022 12:04:07 -0400 Subject: [PATCH] Add a way to require confirmation for publishing --- docs/history.md | 1 + docs/plugins/publisher/reference.md | 1 + docs/publish.md | 27 ++++++- src/hatch/cli/application.py | 1 + src/hatch/cli/publish/__init__.py | 8 +- src/hatch/cli/terminal.py | 4 + src/hatch/publish/plugin/interface.py | 26 +++++++ tests/cli/publish/test_publish.py | 100 +++++++++++++++++++++++++ tests/publish/__init__.py | 0 tests/publish/plugin/__init__.py | 0 tests/publish/plugin/test_interface.py | 56 ++++++++++++++ 11 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 tests/publish/__init__.py create mode 100644 tests/publish/plugin/__init__.py create mode 100644 tests/publish/plugin/test_interface.py diff --git a/docs/history.md b/docs/history.md index 513cf80f4..20891b80f 100644 --- a/docs/history.md +++ b/docs/history.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Support the absence of `pyproject.toml` files, as is the case for apps and non-Python projects - Hide scripts that start with an underscore for the `env show` command by default - Ignoring the exit codes of commands by prefixing with hyphens now works with entire named scripts +- Add a way to require confirmation for publishing - Add `--force-continue` flag to the `env run` command - Make tracebacks colorful and less verbose - When shell configuration has not been defined, attempt to use the current shell based on parent processes before resorting to the defaults diff --git a/docs/plugins/publisher/reference.md b/docs/plugins/publisher/reference.md index 1b29b3363..ae2ce280d 100644 --- a/docs/plugins/publisher/reference.md +++ b/docs/plugins/publisher/reference.md @@ -11,4 +11,5 @@ - cache_dir - project_config - plugin_config + - disable - publish diff --git a/docs/publish.md b/docs/publish.md index c568e8a2b..5ed3569bd 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -52,8 +52,33 @@ The `main` repository is used by default. ## Authentication -The first time you publish to a repository you need to authenticate using the `-u`/`--user` (environment variable `HATCH_PYPI_USER`) and `-a`/`--auth` (environment variable `HATCH_PYPI_AUTH`) options. You will be prompted if either option is not provided. +The first time you publish to a repository you need to authenticate using the `-u`/`--user` (environment variable `HATCH_PYPI_USER`) and `-a`/`--auth` (environment variable `HATCH_PYPI_AUTH`) options. You will be prompted if either option is not provided. The user that most recently published to the chosen repository is [cached](config/hatch.md#cache), with their credentials saved to the system [keyring](https://github.com/jaraco/keyring), so that they will no longer need to provide authentication information. For automated releases, it is recommended that you use per-project [API tokens](https://pypi.org/help/#apitoken). + +## Confirmation + +You can require a confirmation prompt or use of the `-y`/`--yes` flag by setting publishers' `disable` option to `true` in either Hatch's [config file](config/hatch.md) or project-specific configuration (which takes precedence): + +=== ":octicons-file-code-16: config.toml" + + ```toml + [publish.pypi] + disable = true + ``` + +=== ":octicons-file-code-16: pyproject.toml" + + ```toml + [tool.hatch.publish.pypi] + disable = true + ``` + +=== ":octicons-file-code-16: hatch.toml" + + ```toml + [publish.pypi] + disable = true + ``` diff --git a/src/hatch/cli/application.py b/src/hatch/cli/application.py index a54f897e9..1c50d7913 100644 --- a/src/hatch/cli/application.py +++ b/src/hatch/cli/application.py @@ -156,4 +156,5 @@ def __init__(self, app: Application): self.display_mini_header = app.display_mini_header # Divergence from what the backend provides self.prompt = app.prompt + self.confirm = app.confirm self.status_waiting = app.status_waiting diff --git a/src/hatch/cli/publish/__init__.py b/src/hatch/cli/publish/__init__.py index 1a2d5dcdd..0596d07c2 100644 --- a/src/hatch/cli/publish/__init__.py +++ b/src/hatch/cli/publish/__init__.py @@ -20,7 +20,7 @@ envvar=PublishEnvVars.REPO, help='The repository with which to publish artifacts [env var: `HATCH_PYPI_REPO`]', ) -@click.option('--no-prompt', '-n', is_flag=True, help='Do not prompt for missing required fields') +@click.option('--no-prompt', '-n', is_flag=True, help='Disable prompts, such as for missing required fields') @click.option( '--publisher', '-p', @@ -40,8 +40,9 @@ 'times e.g. `-o foo=bar -o baz=23` [env var: `HATCH_PUBLISHER_OPTIONS`]' ), ) +@click.option('--yes', '-y', is_flag=True, help='Confirm without prompting when the plugin is disabled') @click.pass_obj -def publish(app, artifacts, user, auth, repo, no_prompt, publisher_name, options): +def publish(app, artifacts, user, auth, repo, no_prompt, publisher_name, options, yes): """Publish build artifacts.""" option_map = {'no_prompt': no_prompt} if publisher_name == 'pypi': @@ -70,4 +71,7 @@ def publish(app, artifacts, user, auth, repo, no_prompt, publisher_name, options app.project.config.publish.get(publisher_name, {}), app.config.publish.get(publisher_name, {}), ) + if publisher.disable and not (yes or (not no_prompt and app.confirm(f'Confirm `{publisher_name}` publishing'))): + app.abort(f'Publisher is disabled: {publisher_name}') + publisher.publish(list(artifacts), option_map) diff --git a/src/hatch/cli/terminal.py b/src/hatch/cli/terminal.py index 0652a6d3e..445d59421 100644 --- a/src/hatch/cli/terminal.py +++ b/src/hatch/cli/terminal.py @@ -192,6 +192,10 @@ def display_always(self, text='', **kwargs): def prompt(text, **kwargs): return click.prompt(text, **kwargs) + @staticmethod + def confirm(text, **kwargs): + return click.confirm(text, **kwargs) + class MockStatus: def stop(self): diff --git a/src/hatch/publish/plugin/interface.py b/src/hatch/publish/plugin/interface.py index 3134ddf70..b79406e15 100644 --- a/src/hatch/publish/plugin/interface.py +++ b/src/hatch/publish/plugin/interface.py @@ -42,6 +42,8 @@ def __init__(self, app, root, cache_dir, project_config, plugin_config): self.__project_config = project_config self.__plugin_config = plugin_config + self.__disable = None + @property def app(self): """ @@ -93,6 +95,30 @@ def plugin_config(self) -> dict: """ return self.__plugin_config + @property + def disable(self): + """ + Whether this plugin is disabled, thus requiring confirmation when publishing. Local + [project configuration](reference.md#hatch.publish.plugin.interface.PublisherInterface.project_config) + takes precedence over global + [plugin configuration](reference.md#hatch.publish.plugin.interface.PublisherInterface.plugin_config). + """ + if self.__disable is None: + if 'disable' in self.project_config: + disable = self.project_config['disable'] + if not isinstance(disable, bool): + raise TypeError(f'Field `tool.hatch.publish.{self.PLUGIN_NAME}.disable` must be a boolean') + else: + disable = self.plugin_config.get('disable', False) + if not isinstance(disable, bool): + raise TypeError( + f'Global plugin configuration `publish.{self.PLUGIN_NAME}.disable` must be a boolean' + ) + + self.__disable = disable + + return self.__disable + @abstractmethod def publish(self, artifacts: list[str], options: dict): """ diff --git a/tests/cli/publish/test_publish.py b/tests/cli/publish/test_publish.py index 619f2c592..81084f2ed 100644 --- a/tests/cli/publish/test_publish.py +++ b/tests/cli/publish/test_publish.py @@ -130,6 +130,25 @@ def test_unknown_publisher(hatch, temp_dir): assert result.output == 'Unknown publisher: foo\n' +def test_disabled(hatch, temp_dir, config_file): + config_file.model.publish['pypi']['disable'] = True + 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', '-n') + + assert result.exit_code == 1, result.output + assert result.output == 'Publisher is disabled: pypi\n' + + def test_missing_user(hatch, temp_dir): project_name = 'My App' @@ -406,6 +425,87 @@ def test_no_artifacts(hatch, temp_dir_cache, helpers, published_project_name): ) +def test_enable_with_flag(hatch, temp_dir_cache, helpers, published_project_name, config_file): + config_file.model.publish['pypi']['user'] = '__token__' + config_file.model.publish['pypi']['auth'] = PUBLISHER_TOKEN + config_file.model.publish['pypi']['repo'] = 'test' + config_file.model.publish['pypi']['disable'] = True + 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', '-y') + + 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}] + https://test.pypi.org/project/{published_project_name}/{current_version}/ + """ + ) + + +def test_enable_with_prompt(hatch, temp_dir_cache, helpers, published_project_name, config_file): + config_file.model.publish['pypi']['user'] = '__token__' + config_file.model.publish['pypi']['auth'] = PUBLISHER_TOKEN + config_file.model.publish['pypi']['repo'] = 'test' + config_file.model.publish['pypi']['disable'] = True + 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', input='y\n') + + assert result.exit_code == 0, result.output + assert result.output == helpers.dedent( + f""" + Confirm `pypi` publishing [y/N]: y + {artifacts[0].relative_to(path)} ... success + {artifacts[1].relative_to(path)} ... success + + [{published_project_name}] + https://test.pypi.org/project/{published_project_name}/{current_version}/ + """ + ) + + class TestWheel: @pytest.mark.parametrize('field', ['name', 'version']) def test_missing_required_metadata_field(self, hatch, temp_dir_cache, helpers, published_project_name, field): diff --git a/tests/publish/__init__.py b/tests/publish/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/publish/plugin/__init__.py b/tests/publish/plugin/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/publish/plugin/test_interface.py b/tests/publish/plugin/test_interface.py new file mode 100644 index 000000000..c93ed9bdd --- /dev/null +++ b/tests/publish/plugin/test_interface.py @@ -0,0 +1,56 @@ +import pytest + +from hatch.publish.plugin.interface import PublisherInterface + + +class MockPublisher(PublisherInterface): + PLUGIN_NAME = 'mock' + + def publish(self, artifacts, options): + pass + + +class TestDisable: + def test_default(self, isolation): + project_config = {} + plugin_config = {} + publisher = MockPublisher(None, isolation, None, project_config, plugin_config) + + assert publisher.disable is publisher.disable is False + + def test_project_config(self, isolation): + project_config = {'disable': True} + plugin_config = {} + publisher = MockPublisher(None, isolation, None, project_config, plugin_config) + + assert publisher.disable is True + + def test_project_config_not_boolean(self, isolation): + project_config = {'disable': 9000} + plugin_config = {} + publisher = MockPublisher(None, isolation, None, project_config, plugin_config) + + with pytest.raises(TypeError, match='Field `tool.hatch.publish.mock.disable` must be a boolean'): + _ = publisher.disable + + def test_plugin_config(self, isolation): + project_config = {} + plugin_config = {'disable': True} + publisher = MockPublisher(None, isolation, None, project_config, plugin_config) + + assert publisher.disable is True + + def test_plugin_config_not_boolean(self, isolation): + project_config = {} + plugin_config = {'disable': 9000} + publisher = MockPublisher(None, isolation, None, project_config, plugin_config) + + with pytest.raises(TypeError, match='Global plugin configuration `publish.mock.disable` must be a boolean'): + _ = publisher.disable + + def test_project_config_overrides_plugin_config(self, isolation): + project_config = {'disable': False} + plugin_config = {'disable': True} + publisher = MockPublisher(None, isolation, None, project_config, plugin_config) + + assert publisher.disable is False