Skip to content
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 cloudsmith_cli/cli/commands/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def print_version(opts):
envvar="CLOUDSMITH_NO_UPDATE_CHECK",
help="Disable the once-a-day check for a newer version of the CLI.",
)
@decorators.keyring_options
@decorators.common_cli_config_options
@decorators.common_cli_output_options
@click.pass_context
Expand Down
62 changes: 61 additions & 1 deletion cloudsmith_cli/cli/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from cloudsmith_cli.cli import validators
from cloudsmith_cli.core.utils import ColorMode, TTYMode, color_enabled

from ..core import update_check
from ..core import keyring, update_check
from ..core.certificate_errors import describe_certificate_error
from ..core.credentials.chain import CredentialProviderChain
from ..core.credentials.models import CredentialContext
Expand Down Expand Up @@ -118,6 +118,66 @@ def wrapper(ctx, *args, **kwargs):
return wrapper


def _absolute_path(ctx, param, value):
"""Expand ``~`` and environment variables in *value* and make it absolute."""
if not value:
return value
return os.path.abspath(os.path.expanduser(os.path.expandvars(value)))


def keyring_options(f):
"""Add hidden options that read the keyring settings from the environment."""

@click.option(
"--no-keyring-env",
"no_keyring",
envvar="CLOUDSMITH_NO_KEYRING",
hidden=True,
)
@click.option(
"--keyring-backend-env",
"keyring_backend",
envvar="CLOUDSMITH_KEYRING_BACKEND",
hidden=True,
)
@click.option(
"--keyring-key-env",
"keyring_key",
envvar="CLOUDSMITH_KEYRING_KEY",
Comment thread
cloudsmith-iduffy marked this conversation as resolved.
Resolved
hidden=True,
)
@click.option(
"--keyring-file-path-env",
"keyring_file_path",
envvar="CLOUDSMITH_KEYRING_FILE_PATH",
callback=_absolute_path,
hidden=True,
)
@click.option(
"--keyring-dir-env",
"keyring_dir",
envvar="CLOUDSMITH_KEYRING_DIR",
callback=_absolute_path,
hidden=True,
)
@click.pass_context
@functools.wraps(f)
def wrapper(ctx, *args, **kwargs):
keyring.configure(
keyring.KeyringSettings(
no_keyring=(kwargs.pop("no_keyring") or "").strip().lower()
in ("1", "true", "yes"),
backend=kwargs.pop("keyring_backend"),
key=kwargs.pop("keyring_key"),
file_path=kwargs.pop("keyring_file_path"),
directory=kwargs.pop("keyring_dir"),
)
)
return ctx.invoke(f, *args, **kwargs)

return wrapper


def common_cli_config_options(f):
"""Add common CLI config options to commands."""

Expand Down
3 changes: 2 additions & 1 deletion cloudsmith_cli/cli/tests/commands/test_domains.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from ....cli import config as cli_config
from ....cli.commands.domains import list_domains
from ....core import keyring as core_keyring
from ....core.api.exceptions import ApiException
from ....credential_helpers.domains import (
domain_from_payload,
Expand Down Expand Up @@ -35,7 +36,7 @@ def hermetic_environment(monkeypatch, tmp_path):
monkeypatch.delenv("CLOUDSMITH_API_HOST", raising=False)
monkeypatch.delenv("CLOUDSMITH_CONFIG_FILE", raising=False)
monkeypatch.delenv("CLOUDSMITH_API_KEY", raising=False)
monkeypatch.setenv("CLOUDSMITH_NO_KEYRING", "1")
core_keyring.configure(core_keyring.KeyringSettings(no_keyring=True))
monkeypatch.delattr(cli_config.OPTIONS, "value", raising=False)
monkeypatch.setattr(cli_config.ConfigReader, "config_files", ["config.ini"])
monkeypatch.setattr(cli_config.ConfigReader, "config_searchpath", ["."])
Expand Down
4 changes: 3 additions & 1 deletion cloudsmith_cli/cli/tests/test_credential_helper_cargo.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import pytest
import tomlkit

from ...core import keyring as core_keyring
from ...core.credentials.models import CredentialResult
from ...credential_helpers.backends import BackendKind
from ...credential_helpers.cargo.installer import CargoInstaller
Expand Down Expand Up @@ -355,6 +356,7 @@ def test_cli_accepts_the_cargo_plugin_flag_and_extra_provider_args(runner):

def test_cli_exits_non_zero_with_a_hint_when_no_credential_resolves(runner):
"""A refused `get` still answers Cargo in-band, then exits 1 with a hint."""
core_keyring.configure(core_keyring.KeyringSettings(no_keyring=True))
with patch(
"cloudsmith_cli.credential_helpers.cargo.runtime.is_cloudsmith_domain",
return_value=True,
Expand All @@ -363,7 +365,7 @@ def test_cli_exits_non_zero_with_a_hint_when_no_credential_resolves(runner):
cargo,
args=[],
input=json.dumps(_request()) + "\n",
env={"CLOUDSMITH_API_KEY": "", "CLOUDSMITH_NO_KEYRING": "1"},
env={"CLOUDSMITH_API_KEY": ""},
catch_exceptions=False,
)

Expand Down
35 changes: 35 additions & 0 deletions cloudsmith_cli/cli/tests/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import click.testing
import pytest

from ...core import keyring as core_keyring
from ...core.mcp.server import create_ssl_context
from ...core.session import create_requests_session
from ...core.tests.test_certificate_errors import certificate_error
Expand All @@ -12,6 +13,7 @@
from ..decorators import (
initialise_api,
initialise_session,
keyring_options,
report_retry,
resolve_credentials,
)
Expand Down Expand Up @@ -289,3 +291,36 @@ def leaf(opts):
)
assert result.exit_code == 0, result.output
assert seen == [PROXY]


def test_keyring_options_configure_the_keyring(monkeypatch, tmp_path):
"""The keyring settings come from the environment, with absolute paths."""

@click.command()
@keyring_options
def command():
pass

monkeypatch.chdir(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
Comment thread
Copilot marked this conversation as resolved.
Resolved
monkeypatch.setenv("USERPROFILE", str(tmp_path / "home"))
monkeypatch.setenv("KEYRING_SUBDIR", "store")
result = click.testing.CliRunner().invoke(
command,
env={
"CLOUDSMITH_NO_KEYRING": "yes",
"CLOUDSMITH_KEYRING_BACKEND": "keyring.backends.null.Keyring",
"CLOUDSMITH_KEYRING_KEY": "secret",
"CLOUDSMITH_KEYRING_FILE_PATH": "keyring.cfg",
"CLOUDSMITH_KEYRING_DIR": "~/$KEYRING_SUBDIR",
},
)

assert result.exit_code == 0, result.output
assert core_keyring.current_settings() == core_keyring.KeyringSettings(
no_keyring=True,
backend="keyring.backends.null.Keyring",
key="secret",
file_path=str(tmp_path / "keyring.cfg"),
directory=str(tmp_path / "home" / "store"),
)
7 changes: 7 additions & 0 deletions cloudsmith_cli/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import cloudsmith_api
import pytest

from cloudsmith_cli.core import keyring as core_keyring
from cloudsmith_cli.core import session, telemetry

#: A syntactically valid DSN that is never contacted: tests that report use
Expand All @@ -19,6 +20,12 @@ def no_real_error_reports(monkeypatch):
monkeypatch.setattr(telemetry, "_transport", None)


@pytest.fixture(autouse=True)
def default_keyring_settings():
"""Start each test with the default keyring settings."""
core_keyring.configure(core_keyring.KeyringSettings())


@pytest.fixture()
def telemetry_config_dir(monkeypatch, tmp_path_factory):
"""An empty config search path, isolated from the developer's config.ini.
Expand Down
50 changes: 37 additions & 13 deletions cloudsmith_cli/core/keyring.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import dataclasses
import functools
import getpass
import os
Expand All @@ -9,10 +10,37 @@
ACCESS_TOKEN_KEY = "cloudsmith_cli-access_token-{api_host}"


@dataclasses.dataclass(frozen=True)
class KeyringSettings:
"""The CLOUDSMITH_*KEYRING* settings that the CLI parsed."""

no_keyring: bool = False
backend: str | None = None
key: str | None = None
file_path: str | None = None
directory: str | None = None


class _Current:
"""The keyring settings of this process."""

settings = KeyringSettings()


def configure(settings):
"""Use *settings* for the keyring in this process."""
_Current.settings = settings
_prepare_keyring_backend.cache_clear()


def current_settings():
"""Return the keyring settings of this process."""
return _Current.settings


def should_use_keyring():
"""Check if keyring should be used based on CLOUDSMITH_NO_KEYRING env var."""
env_value = os.environ.get("CLOUDSMITH_NO_KEYRING", "").strip().lower()
return env_value not in ("1", "true", "yes")
"""Check if keyring should be used based on CLOUDSMITH_NO_KEYRING."""
return not _Current.settings.no_keyring


ACCESS_TOKEN_REFRESH_ATTEMPTED_AT_KEY = (
Expand Down Expand Up @@ -52,27 +80,23 @@ def _get_value_with_fallback(template, api_host, profile):

def _sync_keyring_backend_env():
"""Allow CLOUDSMITH_KEYRING_BACKEND to alias PYTHON_KEYRING_BACKEND."""
alias_value = os.environ.get("CLOUDSMITH_KEYRING_BACKEND")
alias_value = _Current.settings.backend
if alias_value and "PYTHON_KEYRING_BACKEND" not in os.environ:
os.environ["PYTHON_KEYRING_BACKEND"] = alias_value


def _sync_keyring_property_env():
"""Allow CLOUDSMITH_KEYRING_KEY to alias KEYRING_PROPERTY_KEYRING_KEY."""
alias_value = os.environ.get("CLOUDSMITH_KEYRING_KEY")
alias_value = _Current.settings.key
if alias_value and "KEYRING_PROPERTY_KEYRING_KEY" not in os.environ:
os.environ["KEYRING_PROPERTY_KEYRING_KEY"] = alias_value


def _expand_path(value):
return os.path.expanduser(os.path.expandvars(value))


def _sync_keyring_file_path_env():
"""Allow CLOUDSMITH_KEYRING_FILE_PATH to alias KEYRING_PROPERTY_FILE_PATH."""
alias_value = os.environ.get("CLOUDSMITH_KEYRING_FILE_PATH")
alias_value = _Current.settings.file_path
if alias_value and "KEYRING_PROPERTY_FILE_PATH" not in os.environ:
os.environ["KEYRING_PROPERTY_FILE_PATH"] = _expand_path(alias_value)
os.environ["KEYRING_PROPERTY_FILE_PATH"] = alias_value


def _apply_keyring_file_location(backend):
Expand All @@ -89,13 +113,13 @@ def _apply_keyring_file_location(backend):
if file_path:
backend.file_path = file_path
return
dir_value = os.environ.get("CLOUDSMITH_KEYRING_DIR")
dir_value = _Current.settings.directory
if not dir_value:
return
filename = getattr(backend, "filename", None)
if not filename:
return
backend.file_path = os.path.join(_expand_path(dir_value), filename)
backend.file_path = os.path.join(dir_value, filename)


def _refuse_password_prompt():
Expand Down
Loading
Loading