Skip to content

feat(7983): improving warnings on incorrect or absent configuration #193

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

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
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
19 changes: 16 additions & 3 deletions .envrc
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,22 @@ if [ -d ".venv" ]; then
fi
fi

python -m uv venv .venv --python $PYTHON_VERSION
python -m uv pip install -U pip uv
python -m uv pip install -e .
if [ ! -d ".venv" ]
then
# System python doesn't like us installing packages into it
# Test if uv is already installed (via brew or other package manager etc.)
# and use that if available. Otherwise fall back to previous behvaiour
if command -v uv
then
uv venv .venv --python $PYTHON_VERSION
uv pip install -U pip uv
uv pip install -e .
else
python -m uv venv .venv --python $PYTHON_VERSION
python -m uv pip install -U pip uv
python -m uv pip install -e .
fi
fi
Comment on lines +21 to +36
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't see this mentioned in the PR description.

There is probably something to be done here. You're right to question the awkward combination of tooling, but I think it's better to omit this from the PR and raise it separately.


source ./.venv/bin/activate

Expand Down
31 changes: 30 additions & 1 deletion cloudsmith_cli/cli/commands/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"""Main command/entrypoint."""

from functools import partial

import click

from cloudsmith_cli.cli import config
from cloudsmith_cli.cli.warnings import get_or_create_warnings

from ...core.api.version import get_version as get_api_version
from ...core.utils import get_github_website, get_help_website
from ...core.version import get_version as get_cli_version
Expand Down Expand Up @@ -51,13 +56,37 @@ def print_version():
is_flag=True,
is_eager=True,
)
@click.option(
"--no-warn",
help="Don't display auth or config warnings",
envvar="CLOUDSMITH_CLI_NO_WARN",
is_flag=True,
default=None,
)
@decorators.common_cli_config_options
@click.pass_context
def main(ctx, opts, version):
def main(ctx, opts, version, no_warn):
"""Handle entrypoint to CLI."""
# pylint: disable=unused-argument

if no_warn:
opts.no_warn = True

if version:
opts.no_warn = True
print_version()
elif ctx.invoked_subcommand is None:
click.echo(ctx.get_help())


@main.result_callback()
@click.pass_context
def result_callback(ctx, _, **kwargs):
"""Callback for main function. Required for saving warnings til the end."""

warnings = get_or_create_warnings(ctx)
opts = config.get_or_create_options(ctx)

if warnings and not opts.no_warn:
click_warn_partial = partial(click.secho, fg="yellow")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't write warnings to stdout.

(Question: Is writing to stdout why a bunch of pre-existing tests now need the pytest fixture to suppress these warnings? If we write warnings to stderr instead, do those tests pass without that fixture?)

Suggested change
click_warn_partial = partial(click.secho, fg="yellow")
click_warn_partial = partial(click.secho, fg="yellow", err=True)

warnings.display(click_warn_partial)
66 changes: 59 additions & 7 deletions cloudsmith_cli/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import click
from click_configfile import ConfigFileReader, Param, SectionSchema, matches_section

from cloudsmith_cli.cli.warnings import ConfigLoadWarning, ProfileNotFoundWarning

from ..core.utils import get_data_path, read_file
from . import utils, validators

Expand Down Expand Up @@ -148,8 +150,9 @@ def has_default_file(cls):
return False

@classmethod
def load_config(cls, opts, path=None, profile=None):
def load_config(cls, opts, path=None, warnings=None, profile=None):
"""Load a configuration file into an options object."""

if path and os.path.exists(path):
if os.path.isdir(path):
cls.config_searchpath.insert(0, path)
Expand All @@ -159,10 +162,25 @@ def load_config(cls, opts, path=None, profile=None):
config = cls.read_config()
values = config.get("default", {})
cls._load_values_into_opts(opts, values)
existing_config_paths = {
path: os.path.exists(path) for path in cls.config_files
}

if profile and profile != "default":
values = config.get("profile:%s" % profile, {})
cls._load_values_into_opts(opts, values)
try:
values = config["profile:%s" % profile]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
values = config["profile:%s" % profile]
values = config[f"profile:{profile}"]

cls._load_values_into_opts(opts, values)
except KeyError:
warning = ProfileNotFoundWarning(
paths=existing_config_paths, profile=profile
)
warnings.append(warning)

if not any(list(existing_config_paths.values())):
config_load_warning = ConfigLoadWarning(
paths=existing_config_paths,
)
warnings.append(config_load_warning)
Comment on lines +179 to +183
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really want to warn if no config file was found?

The user might have provided the CLOUDSMITH_API_KEY environment variable for example, such as we do in the pytest suite.


return values

Expand Down Expand Up @@ -206,7 +224,31 @@ class CredentialsReader(ConfigReader):
config_searchpath = list(_CFG_SEARCH_PATHS)
config_section_schemas = [CredentialsSchema.Default, CredentialsSchema.Profile]

@classmethod
def load_config(cls, opts, path=None, warnings=None, profile=None):
"""
Load a credentials configuration file into an options object.
We overload the load_config command in CredentialsReader as
credentials files have their own specific default functionality.
"""
if path and os.path.exists(path):
if os.path.isdir(path):
cls.config_searchpath.insert(0, path)
else:
cls.config_files.insert(0, path)

config = cls.read_config()
values = config.get("default", {})
cls._load_values_into_opts(opts, values)

if profile and profile != "default":
values = config.get("profile:%s" % profile, {})
cls._load_values_into_opts(opts, values)

return values


# pylint: disable=too-many-public-methods
class Options:
"""Options object that holds config for the application."""

Expand All @@ -227,15 +269,15 @@ def get_creds_reader():
"""Get the credentials config reader class."""
return CredentialsReader

def load_config_file(self, path, profile=None):
def load_config_file(self, path, warnings=None, profile=None):
"""Load the standard config file."""
config_cls = self.get_config_reader()
return config_cls.load_config(self, path, profile=profile)
return config_cls.load_config(self, path, warnings=warnings, profile=profile)

def load_creds_file(self, path, profile=None):
def load_creds_file(self, path, warnings=None, profile=None):
"""Load the credentials config file."""
config_cls = self.get_creds_reader()
return config_cls.load_config(self, path, profile=profile)
return config_cls.load_config(self, path, warnings=warnings, profile=profile)

@property
def api_config(self):
Expand Down Expand Up @@ -268,6 +310,16 @@ def api_host(self, value):
"""Set value for API host."""
self._set_option("api_host", value)

@property
def no_warn(self):
"""Get value for API host."""
return self._get_option("no_warn")

@no_warn.setter
def no_warn(self, value):
"""Set value for API host."""
self._set_option("no_warn", value)

@property
def api_key(self):
"""Get value for API key."""
Expand Down
15 changes: 13 additions & 2 deletions cloudsmith_cli/cli/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

import click

from cloudsmith_cli.cli.warnings import ApiAuthenticationWarning, get_or_create_warnings

from ..core.api.init import initialise_api as _initialise_api
from ..core.api.user import get_user_brief
from . import config, utils, validators


Expand Down Expand Up @@ -91,11 +94,12 @@ def common_cli_config_options(f):
def wrapper(ctx, *args, **kwargs):
# pylint: disable=missing-docstring
opts = config.get_or_create_options(ctx)
warnings = get_or_create_warnings(ctx)
profile = kwargs.pop("profile")
config_file = kwargs.pop("config_file")
creds_file = kwargs.pop("credentials_file")
opts.load_config_file(path=config_file, profile=profile)
opts.load_creds_file(path=creds_file, profile=profile)
opts.load_config_file(path=config_file, profile=profile, warnings=warnings)
opts.load_creds_file(path=creds_file, profile=profile, warnings=warnings)
kwargs["opts"] = opts
return ctx.invoke(f, *args, **kwargs)

Expand Down Expand Up @@ -305,6 +309,13 @@ def call_print_rate_limit_info_with_opts(rate_info):
error_retry_cb=opts.error_retry_cb,
)

cloudsmith_host = kwargs["opts"].opts["api_config"].host
is_auth, _, _, _ = get_user_brief()
if not is_auth:
warnings = get_or_create_warnings(ctx)
auth_warning = ApiAuthenticationWarning(cloudsmith_host)
warnings.append(auth_warning)

kwargs["opts"] = opts
return ctx.invoke(f, *args, **kwargs)

Expand Down
4 changes: 3 additions & 1 deletion cloudsmith_cli/cli/tests/commands/policy/test_licence.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ def assert_output_matches_policy_config(output, config_file_path):
return output_table["Identifier"]


@pytest.mark.usefixtures("set_api_key_env_var", "set_api_host_env_var")
@pytest.mark.usefixtures(
"set_api_key_env_var", "set_api_host_env_var", "set_no_warn_env_var"
)
def test_license_policy_commands(runner, organization, tmp_path):
"""Test CRUD operations for license policies."""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ def assert_output_matches_policy_config(output, config_file_path):
return output_table["Identifier"]


@pytest.mark.usefixtures("set_api_key_env_var", "set_api_host_env_var")
@pytest.mark.usefixtures(
"set_api_key_env_var", "set_api_host_env_var", "set_no_warn_env_var"
)
def test_vulnerability_policy_commands(runner, organization, tmp_path):
"""Test CRUD operations for vulnerability policies."""

Expand Down
8 changes: 6 additions & 2 deletions cloudsmith_cli/cli/tests/commands/test_package_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
from ..utils import random_str


@pytest.mark.usefixtures("set_api_key_env_var", "set_api_host_env_var")
@pytest.mark.usefixtures(
"set_api_key_env_var", "set_api_host_env_var", "set_no_warn_env_var"
)
@pytest.mark.parametrize(
"filesize",
[
Expand Down Expand Up @@ -80,7 +82,9 @@ def test_push_and_delete_raw_package(
assert len(data) == 0


@pytest.mark.usefixtures("set_api_key_env_var", "set_api_host_env_var")
@pytest.mark.usefixtures(
"set_api_key_env_var", "set_api_host_env_var", "set_no_warn_env_var"
)
def test_list_packages_with_sort(runner, organization, tmp_repository, tmp_path):
"""Test listing packages with different sort options."""
org_repo = f'{organization}/{tmp_repository["slug"]}'
Expand Down
4 changes: 3 additions & 1 deletion cloudsmith_cli/cli/tests/commands/test_repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ def assert_output_is_equal_to_repo_config(output, organisation, repo_config_file
)


@pytest.mark.usefixtures("set_api_key_env_var", "set_api_host_env_var")
@pytest.mark.usefixtures(
"set_api_key_env_var", "set_api_host_env_var", "set_no_warn_env_var"
)
def test_repos_commands(runner, organization, tmp_path):
"""Test CRUD operations for repositories."""

Expand Down
4 changes: 3 additions & 1 deletion cloudsmith_cli/cli/tests/commands/test_upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
from ..utils import random_str


@pytest.mark.usefixtures("set_api_key_env_var", "set_api_host_env_var")
@pytest.mark.usefixtures(
"set_api_key_env_var", "set_api_host_env_var", "set_no_warn_env_var"
)
@pytest.mark.parametrize("upstream_format", UPSTREAM_FORMATS)
def test_upstream_commands(
runner, organization, upstream_format, tmp_repository, tmp_path
Expand Down
6 changes: 6 additions & 0 deletions cloudsmith_cli/cli/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,9 @@ def set_api_host_env_var(api_host):
def set_api_key_env_var(api_key):
"""Set the CLOUDSMITH_API_KEY environment variable."""
os.environ["CLOUDSMITH_API_KEY"] = api_key


@pytest.fixture()
def set_no_warn_env_var():
"""Set the CLOUDSMITH_API_KEY environment variable."""
os.environ["CLOUDSMITH_CLI_NO_WARN"] = "True"
Comment on lines +76 to +81
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The need to add this fixture to so many pre-existing tests is a bit of a smell.

No, I'm not advocating for autouse=True :)

26 changes: 26 additions & 0 deletions cloudsmith_cli/cli/tests/test_warnings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from cloudsmith_cli.cli.warnings import (
ApiAuthenticationWarning,
CliWarnings,
ConfigLoadWarning,
ProfileNotFoundWarning,
)


class TestWarnings:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WLTS tests that replicate the scenarios mentioned in the PR description.

def test_warning_append(self):
"""Test appending warnings to the CliWarnings."""

config_load_warning_1 = ConfigLoadWarning({"test_path_1": False})
config_load_warning_2 = ConfigLoadWarning({"test_path_2": True})
profile_load_warning = ProfileNotFoundWarning(
{"test_path_1": False}, "test_profile"
)
api_authentication_warning = ApiAuthenticationWarning("test.cloudsmith.io")
cli_warnings = CliWarnings()
cli_warnings.append(config_load_warning_1)
cli_warnings.append(config_load_warning_2)
cli_warnings.append(profile_load_warning)
cli_warnings.append(profile_load_warning)
cli_warnings.append(api_authentication_warning)
assert len(cli_warnings) == 5
assert len(cli_warnings.__dedupe__()) == 4
Loading