From 5a9ecaa433aee34b544dc8323281aabc53757534 Mon Sep 17 00:00:00 2001 From: Carlos Gonzalez Date: Sun, 6 Apr 2025 19:28:40 -0500 Subject: [PATCH 01/18] Add BaseApi class --- cloudsmith_cli/core/api/init.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cloudsmith_cli/core/api/init.py b/cloudsmith_cli/core/api/init.py index dd7bd2ac..fd9bb80e 100644 --- a/cloudsmith_cli/core/api/init.py +++ b/cloudsmith_cli/core/api/init.py @@ -12,6 +12,14 @@ from .exceptions import ApiException +class BaseApi: + def __init__(self, api_client=None): + if api_client is None: + api_client = cloudsmith_api.ApiClient() + self.api_client = api_client + self.config = cloudsmith_api.Configuration() + + def initialise_api( debug=False, host=None, From 80c902469d626749d4d382d3f4f39fdffe1a033d Mon Sep 17 00:00:00 2001 From: Carlos Gonzalez Date: Sun, 6 Apr 2025 19:38:01 -0500 Subject: [PATCH 02/18] Add list_user_tokens() and refresh_user_token() --- cloudsmith_cli/core/api/user.py | 34 ++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/cloudsmith_cli/core/api/user.py b/cloudsmith_cli/core/api/user.py index 55a247ce..220da328 100644 --- a/cloudsmith_cli/core/api/user.py +++ b/cloudsmith_cli/core/api/user.py @@ -7,7 +7,7 @@ from .. import ratelimits from .exceptions import TwoFactorRequiredException, catch_raise_api_exception -from .init import get_api_client, unset_api_key +from .init import BaseApi, get_api_client, unset_api_key def get_user_api(): @@ -64,3 +64,35 @@ def get_user_brief(): ratelimits.maybe_rate_limit(client, headers) return data.authenticated, data.slug, data.email, data.name + + +def list_user_tokens() -> list[dict]: + """List all user API tokens.""" + client = get_api_client(BaseApi) + + with catch_raise_api_exception(): + data, _, headers = client.api_client.call_api( + "/user/tokens/", + "GET", + auth_settings=["apikey", "basic"], + response_type="object", + ) + + ratelimits.maybe_rate_limit(client, headers) + return data["results"] + + +def refresh_user_token(token_slug: str) -> dict: + """Refresh user API token.""" + client = get_api_client(BaseApi) + + with catch_raise_api_exception(): + data, _, headers = client.api_client.call_api( + f"/user/tokens/{token_slug}/refresh/", + "PUT", + auth_settings=["apikey", "basic"], + response_type="object", + ) + + ratelimits.maybe_rate_limit(client, headers) + return data From aebdcb360b9bccee906e79524e87115737ca3c86 Mon Sep 17 00:00:00 2001 From: Carlos Gonzalez Date: Sun, 6 Apr 2025 19:39:12 -0500 Subject: [PATCH 03/18] Add tokens click commands --- cloudsmith_cli/cli/commands/__init__.py | 1 + cloudsmith_cli/cli/commands/tokens.py | 67 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 cloudsmith_cli/cli/commands/tokens.py diff --git a/cloudsmith_cli/cli/commands/__init__.py b/cloudsmith_cli/cli/commands/__init__.py index 88ce9cc8..02afc6b6 100644 --- a/cloudsmith_cli/cli/commands/__init__.py +++ b/cloudsmith_cli/cli/commands/__init__.py @@ -21,6 +21,7 @@ resync, status, tags, + tokens, upstream, whoami, ) diff --git a/cloudsmith_cli/cli/commands/tokens.py b/cloudsmith_cli/cli/commands/tokens.py new file mode 100644 index 00000000..d6e9acfc --- /dev/null +++ b/cloudsmith_cli/cli/commands/tokens.py @@ -0,0 +1,67 @@ +import click + +from ...core.api import user as api +from .. import command, decorators +from ..exceptions import handle_api_exceptions +from ..utils import maybe_print_as_json, maybe_spinner +from .main import main + + +@main.group(cls=command.AliasGroup, name="tokens") +@decorators.common_cli_config_options +@decorators.common_cli_output_options +@click.pass_context +def tokens(ctx, opts): + """Manage your user API tokens.""" + + +@tokens.command(name="list", aliases=["ls"]) +@decorators.common_cli_config_options +@decorators.common_cli_output_options +@decorators.common_api_auth_options +@decorators.initialise_api +@click.pass_context +def list_tokens(ctx, opts): + """List all user API tokens.""" + use_stderr = opts.output in ("json", "pretty_json") + + click.echo("Retrieving API tokens... ", nl=False, err=use_stderr) + + context_msg = "Failed to retrieve API tokens!" + with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg): + with maybe_spinner(opts): + tokens = api.list_user_tokens() + click.secho("OK", fg="green", err=use_stderr) + + if maybe_print_as_json(opts, tokens): + return + + for token in tokens: + click.echo( + f"Token: {click.style(token['key'], fg='magenta')}, " + f"Created: {click.style(token['created'], fg='green')}, " + f"slug_perm: {click.style(token['slug_perm'], fg='cyan')}" + ) + + +@tokens.command() +@click.argument("token_slug") +@decorators.common_cli_config_options +@decorators.common_cli_output_options +@decorators.common_api_auth_options +@decorators.initialise_api +@click.pass_context +def refresh(ctx, opts, token_slug): + """Refresh a specific API token by its slug.""" + click.echo(f"Refreshing token {token_slug}... ", nl=False) + + context_msg = "Failed to refresh the token!" + with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg): + with maybe_spinner(opts): + new_token = api.refresh_user_token(token_slug) + click.secho("OK", fg="green") + + if maybe_print_as_json(opts, new_token): + return + + click.echo(f"New token value: {click.style(new_token['key'], fg='magenta')}") From e03939016fa04cfbb3433e3eb908aa0602a128f4 Mon Sep 17 00:00:00 2001 From: Carlos Gonzalez Date: Mon, 7 Apr 2025 11:59:42 -0500 Subject: [PATCH 04/18] Add create_user_token_saml() --- cloudsmith_cli/core/api/user.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cloudsmith_cli/core/api/user.py b/cloudsmith_cli/core/api/user.py index 220da328..a870da75 100644 --- a/cloudsmith_cli/core/api/user.py +++ b/cloudsmith_cli/core/api/user.py @@ -55,6 +55,22 @@ def get_user_token(login, password, totp_token=None, two_factor_token=None): raise +def create_user_token_saml() -> dict: + """Create a new user API token using SAML.""" + client = get_api_client(BaseApi) + + with catch_raise_api_exception(): + data, _, headers = client.api_client.call_api( + "/user/tokens/", + "POST", + auth_settings=["apikey"], + response_type="object", + ) + + ratelimits.maybe_rate_limit(client, headers) + return data + + def get_user_brief(): """Retrieve brief for current user (if any).""" client = get_user_api() From d4fe6d809af44dfe1f4a081e91737a32583cad65 Mon Sep 17 00:00:00 2001 From: Carlos Gonzalez Date: Mon, 7 Apr 2025 12:06:11 -0500 Subject: [PATCH 05/18] Refactor config --- cloudsmith_cli/cli/commands/login.py | 112 +------------------------- cloudsmith_cli/core/config.py | 114 +++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 110 deletions(-) create mode 100644 cloudsmith_cli/core/config.py diff --git a/cloudsmith_cli/cli/commands/login.py b/cloudsmith_cli/cli/commands/login.py index a3dbecb2..da885ac1 100644 --- a/cloudsmith_cli/cli/commands/login.py +++ b/cloudsmith_cli/cli/commands/login.py @@ -1,23 +1,16 @@ """CLI/Commands - Get an API token.""" -import collections -import stat - import click import cloudsmith_api from ...core.api.exceptions import TwoFactorRequiredException from ...core.api.user import get_user_token -from ...core.utils import get_help_website +from ...core.config import create_config_files, new_config_messaging from .. import decorators from ..exceptions import handle_api_exceptions from ..utils import maybe_spinner from .main import main -ConfigValues = collections.namedtuple( - "ConfigValues", ["reader", "present", "mode", "data"] -) - def validate_login(ctx, param, value): """Ensure that login is not blank.""" @@ -28,81 +21,6 @@ def validate_login(ctx, param, value): return value -def create_config_files(ctx, opts, api_key): - """Create default config files.""" - # pylint: disable=unused-argument - config_reader = opts.get_config_reader() - creds_reader = opts.get_creds_reader() - has_config = config_reader.has_default_file() - has_creds = creds_reader.has_default_file() - - if has_config and has_creds: - create = False - else: - click.echo() - create = click.confirm( - "No default config file(s) found, do you want to create them?" - ) - - click.echo() - if not create: - click.secho( - "For reference here are your default config file locations:", fg="yellow" - ) - else: - click.secho( - "Great! Let me just create your default configs for you now ...", fg="green" - ) - - configs = ( - ConfigValues(reader=config_reader, present=has_config, mode=None, data={}), - ConfigValues( - reader=creds_reader, - present=has_creds, - mode=stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP, - data={"api_key": api_key}, - ), - ) - - has_errors = False - for config in configs: - click.echo( - "%(name)s config file: %(filepath)s ... " - % { - "name": click.style(config.reader.config_name.capitalize(), bold=True), - "filepath": click.style( - config.reader.get_default_filepath(), fg="magenta" - ), - }, - nl=False, - ) - - if not config.present and create: - try: - ok = config.reader.create_default_file( - data=config.data, mode=config.mode - ) - except OSError as exc: - ok = False - error_message = exc.strerror - has_errors = True - - if ok: - click.secho("CREATED", fg="green") - else: - click.secho("ERROR", fg="red") - click.secho( - "The following error occurred while trying to " - "create the file: %(message)s" - % {"message": click.style(error_message, fg="red")} - ) - continue - - click.secho("EXISTS" if config.present else "NOT CREATED", fg="yellow") - - return create, has_errors - - @main.command(aliases=["token"]) @click.option( "-l", @@ -170,30 +88,4 @@ def login(ctx, opts, login, password): # pylint: disable=redefined-outer-name ) create, has_errors = create_config_files(ctx, opts, api_key=api_key) - - if has_errors: - click.echo() - click.secho("Oops, please fix the errors and try again!", fg="red") - return - - if opts.api_key != api_key: - click.echo() - if opts.api_key: - click.secho( - "Note: The above API key doesn't match what you have in " - "your default credentials config file.", - fg="yellow", - ) - elif not create: - click.secho( - "Note: Don't forget to put your API key in a config file, " - "export it on the environment, or set it via -k.", - fg="yellow", - ) - click.secho( - "If you need more help please see the documentation: " - "%(website)s" % {"website": click.style(get_help_website(), bold=True)} - ) - click.echo() - - click.secho("You're ready to rock, let's start automating!", fg="green") + new_config_messaging(has_errors, opts, create, api_key=api_key) diff --git a/cloudsmith_cli/core/config.py b/cloudsmith_cli/core/config.py new file mode 100644 index 00000000..c060f8b4 --- /dev/null +++ b/cloudsmith_cli/core/config.py @@ -0,0 +1,114 @@ +import collections +import stat + +import click + +from .utils import get_help_website + +ConfigValues = collections.namedtuple( + "ConfigValues", ["reader", "present", "mode", "data"] +) + + +def create_config_files(ctx, opts, api_key): + """Create default config files.""" + # pylint: disable=unused-argument + config_reader = opts.get_config_reader() + creds_reader = opts.get_creds_reader() + has_config = config_reader.has_default_file() + has_creds = creds_reader.has_default_file() + + if has_config and has_creds: + create = False + else: + click.echo() + create = click.confirm( + "No default config file(s) found, do you want to create them?" + ) + + click.echo() + if not create: + click.secho( + "For reference here are your default config file locations:", fg="yellow" + ) + else: + click.secho( + "Great! Let me just create your default configs for you now ...", fg="green" + ) + + configs = ( + ConfigValues(reader=config_reader, present=has_config, mode=None, data={}), + ConfigValues( + reader=creds_reader, + present=has_creds, + mode=stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP, + data={"api_key": api_key}, + ), + ) + + has_errors = False + for config in configs: + click.echo( + "%(name)s config file: %(filepath)s ... " + % { + "name": click.style(config.reader.config_name.capitalize(), bold=True), + "filepath": click.style( + config.reader.get_default_filepath(), fg="magenta" + ), + }, + nl=False, + ) + + if not config.present and create: + try: + ok = config.reader.create_default_file( + data=config.data, mode=config.mode + ) + except OSError as exc: + ok = False + error_message = exc.strerror + has_errors = True + + if ok: + click.secho("CREATED", fg="green") + else: + click.secho("ERROR", fg="red") + click.secho( + "The following error occurred while trying to " + "create the file: %(message)s" + % {"message": click.style(error_message, fg="red")} + ) + continue + + click.secho("EXISTS" if config.present else "NOT CREATED", fg="yellow") + + return create, has_errors + + +def new_config_messaging(has_errors, opts, create, api_key): + if has_errors: + click.echo() + click.secho("Oops, please fix the errors and try again!", fg="red") + return + + if opts.api_key != api_key: + click.echo() + if opts.api_key: + click.secho( + "Note: The above API key doesn't match what you have in " + "your default credentials config file.", + fg="yellow", + ) + elif not create: + click.secho( + "Note: Don't forget to put your API key in a config file, " + "export it on the environment, or set it via -k.", + fg="yellow", + ) + click.secho( + "If you need more help please see the documentation: " + "%(website)s" % {"website": click.style(get_help_website(), bold=True)} + ) + click.echo() + + click.secho("You're ready to rock, let's start automating!", fg="green") From a9fff7521d80c536f9ef58e82d1d0f8e5283b7de Mon Sep 17 00:00:00 2001 From: Carlos Gonzalez Date: Mon, 7 Apr 2025 12:07:13 -0500 Subject: [PATCH 06/18] Add feature to create API tokens via auth (saml/sso login) --- cloudsmith_cli/cli/commands/auth.py | 47 ++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/cloudsmith_cli/cli/commands/auth.py b/cloudsmith_cli/cli/commands/auth.py index 46a7d8a4..abd33ae4 100644 --- a/cloudsmith_cli/cli/commands/auth.py +++ b/cloudsmith_cli/cli/commands/auth.py @@ -4,9 +4,13 @@ import click +from ...core.api import exceptions, user +from ...core.api.init import initialise_api +from ...core.config import create_config_files, new_config_messaging from .. import decorators, validators from ..exceptions import handle_api_exceptions from ..saml import create_configured_session, get_idp_url +from ..utils import maybe_spinner from ..webserver import AuthenticationWebRequestHandler, AuthenticationWebServer from .main import main @@ -21,11 +25,18 @@ prompt=True, help="The name of the Cloudsmith organization to authenticate with.", ) +@click.option( + "-t", + "--token", + default=False, + is_flag=True, + help="Retrieve a user API token after successful authentication.", +) @decorators.common_cli_config_options @decorators.common_cli_output_options @decorators.initialise_api @click.pass_context -def authenticate(ctx, opts, owner): +def authenticate(ctx, opts, owner, token): """Authenticate to Cloudsmith using the org's SAML setup.""" owner = owner[0].strip("'[]'") api_host = opts.api_config.host @@ -58,3 +69,37 @@ def authenticate(ctx, opts, owner): debug=opts.debug, ) auth_server.handle_request() + + if not token: + return + + initialise_api() + try: + api_token = user.create_user_token_saml() + click.echo( + f"New token value: {click.style(api_token['key'], fg='magenta')}" + ) + create, has_errors = create_config_files( + ctx, opts, api_key=api_token["key"] + ) + new_config_messaging(has_errors, opts, create, api_key=api_token["key"]) + return + except exceptions.ApiException as exc: + if exc.status == 400: + click.confirm( + "User already has a token. Would you like to recreate it?", + abort=True, + ) + + context_msg = "Failed to refresh the token!" + with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg): + token_slug = user.list_user_tokens()[0]["slug_perm"] + + click.echo(f"Refreshing token {token_slug}... ", nl=False) + with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg): + with maybe_spinner(opts): + new_token = user.refresh_user_token("token_slug") + click.secho("OK", fg="green") + click.echo(f"New token value: {click.style(new_token['key'], fg='magenta')}") + create, has_errors = create_config_files(ctx, opts, api_key=new_token["key"]) + new_config_messaging(has_errors, opts, create, api_key=new_token["key"]) From 0da4ed6cbd9bff3d6137a0c62866cdb87f6bee3f Mon Sep 17 00:00:00 2001 From: Carlos Gonzalez Date: Mon, 7 Apr 2025 13:16:28 -0500 Subject: [PATCH 07/18] Ask user input for refreshing token --- cloudsmith_cli/cli/commands/auth.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cloudsmith_cli/cli/commands/auth.py b/cloudsmith_cli/cli/commands/auth.py index abd33ae4..aa0b998d 100644 --- a/cloudsmith_cli/cli/commands/auth.py +++ b/cloudsmith_cli/cli/commands/auth.py @@ -93,7 +93,17 @@ def authenticate(ctx, opts, owner, token): context_msg = "Failed to refresh the token!" with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg): - token_slug = user.list_user_tokens()[0]["slug_perm"] + api_tokens = user.list_user_tokens() + for t in api_tokens: + click.echo("Current tokens:") + click.echo( + f"Token: {click.style(t['key'], fg='magenta')}, " + f"Created: {click.style(t['created'], fg='green')}, " + f"slug_perm: {click.style(t['slug_perm'], fg='cyan')}" + ) + token_slug = click.prompt( + "Please enter the slug_perm of the token you would like to refresh" + ) click.echo(f"Refreshing token {token_slug}... ", nl=False) with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg): From 364c60e16500885c5c5581e90a1fd6c0b769316f Mon Sep 17 00:00:00 2001 From: Carlos Gonzalez Date: Mon, 7 Apr 2025 13:25:07 -0500 Subject: [PATCH 08/18] docstring for new_config_messaging() --- cloudsmith_cli/core/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cloudsmith_cli/core/config.py b/cloudsmith_cli/core/config.py index c060f8b4..16044fb6 100644 --- a/cloudsmith_cli/core/config.py +++ b/cloudsmith_cli/core/config.py @@ -86,6 +86,7 @@ def create_config_files(ctx, opts, api_key): def new_config_messaging(has_errors, opts, create, api_key): + """Provide messaging to user after generating new configs""" if has_errors: click.echo() click.secho("Oops, please fix the errors and try again!", fg="red") From 1fed75230171ba3a8c0ea288133adef0f18fa320 Mon Sep 17 00:00:00 2001 From: Carlos Gonzalez Date: Mon, 7 Apr 2025 13:47:08 -0500 Subject: [PATCH 09/18] Make token_slug optional --- cloudsmith_cli/cli/commands/tokens.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/cloudsmith_cli/cli/commands/tokens.py b/cloudsmith_cli/cli/commands/tokens.py index d6e9acfc..1b414af4 100644 --- a/cloudsmith_cli/cli/commands/tokens.py +++ b/cloudsmith_cli/cli/commands/tokens.py @@ -45,7 +45,10 @@ def list_tokens(ctx, opts): @tokens.command() -@click.argument("token_slug") +@click.argument( + "token_slug", + required=False, +) @decorators.common_cli_config_options @decorators.common_cli_output_options @decorators.common_api_auth_options @@ -53,9 +56,24 @@ def list_tokens(ctx, opts): @click.pass_context def refresh(ctx, opts, token_slug): """Refresh a specific API token by its slug.""" - click.echo(f"Refreshing token {token_slug}... ", nl=False) - context_msg = "Failed to refresh the token!" + + if not token_slug: + with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg): + with maybe_spinner(opts): + api_tokens = api.list_user_tokens() + for t in api_tokens: + click.echo("Current tokens:") + click.echo( + f"Token: {click.style(t['key'], fg='magenta')}, " + f"Created: {click.style(t['created'], fg='green')}, " + f"slug_perm: {click.style(t['slug_perm'], fg='cyan')}" + ) + token_slug = click.prompt( + "Please enter the slug_perm of the token you would like to refresh" + ) + + click.echo(f"Refreshing token {token_slug}... ", nl=False) with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg): with maybe_spinner(opts): new_token = api.refresh_user_token(token_slug) From f50cfe3e655744e5f9a984e9dce4180ebf7a6ef6 Mon Sep 17 00:00:00 2001 From: Carlos Gonzalez Date: Mon, 7 Apr 2025 19:14:11 -0500 Subject: [PATCH 10/18] Update error handling --- cloudsmith_cli/cli/commands/auth.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cloudsmith_cli/cli/commands/auth.py b/cloudsmith_cli/cli/commands/auth.py index aa0b998d..00721deb 100644 --- a/cloudsmith_cli/cli/commands/auth.py +++ b/cloudsmith_cli/cli/commands/auth.py @@ -86,10 +86,12 @@ def authenticate(ctx, opts, owner, token): return except exceptions.ApiException as exc: if exc.status == 400: - click.confirm( - "User already has a token. Would you like to recreate it?", - abort=True, - ) + if "User has already created an API key" in exc.detail: + click.confirm( + "User already has a token. Would you like to recreate it?", + abort=True, + ) + raise context_msg = "Failed to refresh the token!" with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg): From e2d8206bd38188f9ebcd71cc55190d8673b1151d Mon Sep 17 00:00:00 2001 From: Carlos Gonzalez Date: Tue, 8 Apr 2025 08:04:11 -0500 Subject: [PATCH 11/18] Fix error handling --- cloudsmith_cli/cli/commands/auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cloudsmith_cli/cli/commands/auth.py b/cloudsmith_cli/cli/commands/auth.py index 00721deb..e4fed687 100644 --- a/cloudsmith_cli/cli/commands/auth.py +++ b/cloudsmith_cli/cli/commands/auth.py @@ -91,7 +91,8 @@ def authenticate(ctx, opts, owner, token): "User already has a token. Would you like to recreate it?", abort=True, ) - raise + else: + raise context_msg = "Failed to refresh the token!" with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg): From acf41f1735f028983287e72a53b3e5112f7aa8ff Mon Sep 17 00:00:00 2001 From: Martin Hutchings Date: Wed, 30 Apr 2025 15:28:39 +0100 Subject: [PATCH 12/18] Update cloudsmith-api version --- requirements.txt | 16 +++++++--------- setup.py | 2 +- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9c9d8409..e82dc971 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,19 +34,17 @@ click-didyoumean==0.3.1 # via cloudsmith-cli (setup.py) click-spinner==0.1.10 # via cloudsmith-cli (setup.py) -cloudsmith-api==2.0.16 +cloudsmith-api==2.0.18 # via cloudsmith-cli (setup.py) configparser==7.1.0 # via click-configfile coverage[toml]==7.2.7 - # via - # coverage - # pytest-cov + # via pytest-cov dill==0.3.7 # via pylint distlib==0.3.7 # via virtualenv -exceptiongroup==1.1.2 +exceptiongroup==1.2.2 # via pytest filelock==3.12.2 # via virtualenv @@ -58,7 +56,7 @@ identify==2.5.26 # via pre-commit idna==3.8 # via requests -importlib-metadata==8.5.0 +importlib-metadata==8.7.0 # via keyring iniconfig==2.0.0 # via pytest @@ -123,7 +121,7 @@ six==1.16.0 # click-configfile # cloudsmith-api # python-dateutil -tomli==2.0.1 +tomli==2.2.1 # via # build # coverage @@ -133,7 +131,7 @@ tomli==2.0.1 # pytest tomlkit==0.12.1 # via pylint -typing-extensions==4.7.1 +typing-extensions==4.13.2 # via # astroid # pylint @@ -148,7 +146,7 @@ wheel==0.41.1 # via pip-tools wrapt==1.15.0 # via astroid -zipp==3.20.2 +zipp==3.21.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/setup.py b/setup.py index 07a7481b..4bba7796 100644 --- a/setup.py +++ b/setup.py @@ -53,7 +53,7 @@ def get_long_description(): "click-configfile>=0.2.3", "click-didyoumean>=0.0.3", "click-spinner>=0.1.7", - "cloudsmith-api>=2.0.17,<3.0", # Compatible upto (but excluding) 3.0+ + "cloudsmith-api>=2.0.18,<3.0", # Compatible upto (but excluding) 3.0+ "keyring>=25.4.1", "requests>=2.18.4", "requests_toolbelt>=0.8.0", From 17c0cdc0a7ed4e0d21cea2e6d15cb600f656e2ce Mon Sep 17 00:00:00 2001 From: Martin Hutchings Date: Wed, 30 Apr 2025 15:29:46 +0100 Subject: [PATCH 13/18] Update code to use the new api bindings --- cloudsmith_cli/cli/commands/auth.py | 20 +++++++++--------- cloudsmith_cli/cli/commands/tokens.py | 16 +++++++------- cloudsmith_cli/core/api/user.py | 30 +++++++-------------------- 3 files changed, 25 insertions(+), 41 deletions(-) diff --git a/cloudsmith_cli/cli/commands/auth.py b/cloudsmith_cli/cli/commands/auth.py index e4fed687..528d3991 100644 --- a/cloudsmith_cli/cli/commands/auth.py +++ b/cloudsmith_cli/cli/commands/auth.py @@ -77,12 +77,12 @@ def authenticate(ctx, opts, owner, token): try: api_token = user.create_user_token_saml() click.echo( - f"New token value: {click.style(api_token['key'], fg='magenta')}" + f"New token value: {click.style(api_token.key, fg='magenta')}" ) create, has_errors = create_config_files( - ctx, opts, api_key=api_token["key"] + ctx, opts, api_key=api_token.key ) - new_config_messaging(has_errors, opts, create, api_key=api_token["key"]) + new_config_messaging(has_errors, opts, create, api_key=api_token.key) return except exceptions.ApiException as exc: if exc.status == 400: @@ -100,9 +100,9 @@ def authenticate(ctx, opts, owner, token): for t in api_tokens: click.echo("Current tokens:") click.echo( - f"Token: {click.style(t['key'], fg='magenta')}, " - f"Created: {click.style(t['created'], fg='green')}, " - f"slug_perm: {click.style(t['slug_perm'], fg='cyan')}" + f"Token: {click.style(t.key, fg='magenta')}, " + f"Created: {click.style(t.created, fg='green')}, " + f"slug_perm: {click.style(t.slug_perm, fg='cyan')}" ) token_slug = click.prompt( "Please enter the slug_perm of the token you would like to refresh" @@ -111,8 +111,8 @@ def authenticate(ctx, opts, owner, token): click.echo(f"Refreshing token {token_slug}... ", nl=False) with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg): with maybe_spinner(opts): - new_token = user.refresh_user_token("token_slug") + new_token = user.refresh_user_token(token_slug) click.secho("OK", fg="green") - click.echo(f"New token value: {click.style(new_token['key'], fg='magenta')}") - create, has_errors = create_config_files(ctx, opts, api_key=new_token["key"]) - new_config_messaging(has_errors, opts, create, api_key=new_token["key"]) + click.echo(f"New token value: {click.style(new_token.key, fg='magenta')}") + create, has_errors = create_config_files(ctx, opts, api_key=new_token.key) + new_config_messaging(has_errors, opts, create, api_key=new_token.key) diff --git a/cloudsmith_cli/cli/commands/tokens.py b/cloudsmith_cli/cli/commands/tokens.py index 1b414af4..b7da6863 100644 --- a/cloudsmith_cli/cli/commands/tokens.py +++ b/cloudsmith_cli/cli/commands/tokens.py @@ -38,9 +38,9 @@ def list_tokens(ctx, opts): for token in tokens: click.echo( - f"Token: {click.style(token['key'], fg='magenta')}, " - f"Created: {click.style(token['created'], fg='green')}, " - f"slug_perm: {click.style(token['slug_perm'], fg='cyan')}" + f"Token: {click.style(token.key, fg='magenta')}, " + f"Created: {click.style(token.created, fg='green')}, " + f"slug_perm: {click.style(token.slug_perm, fg='cyan')}" ) @@ -62,12 +62,12 @@ def refresh(ctx, opts, token_slug): with handle_api_exceptions(ctx, opts=opts, context_msg=context_msg): with maybe_spinner(opts): api_tokens = api.list_user_tokens() + click.echo("Current tokens:") for t in api_tokens: - click.echo("Current tokens:") click.echo( - f"Token: {click.style(t['key'], fg='magenta')}, " - f"Created: {click.style(t['created'], fg='green')}, " - f"slug_perm: {click.style(t['slug_perm'], fg='cyan')}" + f"Token: {click.style(t.key, fg='magenta')}, " + f"Created: {click.style(t.created, fg='green')}, " + f"slug_perm: {click.style(t.slug_perm, fg='cyan')}" ) token_slug = click.prompt( "Please enter the slug_perm of the token you would like to refresh" @@ -82,4 +82,4 @@ def refresh(ctx, opts, token_slug): if maybe_print_as_json(opts, new_token): return - click.echo(f"New token value: {click.style(new_token['key'], fg='magenta')}") + click.echo(f"New token value: {click.style(new_token.key, fg='magenta')}") diff --git a/cloudsmith_cli/core/api/user.py b/cloudsmith_cli/core/api/user.py index a870da75..3eb77e8a 100644 --- a/cloudsmith_cli/core/api/user.py +++ b/cloudsmith_cli/core/api/user.py @@ -57,15 +57,10 @@ def get_user_token(login, password, totp_token=None, two_factor_token=None): def create_user_token_saml() -> dict: """Create a new user API token using SAML.""" - client = get_api_client(BaseApi) + client = get_user_api() with catch_raise_api_exception(): - data, _, headers = client.api_client.call_api( - "/user/tokens/", - "POST", - auth_settings=["apikey"], - response_type="object", - ) + data, _, headers = client.user_tokens_create_with_http_info() ratelimits.maybe_rate_limit(client, headers) return data @@ -84,31 +79,20 @@ def get_user_brief(): def list_user_tokens() -> list[dict]: """List all user API tokens.""" - client = get_api_client(BaseApi) + client = get_user_api() with catch_raise_api_exception(): - data, _, headers = client.api_client.call_api( - "/user/tokens/", - "GET", - auth_settings=["apikey", "basic"], - response_type="object", - ) + data, _, headers = client.user_tokens_list_with_http_info() ratelimits.maybe_rate_limit(client, headers) - return data["results"] - + return data.results def refresh_user_token(token_slug: str) -> dict: """Refresh user API token.""" - client = get_api_client(BaseApi) + client = get_user_api() with catch_raise_api_exception(): - data, _, headers = client.api_client.call_api( - f"/user/tokens/{token_slug}/refresh/", - "PUT", - auth_settings=["apikey", "basic"], - response_type="object", - ) + data, _, headers = client.user_tokens_refresh_with_http_info(token_slug) ratelimits.maybe_rate_limit(client, headers) return data From da7588159e189eb2c656fe1ec6484d0290ea7dfc Mon Sep 17 00:00:00 2001 From: Martin Hutchings Date: Wed, 30 Apr 2025 15:30:36 +0100 Subject: [PATCH 14/18] Add test for tokens --- .../cli/tests/commands/test_tokens.py | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 cloudsmith_cli/cli/tests/commands/test_tokens.py diff --git a/cloudsmith_cli/cli/tests/commands/test_tokens.py b/cloudsmith_cli/cli/tests/commands/test_tokens.py new file mode 100644 index 00000000..91683627 --- /dev/null +++ b/cloudsmith_cli/cli/tests/commands/test_tokens.py @@ -0,0 +1,107 @@ +from unittest.mock import patch + +import pytest + +from cloudsmith_cli.cli.commands.tokens import list_tokens, refresh +from cloudsmith_cli.core.api.exceptions import ApiException + + +class MockToken: + """Mock Token object with the properties needed for testing.""" + def __init__(self, key, created, slug_perm): + self.key = key + self.created = created + self.slug_perm = slug_perm + + +@pytest.mark.usefixtures("set_api_host_env_var") +class TestListTokensCommand: + + def test_list_tokens_success(self, runner): + """Test successful listing of tokens.""" + mock_tokens = [ + MockToken( + key="abc123", + created="2025-01-01T00:00:00Z", + slug_perm="token-1" + ), + MockToken( + key="def456", + created="2025-01-02T00:00:00Z", + slug_perm="token-2" + ) + ] + + with patch("cloudsmith_cli.core.api.user.list_user_tokens") as mock_list_tokens: + mock_list_tokens.return_value = mock_tokens + result = runner.invoke(list_tokens, [], catch_exceptions=False) + + assert result.exit_code == 0 + assert "Retrieving API tokens... OK" in result.output + assert "Token: abc123" in result.output + assert "Created: 2025-01-01T00:00:00Z" in result.output + assert "slug_perm: token-1" in result.output + assert "Token: def456" in result.output + assert "Created: 2025-01-02T00:00:00Z" in result.output + assert "slug_perm: token-2" in result.output + + def test_list_tokens_error(self, runner): + """Test error handling when listing tokens fails.""" + with patch("cloudsmith_cli.core.api.user.list_user_tokens") as mock_list_tokens: + # Use ApiException for proper error handling + mock_list_tokens.side_effect = ApiException("API error") + result = runner.invoke(list_tokens, [], catch_exceptions=True) + + assert result.exit_code != 0 + + # The error message might be in different places depending on how the exception is raised + error_content = str(getattr(result, 'exception', '')) + result.output + result.stderr + assert "API error" in error_content or "Failed to retrieve API tokens" in error_content + + +@pytest.mark.usefixtures("set_api_host_env_var") +class TestRefreshTokenCommand: + """Test suite for the 'tokens refresh' command.""" + + def test_refresh_token_with_slug(self, runner): + """Test successful refreshing of a token with a provided slug.""" + mock_new_token = MockToken( + key="new_token_123", + created="2025-01-03T00:00:00Z", + slug_perm="token-refresh" + ) + + with patch("cloudsmith_cli.core.api.user.refresh_user_token") as mock_refresh_token: + mock_refresh_token.return_value = mock_new_token + result = runner.invoke(refresh, ["token-refresh"], catch_exceptions=False) + + assert result.exit_code == 0 + assert "Refreshing token token-refresh... OK" in result.output + assert "New token value: new_token_123" in result.output + mock_refresh_token.assert_called_once_with("token-refresh") + + def test_refresh_token_error(self, runner): + """Test error handling when refreshing a token fails.""" + with patch("cloudsmith_cli.core.api.user.refresh_user_token") as mock_refresh_token: + # Use ApiException for proper error handling + mock_refresh_token.side_effect = ApiException("API error") + result = runner.invoke(refresh, ["token-error"], catch_exceptions=True) + + assert result.exit_code != 0 + + # The error message might be in different places depending on how the exception is raised + error_content = str(getattr(result, 'exception', '')) + result.output + result.stderr + assert "API error" in error_content or "Failed to refresh the token" in error_content + + def test_refresh_token_list_error(self, runner): + """Test error handling when listing tokens fails during refresh.""" + with patch("cloudsmith_cli.core.api.user.list_user_tokens") as mock_list_tokens: + # Use ApiException for proper error handling + mock_list_tokens.side_effect = ApiException("API error") + result = runner.invoke(refresh, [], catch_exceptions=True) + + assert result.exit_code != 0 + + # The error message might be in different places depending on how the exception is raised + error_content = str(getattr(result, 'exception', '')) + result.output + result.stderr + assert "API error" in error_content or "Failed to refresh the token" in error_content From dc6fb7f4e60d131bd7a55c348ee6c0a126e4f788 Mon Sep 17 00:00:00 2001 From: Martin Hutchings Date: Wed, 30 Apr 2025 15:46:27 +0100 Subject: [PATCH 15/18] fix failing required steps --- cloudsmith_cli/cli/commands/auth.py | 8 +--- .../cli/tests/commands/test_tokens.py | 48 ++++++++++++------- cloudsmith_cli/core/api/user.py | 3 +- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/cloudsmith_cli/cli/commands/auth.py b/cloudsmith_cli/cli/commands/auth.py index 528d3991..276aefca 100644 --- a/cloudsmith_cli/cli/commands/auth.py +++ b/cloudsmith_cli/cli/commands/auth.py @@ -76,12 +76,8 @@ def authenticate(ctx, opts, owner, token): initialise_api() try: api_token = user.create_user_token_saml() - click.echo( - f"New token value: {click.style(api_token.key, fg='magenta')}" - ) - create, has_errors = create_config_files( - ctx, opts, api_key=api_token.key - ) + click.echo(f"New token value: {click.style(api_token.key, fg='magenta')}") + create, has_errors = create_config_files(ctx, opts, api_key=api_token.key) new_config_messaging(has_errors, opts, create, api_key=api_token.key) return except exceptions.ApiException as exc: diff --git a/cloudsmith_cli/cli/tests/commands/test_tokens.py b/cloudsmith_cli/cli/tests/commands/test_tokens.py index 91683627..0954407f 100644 --- a/cloudsmith_cli/cli/tests/commands/test_tokens.py +++ b/cloudsmith_cli/cli/tests/commands/test_tokens.py @@ -8,6 +8,7 @@ class MockToken: """Mock Token object with the properties needed for testing.""" + def __init__(self, key, created, slug_perm): self.key = key self.created = created @@ -21,15 +22,11 @@ def test_list_tokens_success(self, runner): """Test successful listing of tokens.""" mock_tokens = [ MockToken( - key="abc123", - created="2025-01-01T00:00:00Z", - slug_perm="token-1" + key="abc123", created="2025-01-01T00:00:00Z", slug_perm="token-1" ), MockToken( - key="def456", - created="2025-01-02T00:00:00Z", - slug_perm="token-2" - ) + key="def456", created="2025-01-02T00:00:00Z", slug_perm="token-2" + ), ] with patch("cloudsmith_cli.core.api.user.list_user_tokens") as mock_list_tokens: @@ -55,8 +52,13 @@ def test_list_tokens_error(self, runner): assert result.exit_code != 0 # The error message might be in different places depending on how the exception is raised - error_content = str(getattr(result, 'exception', '')) + result.output + result.stderr - assert "API error" in error_content or "Failed to retrieve API tokens" in error_content + error_content = ( + str(getattr(result, "exception", "")) + result.output + result.stderr + ) + assert ( + "API error" in error_content + or "Failed to retrieve API tokens" in error_content + ) @pytest.mark.usefixtures("set_api_host_env_var") @@ -68,10 +70,12 @@ def test_refresh_token_with_slug(self, runner): mock_new_token = MockToken( key="new_token_123", created="2025-01-03T00:00:00Z", - slug_perm="token-refresh" + slug_perm="token-refresh", ) - with patch("cloudsmith_cli.core.api.user.refresh_user_token") as mock_refresh_token: + with patch( + "cloudsmith_cli.core.api.user.refresh_user_token" + ) as mock_refresh_token: mock_refresh_token.return_value = mock_new_token result = runner.invoke(refresh, ["token-refresh"], catch_exceptions=False) @@ -82,7 +86,9 @@ def test_refresh_token_with_slug(self, runner): def test_refresh_token_error(self, runner): """Test error handling when refreshing a token fails.""" - with patch("cloudsmith_cli.core.api.user.refresh_user_token") as mock_refresh_token: + with patch( + "cloudsmith_cli.core.api.user.refresh_user_token" + ) as mock_refresh_token: # Use ApiException for proper error handling mock_refresh_token.side_effect = ApiException("API error") result = runner.invoke(refresh, ["token-error"], catch_exceptions=True) @@ -90,8 +96,13 @@ def test_refresh_token_error(self, runner): assert result.exit_code != 0 # The error message might be in different places depending on how the exception is raised - error_content = str(getattr(result, 'exception', '')) + result.output + result.stderr - assert "API error" in error_content or "Failed to refresh the token" in error_content + error_content = ( + str(getattr(result, "exception", "")) + result.output + result.stderr + ) + assert ( + "API error" in error_content + or "Failed to refresh the token" in error_content + ) def test_refresh_token_list_error(self, runner): """Test error handling when listing tokens fails during refresh.""" @@ -103,5 +114,10 @@ def test_refresh_token_list_error(self, runner): assert result.exit_code != 0 # The error message might be in different places depending on how the exception is raised - error_content = str(getattr(result, 'exception', '')) + result.output + result.stderr - assert "API error" in error_content or "Failed to refresh the token" in error_content + error_content = ( + str(getattr(result, "exception", "")) + result.output + result.stderr + ) + assert ( + "API error" in error_content + or "Failed to refresh the token" in error_content + ) diff --git a/cloudsmith_cli/core/api/user.py b/cloudsmith_cli/core/api/user.py index 3eb77e8a..b42ae401 100644 --- a/cloudsmith_cli/core/api/user.py +++ b/cloudsmith_cli/core/api/user.py @@ -7,7 +7,7 @@ from .. import ratelimits from .exceptions import TwoFactorRequiredException, catch_raise_api_exception -from .init import BaseApi, get_api_client, unset_api_key +from .init import get_api_client, unset_api_key def get_user_api(): @@ -87,6 +87,7 @@ def list_user_tokens() -> list[dict]: ratelimits.maybe_rate_limit(client, headers) return data.results + def refresh_user_token(token_slug: str) -> dict: """Refresh user API token.""" client = get_user_api() From 1a0c8c5d79dfd396847d7a578f9d363827adf43d Mon Sep 17 00:00:00 2001 From: Martin Hutchings Date: Wed, 30 Apr 2025 22:57:26 +0100 Subject: [PATCH 16/18] PR comments --- cloudsmith_cli/core/api/init.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/cloudsmith_cli/core/api/init.py b/cloudsmith_cli/core/api/init.py index fd9bb80e..dd7bd2ac 100644 --- a/cloudsmith_cli/core/api/init.py +++ b/cloudsmith_cli/core/api/init.py @@ -12,14 +12,6 @@ from .exceptions import ApiException -class BaseApi: - def __init__(self, api_client=None): - if api_client is None: - api_client = cloudsmith_api.ApiClient() - self.api_client = api_client - self.config = cloudsmith_api.Configuration() - - def initialise_api( debug=False, host=None, From 1a6629e1db0b17d2eecc92bfd42c36252a0d835d Mon Sep 17 00:00:00 2001 From: Martin Hutchings Date: Thu, 1 May 2025 12:11:57 +0100 Subject: [PATCH 17/18] PR comments --- cloudsmith_cli/cli/commands/tokens.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/cloudsmith_cli/cli/commands/tokens.py b/cloudsmith_cli/cli/commands/tokens.py index b7da6863..480a1f06 100644 --- a/cloudsmith_cli/cli/commands/tokens.py +++ b/cloudsmith_cli/cli/commands/tokens.py @@ -36,12 +36,7 @@ def list_tokens(ctx, opts): if maybe_print_as_json(opts, tokens): return - for token in tokens: - click.echo( - f"Token: {click.style(token.key, fg='magenta')}, " - f"Created: {click.style(token.created, fg='green')}, " - f"slug_perm: {click.style(token.slug_perm, fg='cyan')}" - ) + print_tokens(tokens) @tokens.command() @@ -63,12 +58,7 @@ def refresh(ctx, opts, token_slug): with maybe_spinner(opts): api_tokens = api.list_user_tokens() click.echo("Current tokens:") - for t in api_tokens: - click.echo( - f"Token: {click.style(t.key, fg='magenta')}, " - f"Created: {click.style(t.created, fg='green')}, " - f"slug_perm: {click.style(t.slug_perm, fg='cyan')}" - ) + print_tokens(api_tokens) token_slug = click.prompt( "Please enter the slug_perm of the token you would like to refresh" ) @@ -83,3 +73,12 @@ def refresh(ctx, opts, token_slug): return click.echo(f"New token value: {click.style(new_token.key, fg='magenta')}") + + +def print_tokens(tokens): + for token in tokens: + click.echo( + f"Token: {click.style(token.key, fg='magenta')}, " + f"Created: {click.style(token.created, fg='green')}, " + f"slug_perm: {click.style(token.slug_perm, fg='cyan')}" + ) From dbc3ee239e04d698fa28279b571d7fa8c4026ac7 Mon Sep 17 00:00:00 2001 From: Martin Hutchings Date: Thu, 1 May 2025 14:20:54 +0100 Subject: [PATCH 18/18] Update README --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 571dc5db..4b85905d 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,9 @@ The CLI currently supports the following commands (and sub-commands): - `remove`|`rm`: Remove tags from a package in a repository. - `replace`: Replace all existing (non-immutable) tags on a package in a repository. - `whoami`: Retrieve your current authentication status. - +- `tokens`: Manage API tokens. + - `list`|`ls`: List API tokens. + - `refresh`: Refresh an API token. ## Installation