Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a way to require confirmation for publishing #340

Merged
merged 1 commit into from
Jul 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/plugins/publisher/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@
- cache_dir
- project_config
- plugin_config
- disable
- publish
27 changes: 26 additions & 1 deletion docs/publish.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
1 change: 1 addition & 0 deletions src/hatch/cli/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 6 additions & 2 deletions src/hatch/cli/publish/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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':
Expand Down Expand Up @@ -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)
4 changes: 4 additions & 0 deletions src/hatch/cli/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
26 changes: 26 additions & 0 deletions src/hatch/publish/plugin/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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):
"""
Expand Down
100 changes: 100 additions & 0 deletions tests/cli/publish/test_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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):
Expand Down
Empty file added tests/publish/__init__.py
Empty file.
Empty file.
56 changes: 56 additions & 0 deletions tests/publish/plugin/test_interface.py
Original file line number Diff line number Diff line change
@@ -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