diff --git a/clarifai/cli/app.py b/clarifai/cli/app.py new file mode 100644 index 00000000..505240f3 --- /dev/null +++ b/clarifai/cli/app.py @@ -0,0 +1,85 @@ +import shutil + +import click + +from clarifai.cli.base import cli +from clarifai.utils.cli import AliasedGroup, validate_context + + +@cli.group( + ['app', 'a'], + cls=AliasedGroup, + context_settings={'max_content_width': shutil.get_terminal_size().columns - 10}, +) +def app(): + """Manage Apps: create, delete, list""" + + +@app.command(['c']) +@click.argument('app_id') +@click.option( + '--base-workflow', + default='Empty', + help='Base workflow to use for the app. Examples: Universal, Language-Understanding, General', +) +@click.pass_context +def create(ctx, app_id, base_workflow): + """Create a new App with the given app ID.""" + from clarifai.client.user import User + + validate_context(ctx) + user = User( + user_id=ctx.obj.current.user_id, pat=ctx.obj.current.pat, base_url=ctx.obj.current.api_base + ) + user.create_app(app_id=app_id, base_workflow=base_workflow) + click.echo(f"App '{app_id}' created successfully.") + + +@app.command(['ls']) +@click.option('--page_no', required=False, help='Page number to list.', default=1) +@click.option('--per_page', required=False, help='Number of items per page.', default=16) +@click.option( + '--user_id', + required=False, + help='User ID to list apps for (defaults to current user).', + default=None, +) +@click.pass_context +def list(ctx, page_no, per_page, user_id): + """List all apps for the user.""" + from clarifai.client.user import User + + validate_context(ctx) + # Use provided user_id or fall back to current context's user_id + target_user_id = user_id if user_id else ctx.obj.current.user_id + user = User(user_id=target_user_id, pat=ctx.obj.current.pat, base_url=ctx.obj.current.api_base) + apps = [app for app in user.list_apps(page_no=page_no, per_page=per_page)] + + if not apps: + click.echo("No apps found.") + return + + # Display apps in a simple table + from tabulate import tabulate + + rows = [] + for app in apps: + rows.append([app.user_id, app.id]) + + table = tabulate(rows, headers=["User ID", "App ID"], tablefmt="plain") + click.echo(table) + + +@app.command(['rm']) +@click.argument('app_id') +@click.pass_context +def delete(ctx, app_id): + """Deletes an app for the user.""" + from clarifai.client.user import User + + validate_context(ctx) + user = User( + user_id=ctx.obj.current.user_id, pat=ctx.obj.current.pat, base_url=ctx.obj.current.api_base + ) + user.delete_app(app_id) + click.echo(f"App '{app_id}' deleted successfully.") diff --git a/clarifai/cli/artifact.py b/clarifai/cli/artifact.py index 1c8aa7ab..6c34787a 100644 --- a/clarifai/cli/artifact.py +++ b/clarifai/cli/artifact.py @@ -5,12 +5,9 @@ from typing import Dict, Optional import click -from clarifai_grpc.grpc.api import resources_pb2 from google.protobuf.timestamp_pb2 import Timestamp from clarifai.cli.base import cli -from clarifai.client.artifact import Artifact -from clarifai.client.artifact_version import ArtifactVersion from clarifai.constants.artifact import ( ARTIFACT_VISIBILITY_ORG, ARTIFACT_VISIBILITY_PRIVATE, @@ -139,6 +136,8 @@ def _upload_artifact(source_path: str, parsed_destination: dict, client_kwargs: Returns: ArtifactVersion: The created artifact version """ + from clarifai.client.artifact_version import ArtifactVersion + user_id = parsed_destination['user_id'] app_id = parsed_destination['app_id'] artifact_id = parsed_destination['artifact_id'] # Now required @@ -177,6 +176,10 @@ def _download_artifact( Returns: str: The path where file was downloaded """ + + from clarifai.client.artifact import Artifact + from clarifai.client.artifact_version import ArtifactVersion + user_id = parsed_source['user_id'] app_id = parsed_source['app_id'] artifact_id = parsed_source['artifact_id'] @@ -229,6 +232,12 @@ def list(ctx, path, versions): clarifai af list users/u/apps/a clarifai af list users/u/apps/a/artifacts/my-artifact --versions """ + + from clarifai_grpc.grpc.api import resources_pb2 + + from clarifai.client.artifact import Artifact + from clarifai.client.artifact_version import ArtifactVersion + try: validate_context(ctx) @@ -327,6 +336,11 @@ def get(ctx, path): clarifai af get users/u/apps/a/artifacts/my-artifact clarifai af get users/u/apps/a/artifacts/my-artifact/versions/v123 """ + from clarifai_grpc.grpc.api import resources_pb2 + + from clarifai.client.artifact import Artifact + from clarifai.client.artifact_version import ArtifactVersion + try: validate_context(ctx) parsed = _parse_and_validate_path(path) @@ -399,6 +413,9 @@ def delete(ctx, path, force): clarifai af rm users/u/apps/a/artifacts/my-artifact/versions/v123 clarifai af rm users/u/apps/a/artifacts/my-artifact --force """ + from clarifai.client.artifact import Artifact + from clarifai.client.artifact_version import ArtifactVersion + try: validate_context(ctx) parsed = _parse_and_validate_path(path) @@ -484,6 +501,7 @@ def cp( clarifai af cp users/u/apps/a/artifacts/my-artifact/versions/v123 /tmp/ clarifai af cp users/u/apps/a/artifacts/my-artifact . """ + try: validate_context(ctx) diff --git a/clarifai/cli/base.py b/clarifai/cli/base.py index 59479810..98b04e88 100644 --- a/clarifai/cli/base.py +++ b/clarifai/cli/base.py @@ -6,13 +6,17 @@ import yaml from clarifai import __version__ -from clarifai.utils.cli import AliasedGroup, TableFormatter, load_command_modules, masked_input +from clarifai.utils.cli import ( + LazyAliasedGroup, + TableFormatter, + masked_input, +) from clarifai.utils.config import Config, Context from clarifai.utils.constants import DEFAULT_BASE, DEFAULT_CONFIG, DEFAULT_UI from clarifai.utils.logging import logger -@click.group(cls=AliasedGroup) +@click.group(cls=LazyAliasedGroup) @click.version_option(version=__version__) @click.option('--config', default=DEFAULT_CONFIG, help='Path to config file') @click.option('--context', default=None, help='Context to use for this command') @@ -58,7 +62,7 @@ def shell_completion(shell): os.system(f"_CLARIFAI_COMPLETE={shell}_source clarifai") -@cli.group(cls=AliasedGroup) +@cli.group(cls=LazyAliasedGroup) def config(): """ Manage multiple configuration profiles (contexts). @@ -120,6 +124,54 @@ def login(ctx, api_url, user_id): logger.info(f"Login successful for user '{user_id}' in context '{context_name}'") +@cli.command() +@click.pass_context +def whoami(ctx): + """Display information about the current user.""" + from clarifai_grpc.grpc.api.status import status_code_pb2 + + from clarifai.client.user import User + + # Get the current context + cfg = ctx.obj + current_ctx = cfg.contexts[cfg.current_context] + + # Get user_id from context + context_user_id = current_ctx.CLARIFAI_USER_ID + pat = current_ctx.CLARIFAI_PAT + base_url = current_ctx.CLARIFAI_API_BASE + + # Display context user info + click.echo("Context User ID: " + click.style(context_user_id, fg='cyan', bold=True)) + + # Call GetUser RPC with "me" to get the actual authenticated user + try: + user_client = User(user_id="me", pat=pat, base_url=base_url) + response = user_client.get_user_info(user_id="me") + + if response.status.code == status_code_pb2.SUCCESS: + actual_user_id = response.user.id + click.echo( + "Authenticated User ID: " + click.style(actual_user_id, fg='green', bold=True) + ) + + # Check if they differ + if context_user_id != actual_user_id: + click.echo() + click.secho( + "��️ Warning: The context user ID differs from the authenticated user ID!", + fg='yellow', + ) + click.echo( + "This means you as the caller will be calling different user or organization." + ) + else: + click.secho(f"Error getting user info: {response.status.description}", fg='red') + + except Exception as e: + click.secho(f"Error: Could not retrieve authenticated user info: {str(e)}", fg='red') + + def _warn_env_pat(): """Warn if CLARIFAI_PAT environment variable is still set.""" if os.environ.get('CLARIFAI_PAT'): @@ -539,7 +591,7 @@ def run(ctx, script, context=None): # Import the CLI commands to register them -load_command_modules() +# load_command_modules() - Now handled lazily by LazyLazyAliasedGroupp def main(): diff --git a/clarifai/cli/pipeline_template.py b/clarifai/cli/pipeline_template.py index 1cdc8995..773b1b12 100644 --- a/clarifai/cli/pipeline_template.py +++ b/clarifai/cli/pipeline_template.py @@ -7,7 +7,6 @@ from clarifai.cli.base import cli from clarifai.utils.cli import AliasedGroup, display_co_resources from clarifai.utils.logging import logger -from clarifai.utils.template_manager import TemplateManager @cli.group( @@ -37,6 +36,8 @@ def list_templates(template_type): clarifai pipelinetemplate ls --type=train # List only training templates clarifai pipelinetemplate ls --type=data # List only data processing templates """ + from clarifai.utils.template_manager import TemplateManager + try: template_manager = TemplateManager() templates = template_manager.list_templates(template_type) @@ -102,6 +103,8 @@ def info(template_name): clarifai pipelinetemplate info image-classification clarifai pipelinetemplate info text-prep """ + from clarifai.utils.template_manager import TemplateManager + try: template_manager = TemplateManager() template_info = template_manager.get_template_info(template_name) diff --git a/clarifai/utils/cli.py b/clarifai/utils/cli.py index 846f4259..650ba625 100644 --- a/clarifai/utils/cli.py +++ b/clarifai/utils/cli.py @@ -326,6 +326,49 @@ def format_commands(self, ctx, formatter): formatter.write_dl(rows) +class LazyAliasedGroup(AliasedGroup): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.lazy_mapping = { + 'app': 'app', + 'a': 'app', + 'artifact': 'artifact', + 'af': 'artifact', + 'computecluster': 'compute_cluster', + 'cc': 'compute_cluster', + 'deployment': 'deployment', + 'dp': 'deployment', + 'model': 'model', + 'nodepool': 'nodepool', + 'np': 'nodepool', + 'pipeline': 'pipeline', + 'pl': 'pipeline', + 'pipelinerun': 'pipeline_run', + 'pr': 'pipeline_run', + 'pipelinestep': 'pipeline_step', + 'ps': 'pipeline_step', + 'pipelinetemplate': 'pipeline_template', + 'pt': 'pipeline_template', + } + + def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]: + cmd = super().get_command(ctx, cmd_name) + if cmd is not None: + return cmd + + if cmd_name in self.lazy_mapping: + module_name = self.lazy_mapping[cmd_name] + importlib.import_module(f'clarifai.cli.{module_name}') + return super().get_command(ctx, cmd_name) + + return None + + def list_commands(self, ctx: click.Context) -> t.List[str]: + base_commands = super().list_commands(ctx) + lazy_commands = [k for k in self.lazy_mapping.keys()] + return sorted(set(base_commands) | set(lazy_commands)) + + def validate_context(ctx): from clarifai.utils.logging import logger diff --git a/tests/cli/test_artifact_cli.py b/tests/cli/test_artifact_cli.py index 94558cc5..aafb7958 100644 --- a/tests/cli/test_artifact_cli.py +++ b/tests/cli/test_artifact_cli.py @@ -115,7 +115,7 @@ def setup_method(self): self.runner = CliRunner() @patch('clarifai.cli.artifact.validate_context') - @patch('clarifai.cli.artifact.Artifact') + @patch('clarifai.client.artifact.Artifact') def test_list_command_success(self, mock_artifact_class, mock_validate): """Test successful list command.""" mock_validate.return_value = None @@ -149,7 +149,7 @@ def test_list_command_missing_params(self, mock_validate): assert "Missing argument 'PATH'" in result.output @patch('clarifai.cli.artifact.validate_context') - @patch('clarifai.cli.artifact.ArtifactVersion') + @patch('clarifai.client.artifact_version.ArtifactVersion') def test_list_versions_command(self, mock_artifact_version_class, mock_validate): """Test list versions command.""" mock_validate.return_value = None @@ -178,7 +178,7 @@ def test_list_versions_command(self, mock_artifact_version_class, mock_validate) @patch('clarifai.cli.artifact.validate_context') @patch('clarifai.cli.artifact.parse_artifact_path') - @patch('clarifai.cli.artifact.Artifact') + @patch('clarifai.client.artifact.Artifact') def test_get_command_success(self, mock_artifact_class, mock_parse_path, mock_validate): """Test successful get command.""" mock_validate.return_value = None @@ -221,7 +221,7 @@ def test_get_command_missing_params(self, mock_validate): @patch('clarifai.cli.artifact.validate_context') @patch('clarifai.cli.artifact.parse_artifact_path') - @patch('clarifai.cli.artifact.Artifact') + @patch('clarifai.client.artifact.Artifact') def test_delete_command_success(self, mock_artifact_class, mock_parse_path, mock_validate): """Test successful delete command.""" mock_validate.return_value = None @@ -265,7 +265,7 @@ def test_delete_command_cancel(self, mock_validate): assert "Operation cancelled" in result.output @patch('clarifai.cli.artifact.validate_context') - @patch('clarifai.cli.artifact.Artifact') + @patch('clarifai.client.artifact.Artifact') def test_delete_command_force(self, mock_artifact_class, mock_validate): """Test delete command with force flag (no confirmation needed).""" mock_obj = setup_context_mock(mock_validate) @@ -289,7 +289,7 @@ def test_delete_command_force(self, mock_artifact_class, mock_validate): mock_artifact_instance.delete.assert_called_once() @patch('clarifai.cli.artifact.validate_context') - @patch('clarifai.cli.artifact.ArtifactVersion') + @patch('clarifai.client.artifact_version.ArtifactVersion') def test_delete_version_command_force(self, mock_artifact_version_class, mock_validate): """Test delete version command with force flag (no confirmation needed).""" mock_obj = setup_context_mock(mock_validate) @@ -377,8 +377,8 @@ def test_cp_command_upload_org_visibility(self, mock_validate, mock_exists, mock assert kwargs.get('visibility') == 'org' @patch('clarifai.cli.artifact.validate_context') - @patch('clarifai.cli.artifact.ArtifactVersion') - @patch('clarifai.cli.artifact.Artifact') + @patch('clarifai.client.artifact_version.ArtifactVersion') + @patch('clarifai.client.artifact.Artifact') def test_cp_command_download_success( self, mock_artifact_class, mock_artifact_version_class, mock_validate ): @@ -463,7 +463,7 @@ def test_instance_reuse_across_operations(self): """Test that CLI operations properly handle instance reuse.""" mock_obj = setup_context_mock(Mock()) - with patch('clarifai.cli.artifact.Artifact') as mock_artifact: + with patch('clarifai.client.artifact.Artifact') as mock_artifact: mock_instance = Mock() mock_artifact.return_value = mock_instance @@ -541,7 +541,7 @@ def setup_method(self): self.runner = CliRunner() @patch('clarifai.cli.artifact.validate_context') - @patch('clarifai.cli.artifact.Artifact') + @patch('clarifai.client.artifact.Artifact') def test_full_workflow_simulation(self, mock_artifact_class, mock_validate): """Test simulated full workflow - just test list command as representative.""" mock_validate.return_value = None @@ -563,7 +563,7 @@ def test_full_workflow_simulation(self, mock_artifact_class, mock_validate): assert result.exit_code == 0 @patch('clarifai.cli.artifact.validate_context') - @patch('clarifai.cli.artifact.Artifact') + @patch('clarifai.client.artifact.Artifact') def test_error_handling(self, mock_artifact_class, mock_validate): """Test CLI error handling.""" mock_validate.return_value = None @@ -591,7 +591,7 @@ def test_error_handling(self, mock_artifact_class, mock_validate): class TestConvenienceFunctions: """Test class for CLI convenience functions.""" - @patch('clarifai.cli.artifact.ArtifactVersion') + @patch('clarifai.client.artifact_version.ArtifactVersion') @patch('os.path.exists', return_value=True) def test_upload_artifact_function(self, mock_exists, mock_artifact_version_class): """Test _upload_artifact convenience function.""" @@ -638,8 +638,8 @@ def test_upload_artifact_function(self, mock_exists, mock_artifact_version_class assert result.version_id == "test_version" - @patch('clarifai.cli.artifact.ArtifactVersion') - @patch('clarifai.cli.artifact.Artifact') + @patch('clarifai.client.artifact_version.ArtifactVersion') + @patch('clarifai.client.artifact.Artifact') def test_download_artifact_function(self, mock_artifact_class, mock_artifact_version_class): """Test _download_artifact convenience function.""" # Mock Artifact instance for getting latest version @@ -698,7 +698,7 @@ def test_download_artifact_function(self, mock_artifact_class, mock_artifact_ver assert result == "/downloaded/path" - @patch('clarifai.cli.artifact.ArtifactVersion') + @patch('clarifai.client.artifact_version.ArtifactVersion') def test_download_artifact_with_specific_version(self, mock_artifact_version_class): """Test _download_artifact with specific version ID.""" # Mock ArtifactVersion instance and its download method diff --git a/tests/cli/test_artifact_version_cli.py b/tests/cli/test_artifact_version_cli.py index 25e9be2e..510f530e 100644 --- a/tests/cli/test_artifact_version_cli.py +++ b/tests/cli/test_artifact_version_cli.py @@ -18,7 +18,7 @@ def setup_method(self): self.runner = CliRunner() @patch('clarifai.cli.artifact.validate_context') - @patch('clarifai.cli.artifact.ArtifactVersion') + @patch('clarifai.client.artifact_version.ArtifactVersion') def test_list_versions_command_success(self, mock_artifact_version_class, mock_validate): """Test successful list versions command.""" mock_obj = setup_context_mock(mock_validate) @@ -51,7 +51,7 @@ def test_list_versions_command_success(self, mock_artifact_version_class, mock_v mock_version_instance.list.assert_called_once() @patch('clarifai.cli.artifact.validate_context') - @patch('clarifai.cli.artifact.ArtifactVersion') + @patch('clarifai.client.artifact_version.ArtifactVersion') def test_get_version_command_success(self, mock_artifact_version_class, mock_validate): """Test successful get version command.""" mock_obj = setup_context_mock(mock_validate) @@ -85,7 +85,7 @@ def test_get_version_command_success(self, mock_artifact_version_class, mock_val mock_version_instance.get.assert_called_once() @patch('clarifai.cli.artifact.validate_context') - @patch('clarifai.cli.artifact.ArtifactVersion') + @patch('clarifai.client.artifact_version.ArtifactVersion') def test_delete_version_command_success(self, mock_artifact_version_class, mock_validate): """Test successful delete version command.""" mock_obj = setup_context_mock(mock_validate) @@ -134,7 +134,7 @@ def test_cp_upload_to_version_success(self, mock_exists, mock_validate): mock_obj = setup_context_mock(mock_validate) mock_exists.return_value = True - with patch('clarifai.cli.artifact.ArtifactVersion') as mock_artifact_version: + with patch('clarifai.client.artifact_version.ArtifactVersion') as mock_artifact_version: mock_instance = Mock() mock_artifact_version.return_value = mock_instance mock_instance.upload.return_value = Mock(id="new_version") @@ -158,7 +158,7 @@ def test_cp_download_specific_version_success(self, mock_validate): """Test successful download of specific version via cp command.""" mock_obj = setup_context_mock(mock_validate) - with patch('clarifai.cli.artifact.ArtifactVersion') as mock_artifact_version: + with patch('clarifai.client.artifact_version.ArtifactVersion') as mock_artifact_version: mock_instance = Mock() mock_artifact_version.return_value = mock_instance mock_instance.download.return_value = "./downloaded_version.txt" @@ -194,7 +194,7 @@ def test_version_error_handling(self, mock_validate): """Test version-specific error handling.""" mock_obj = setup_context_mock(mock_validate) - with patch('clarifai.cli.artifact.ArtifactVersion') as mock_artifact_version: + with patch('clarifai.client.artifact_version.ArtifactVersion') as mock_artifact_version: mock_instance = Mock() mock_artifact_version.return_value = mock_instance @@ -226,7 +226,7 @@ def test_list_versions_empty_result(self, mock_validate): """Test list versions with empty result.""" mock_obj = setup_context_mock(mock_validate) - with patch('clarifai.cli.artifact.ArtifactVersion') as mock_artifact_version: + with patch('clarifai.client.artifact_version.ArtifactVersion') as mock_artifact_version: mock_instance = Mock() mock_artifact_version.return_value = mock_instance mock_instance.list.return_value = [] @@ -262,7 +262,7 @@ def test_upload_large_file_simulation(self, mock_exists, mock_validate): mock_exists.return_value = True with ( - patch('clarifai.cli.artifact.ArtifactVersion') as mock_artifact_version, + patch('clarifai.client.artifact_version.ArtifactVersion') as mock_artifact_version, patch('os.path.getsize') as mock_getsize, ): # Simulate large file (1GB) @@ -290,7 +290,7 @@ def test_download_to_existing_file_no_overwrite(self, mock_validate): mock_obj = setup_context_mock(mock_validate) with ( - patch('clarifai.cli.artifact.ArtifactVersion') as mock_artifact_version, + patch('clarifai.client.artifact_version.ArtifactVersion') as mock_artifact_version, patch('os.path.exists') as mock_exists, ): # Simulate target file exists @@ -319,7 +319,7 @@ def test_version_commands_with_special_characters(self, mock_validate): """Test version commands with special characters in IDs.""" mock_obj = setup_context_mock(mock_validate) - with patch('clarifai.cli.artifact.ArtifactVersion') as mock_artifact_version: + with patch('clarifai.client.artifact_version.ArtifactVersion') as mock_artifact_version: mock_instance = Mock() mock_artifact_version.return_value = mock_instance mock_instance.get.return_value = Mock( @@ -365,7 +365,7 @@ def test_complete_version_lifecycle(self, mock_exists, mock_validate): mock_obj = setup_context_mock(mock_validate) mock_exists.return_value = True - with patch('clarifai.cli.artifact.ArtifactVersion') as mock_artifact_version: + with patch('clarifai.client.artifact_version.ArtifactVersion') as mock_artifact_version: mock_instance = Mock() mock_artifact_version.return_value = mock_instance diff --git a/tests/cli/test_pipeline.py b/tests/cli/test_pipeline.py index 8f6f6f14..1980f7a7 100644 --- a/tests/cli/test_pipeline.py +++ b/tests/cli/test_pipeline.py @@ -1764,7 +1764,7 @@ def test_list_command_default_parameters(self, mock_validate): class TestPipelineTemplateCommands: """Test cases for the pipeline template CLI commands.""" - @patch('clarifai.cli.pipeline_template.TemplateManager') + @patch('clarifai.utils.template_manager.TemplateManager') def test_list_templates_command_all_templates(self, mock_template_manager_class): """Test that list_templates command works without type filter.""" # Mock template manager @@ -1787,7 +1787,7 @@ def test_list_templates_command_all_templates(self, mock_template_manager_class) assert 'template1' in result.output assert 'template2' in result.output - @patch('clarifai.cli.pipeline_template.TemplateManager') + @patch('clarifai.utils.template_manager.TemplateManager') def test_list_templates_command_with_type_filter(self, mock_template_manager_class): """Test that list_templates command works with type filter.""" # Mock template manager @@ -1810,7 +1810,7 @@ def test_list_templates_command_with_type_filter(self, mock_template_manager_cla assert 'train-template1' in result.output assert 'train-template2' in result.output - @patch('clarifai.cli.pipeline_template.TemplateManager') + @patch('clarifai.utils.template_manager.TemplateManager') def test_list_templates_command_empty_list(self, mock_template_manager_class): """Test that list_templates command handles empty template list.""" # Mock template manager to return empty list @@ -1826,7 +1826,7 @@ def test_list_templates_command_empty_list(self, mock_template_manager_class): # Should display message about no templates assert 'No templates found' in result.output or len(result.output.strip()) == 0 - @patch('clarifai.cli.pipeline_template.TemplateManager') + @patch('clarifai.utils.template_manager.TemplateManager') def test_info_command_template_found(self, mock_template_manager_class): """Test that info command works when template exists.""" # Mock template manager @@ -1869,7 +1869,7 @@ def test_info_command_template_found(self, mock_template_manager_class): assert 'EXAMPLE_PATH (default: /default/data/path)' in result.output assert 'EXAMPLE_TYPE (default: default_type)' in result.output - @patch('clarifai.cli.pipeline_template.TemplateManager') + @patch('clarifai.utils.template_manager.TemplateManager') def test_info_command_template_not_found(self, mock_template_manager_class): """Test that info command handles template not found.""" # Mock template manager to return None @@ -1886,7 +1886,7 @@ def test_info_command_template_not_found(self, mock_template_manager_class): # Should display error message assert 'not found' in result.output or 'Error' in result.output - @patch('clarifai.cli.pipeline_template.TemplateManager') + @patch('clarifai.utils.template_manager.TemplateManager') def test_info_command_template_with_no_parameters(self, mock_template_manager_class): """Test that info command handles templates with no parameters.""" # Mock template manager @@ -1921,7 +1921,7 @@ def test_info_command_template_with_no_parameters(self, mock_template_manager_cl or result.output.count('Parameters') <= 1 ) # Just the section header - @patch('clarifai.cli.pipeline_template.TemplateManager') + @patch('clarifai.utils.template_manager.TemplateManager') def test_list_templates_command_with_rich_display(self, mock_template_manager_class): """Test that list_templates displays templates in a formatted table.""" # Mock template manager diff --git a/tests/client/test_config.py b/tests/client/test_config.py new file mode 100644 index 00000000..e58ed38a --- /dev/null +++ b/tests/client/test_config.py @@ -0,0 +1,226 @@ +import os +import tempfile +import unittest +from unittest.mock import patch + +from clarifai.utils.config import Config, Context + + +class TestConfig(unittest.TestCase): + def setUp(self): + # Create a temporary config file for testing + self.temp_config = tempfile.NamedTemporaryFile(delete=False) + self.temp_config.write(b""" +current_context: test_context +contexts: + test_context: + env: + CLARIFAI_PAT: "config_pat" + CLARIFAI_UI: "config_ui" + api_base: "config_api_base" + pat: "direct_pat" + ui: "direct_ui" +""") + self.temp_config.close() + + def tearDown(self): + # Clean up the temporary config file + os.unlink(self.temp_config.name) + + def test_config_from_yaml(self): + config = Config.from_yaml(self.temp_config.name) + self.assertEqual(config.current_context, "test_context") + self.assertIn("test_context", config.contexts) + + @patch.dict('os.environ', {"CLARIFAI_PAT": "env_pat", "CLARIFAI_UI": "env_ui"}) + def test_env_var_override(self): + # Set environment variables that should override config values + config = Config.from_yaml(self.temp_config.name) + context = config.current + + # Check that environment variables override config values + self.assertEqual(context.pat, "env_pat") + self.assertEqual(context.ui, "env_ui") + + @patch.dict('os.environ', {}, clear=True) + def test_direct_access(self): + config = Config.from_yaml(self.temp_config.name) + context = config.current + + # Test direct access to config values + self.assertEqual(context.pat, "config_pat") + self.assertEqual(context.ui, "config_ui") + + @patch.dict('os.environ', {"CLARIFAI_PAT": "env_pat"}) + def test_envvar_fallback(self): + # Create a config with ENVVAR placeholder + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(b""" +current_context: test_context +contexts: + test_context: + env: + CLARIFAI_PAT: "ENVVAR" +""") + temp_file_path = temp_file.name + + config = Config.from_yaml(temp_file_path) + context = config.current + + # Check that ENVVAR is replaced with the actual environment variable + self.assertEqual(context.pat, "env_pat") + + # Clean up + os.unlink(temp_file_path) + + def test_attribute_error(self): + config = Config.from_yaml(self.temp_config.name) + context = config.current + + # Test that accessing a non-existent attribute raises AttributeError + with self.assertRaises(AttributeError): + _ = context.nonexistent + + def test_set_to_env(self): + # Create a config with a custom value + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(b""" +current_context: test_context +contexts: + test_context: + env: + CLARIFAI_CUSTOM: "custom_value" +""") + temp_file_path = temp_file.name + + config = Config.from_yaml(temp_file_path) + context = config.current + + # Set the environment variables from the config + context.set_to_env() + + # Check that the environment variable was set correctly + self.assertEqual(os.environ["CLARIFAI_CUSTOM"], "custom_value") + + # Clean up + del os.environ["CLARIFAI_CUSTOM"] + os.unlink(temp_file_path) + + @patch.dict('os.environ', {}, clear=True) + def test_default_fallbacks(self): + # Create a config with no UI or API_BASE values + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(b""" +current_context: test_context +contexts: + test_context: + env: {} +""") + temp_file_path = temp_file.name + + config = Config.from_yaml(temp_file_path) + context = config.current + + # Check that default values are used + self.assertEqual(context.ui, "https://clarifai.com") + self.assertEqual(context.api_base, "https://api.clarifai.com") + + # Clean up + os.unlink(temp_file_path) + + @patch.dict('os.environ', {}, clear=True) + def test_context_creation(self): + # Test creating a context directly + context = Context("test_context", CLARIFAI_PAT="context_pat", CLARIFAI_UI="context_ui") + + self.assertEqual(context.name, "test_context") + self.assertEqual(context.pat, "context_pat") + self.assertEqual(context.ui, "context_ui") + + def test_env_var_name_access(self): + # Test accessing config values using their environment variable names + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(b""" +current_context: test_context +contexts: + test_context: + env: + CLARIFAI_PAT: "config_pat" + CLARIFAI_UI: "config_ui" +""") + temp_file_path = temp_file.name + + config = Config.from_yaml(temp_file_path) + context = config.current + + # Test accessing values using environment variable names + self.assertEqual(context.CLARIFAI_PAT, "config_pat") + self.assertEqual(context.CLARIFAI_UI, "config_ui") + + # Clean up + os.unlink(temp_file_path) + + @patch.dict('os.environ', clear=True) + def test_shorthand_and_env_var_access(self): + # Create a config with both shorthand and full environment variable names + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(b""" +current_context: test_context +contexts: + test_context: + env: + CLARIFAI_PAT: "config_pat" + CLARIFAI_UI: "config_ui" + pat: "direct_pat" + ui: "direct_ui" +""") + temp_file_path = temp_file.name + + config = Config.from_yaml(temp_file_path) + context = config.current + + # Test accessing values using shorthand names + # When using shorthand, it first looks for CLARIFAI_ in config, then in config + self.assertEqual(context.pat, "config_pat") # Finds CLARIFAI_PAT in config + self.assertEqual(context.ui, "config_ui") # Finds CLARIFAI_UI in config + + # Test accessing values using environment variable names + self.assertEqual(context.CLARIFAI_PAT, "config_pat") + self.assertEqual(context.CLARIFAI_UI, "config_ui") + + # Clean up + os.unlink(temp_file_path) + + @patch.dict('os.environ', {"CLARIFAI_PAT": "env_pat", "CLARIFAI_UI": "env_ui"}) + def test_environment_variable_override(self): + # Create a config file + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(b""" +current_context: test_context +contexts: + test_context: + env: + CLARIFAI_PAT: "config_pat" + CLARIFAI_UI: "config_ui" + pat: "direct_pat" + ui: "direct_ui" +""") + temp_file_path = temp_file.name + + config = Config.from_yaml(temp_file_path) + context = config.current + + # Environment variables should take precedence when using shorthand access + self.assertEqual(context.pat, "env_pat") # From environment + self.assertEqual(context.ui, "env_ui") # From environment + + # When accessing through the environment variable name, it checks the config first + self.assertEqual(context.CLARIFAI_PAT, "config_pat") # From config + self.assertEqual(context.CLARIFAI_UI, "config_ui") # From config + + # Clean up + os.unlink(temp_file_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_whoami_app.py b/tests/test_cli_whoami_app.py new file mode 100644 index 00000000..6268d2b8 --- /dev/null +++ b/tests/test_cli_whoami_app.py @@ -0,0 +1,187 @@ +"""Tests for the CLI whoami and app CRUD commands.""" + +import os +from collections import OrderedDict +from unittest import mock + +import pytest +from click.testing import CliRunner + +from clarifai.cli.base import cli +from clarifai.utils.config import Config, Context + + +@pytest.fixture(autouse=True) +def _clean_env(): + """Ensure CLARIFAI_* variables are not leaked from the host environment into tests.""" + with mock.patch.dict(os.environ, {}, clear=False): + for key in list(os.environ.keys()): + if key.startswith('CLARIFAI_'): + os.environ.pop(key, None) + yield + + +def _make_config(tmp_path, contexts=None, current_context='default'): + """Build a Config, save it to disk, and return the config file path.""" + if contexts is None: + contexts = OrderedDict( + { + 'default': Context( + 'default', + CLARIFAI_PAT='test_pat_12345', + CLARIFAI_USER_ID='test_user', + CLARIFAI_API_BASE='https://api.clarifai.com', + ), + } + ) + config_path = str(tmp_path / 'config') + cfg = Config( + current_context=current_context, + filename=config_path, + contexts=contexts, + ) + cfg.to_yaml(config_path) + return config_path + + +class TestWhoami: + """Tests for the whoami command.""" + + def test_whoami_displays_context_user_id(self, tmp_path): + """should display the context user ID.""" + config_path = _make_config(tmp_path) + runner = CliRunner() + result = runner.invoke(cli, ['--config', config_path, 'whoami']) + + assert result.exit_code == 0 + assert 'Context User ID: test_user' in result.output + + def test_whoami_handles_api_error(self, tmp_path): + """should handle API errors gracefully.""" + config_path = _make_config(tmp_path) + + # Mock an API error response + mock_response = mock.MagicMock() + mock_response.status.code = 1 # ERROR + mock_response.status.description = 'Authentication failed' + + with mock.patch('clarifai.client.user.User.get_user_info', return_value=mock_response): + runner = CliRunner() + result = runner.invoke(cli, ['--config', config_path, 'whoami']) + + assert result.exit_code == 0 + assert 'Context User ID: test_user' in result.output + + +class TestAppList: + """Tests for the app list command.""" + + def test_app_list_displays_apps(self, tmp_path): + """should display list of apps.""" + config_path = _make_config(tmp_path) + + # Mock the list_apps generator to yield App objects + mock_app1 = mock.MagicMock() + mock_app1.id = 'app_1' + mock_app1.user_id = 'test_user' + + mock_app2 = mock.MagicMock() + mock_app2.id = 'app_2' + mock_app2.user_id = 'test_user' + + with mock.patch( + 'clarifai.client.user.User.list_apps', return_value=iter([mock_app1, mock_app2]) + ): + runner = CliRunner() + result = runner.invoke(cli, ['--config', config_path, 'app', 'ls']) + + assert result.exit_code == 0 + assert 'app_1' in result.output + assert 'app_2' in result.output + assert 'User ID' in result.output + + def test_app_list_no_apps_found(self, tmp_path): + """should display message when no apps found.""" + config_path = _make_config(tmp_path) + + with mock.patch('clarifai.client.user.User.list_apps', return_value=iter([])): + runner = CliRunner() + result = runner.invoke(cli, ['--config', config_path, 'app', 'ls']) + + assert result.exit_code == 0 + assert 'No apps found' in result.output + + def test_app_list_with_user_id_option(self, tmp_path): + """should list apps for a different user when --user_id is provided.""" + config_path = _make_config(tmp_path) + + # Mock the list_apps generator to yield App objects + mock_app = mock.MagicMock() + mock_app.id = 'other_user_app' + mock_app.user_id = 'other_user' + + with mock.patch('clarifai.client.user.User.list_apps', return_value=iter([mock_app])): + runner = CliRunner() + result = runner.invoke( + cli, ['--config', config_path, 'app', 'ls', '--user_id', 'other_user'] + ) + + assert result.exit_code == 0 + assert 'other_user_app' in result.output + assert 'other_user' in result.output + + +class TestAppCreate: + """Tests for the app create command.""" + + def test_app_create_success(self, tmp_path): + """should create an app successfully.""" + config_path = _make_config(tmp_path) + + with mock.patch('clarifai.client.user.User.create_app') as mock_create: + runner = CliRunner() + result = runner.invoke(cli, ['--config', config_path, 'app', 'create', 'new_app']) + + assert result.exit_code == 0 + assert "App 'new_app' created successfully" in result.output + mock_create.assert_called_once_with(app_id='new_app', base_workflow='Empty') + + def test_app_create_with_base_workflow(self, tmp_path): + """should create an app with custom base workflow.""" + config_path = _make_config(tmp_path) + + with mock.patch('clarifai.client.user.User.create_app') as mock_create: + runner = CliRunner() + result = runner.invoke( + cli, + [ + '--config', + config_path, + 'app', + 'create', + 'new_app', + '--base-workflow', + 'Universal', + ], + ) + + assert result.exit_code == 0 + mock_create.assert_called_once_with(app_id='new_app', base_workflow='Universal') + + +class TestAppDelete: + """Tests for the app delete command.""" + + def test_app_delete_success(self, tmp_path): + """should delete an app successfully.""" + config_path = _make_config(tmp_path) + + with mock.patch('clarifai.client.user.User.delete_app') as mock_delete: + runner = CliRunner() + result = runner.invoke( + cli, ['--config', config_path, 'app', 'delete', 'app_to_delete'] + ) + + assert result.exit_code == 0 + assert "App 'app_to_delete' deleted successfully" in result.output + mock_delete.assert_called_once_with('app_to_delete') diff --git a/tests/test_pipeline_templates.py b/tests/test_pipeline_templates.py index 4ba6cc7a..c128befd 100644 --- a/tests/test_pipeline_templates.py +++ b/tests/test_pipeline_templates.py @@ -18,7 +18,7 @@ def setup_method(self): """Set up test fixtures.""" self.runner = CliRunner() - @patch('clarifai.cli.pipeline_template.TemplateManager') + @patch('clarifai.utils.template_manager.TemplateManager') def test_list_templates_success(self, mock_template_manager_class): """Test successful template listing.""" mock_manager = Mock() @@ -43,7 +43,7 @@ def test_list_templates_success(self, mock_template_manager_class): assert 'Available types: data, train' in result.output mock_manager.list_templates.assert_called_once_with(None) - @patch('clarifai.cli.pipeline_template.TemplateManager') + @patch('clarifai.utils.template_manager.TemplateManager') def test_list_templates_with_type_filter(self, mock_template_manager_class): """Test template listing with type filter.""" mock_manager = Mock() @@ -65,7 +65,7 @@ def test_list_templates_with_type_filter(self, mock_template_manager_class): assert "Found 1 template(s) of type 'train'" in result.output mock_manager.list_templates.assert_called_once_with('train') - @patch('clarifai.cli.pipeline_template.TemplateManager') + @patch('clarifai.utils.template_manager.TemplateManager') def test_list_templates_empty_result(self, mock_template_manager_class): """Test template listing when no templates found.""" mock_manager = Mock() @@ -78,7 +78,7 @@ def test_list_templates_empty_result(self, mock_template_manager_class): assert 'No templates found' in result.output mock_manager.list_templates.assert_called_once_with(None) - @patch('clarifai.cli.pipeline_template.TemplateManager') + @patch('clarifai.utils.template_manager.TemplateManager') def test_list_templates_with_error(self, mock_template_manager_class): """Test template listing error handling.""" mock_template_manager_class.side_effect = Exception("Template root not found") @@ -88,7 +88,7 @@ def test_list_templates_with_error(self, mock_template_manager_class): assert result.exit_code == 0 assert 'Error: Could not list templates' in result.output - @patch('clarifai.cli.pipeline_template.TemplateManager') + @patch('clarifai.utils.template_manager.TemplateManager') def test_template_info_success(self, mock_template_manager_class): """Test successful template info display.""" mock_manager = Mock() @@ -118,7 +118,7 @@ def test_template_info_success(self, mock_template_manager_class): assert 'EXAMPLE_SIZE (default: 32)' in result.output mock_manager.get_template_info.assert_called_once_with('image-classification') - @patch('clarifai.cli.pipeline_template.TemplateManager') + @patch('clarifai.utils.template_manager.TemplateManager') def test_template_info_not_found(self, mock_template_manager_class): """Test template info when template not found.""" mock_manager = Mock() @@ -131,7 +131,7 @@ def test_template_info_not_found(self, mock_template_manager_class): assert "Template 'nonexistent-template' not found" in result.output mock_manager.get_template_info.assert_called_once_with('nonexistent-template') - @patch('clarifai.cli.pipeline_template.TemplateManager') + @patch('clarifai.utils.template_manager.TemplateManager') def test_template_info_with_error(self, mock_template_manager_class): """Test template info error handling.""" mock_template_manager_class.side_effect = Exception("File not found")