diff --git a/clarifai/cli/pipeline.py b/clarifai/cli/pipeline.py index 9b71cb52..698a1e64 100644 --- a/clarifai/cli/pipeline.py +++ b/clarifai/cli/pipeline.py @@ -8,10 +8,13 @@ AliasedGroup, convert_timestamp_to_string, display_co_resources, + resolve_id, validate_context, ) from clarifai.utils.logging import logger +_DEFAULT_PIPELINE_ID = "hello-world-pipeline" + @cli.group( ['pipeline', 'pl'], @@ -285,14 +288,43 @@ def run( required=False, help='Initialize from a template (e.g., image-classification, text-prep)', ) -def init(pipeline_path, template): +@click.option('--user_id', required=False, help='User ID for the pipeline.') +@click.option('--app_id', required=False, help='App ID for the pipeline.') +@click.option( + '--pipeline_id', + required=False, + default=_DEFAULT_PIPELINE_ID, + show_default=True, + help='Pipeline ID.', +) +@click.option( + '--steps', + required=False, + multiple=True, + help='Pipeline step names. Can be specified multiple times (e.g., --steps stepA --steps stepB). Ignored when --template is used.', +) +@click.option( + '--num_steps', + required=False, + type=click.IntRange(min=1), + default=2, + show_default=True, + help='Number of pipeline steps to create when --steps is not specified. Ignored when --template or --steps is used.', +) +@click.option( + '--set', + 'override_params', + multiple=True, + help='Template parameter overrides. Format: --set key=value. Can be used multiple times. Only used with --template.', +) +def init(pipeline_path, template, user_id, app_id, pipeline_id, steps, num_steps, override_params): """Initialize a new pipeline project structure. - Creates a pipeline project structure either from a template or interactively. + Creates a pipeline project structure either from a template or using flag-based inputs. When using --template, initializes from a predefined template with specific - parameters and structure. Without --template, uses the interactive flow - to create a custom pipeline structure. + parameters and structure. Without --template, creates a custom pipeline structure + using the provided flags. Creates the following structure in the specified directory: ├── config.yaml # Pipeline configuration @@ -309,17 +341,61 @@ def init(pipeline_path, template): └── README.md # Documentation PIPELINE_PATH: Path where to create the pipeline project structure. If not specified, the current directory is used by default. + + Examples: + + # user_id/app_id auto-detected from global config (~/.clarifai/config.yaml) + clarifai pipeline init + + # Initialize with explicit IDs and steps + clarifai pipeline init --user_id=my_user --app_id=my_app --pipeline_id=my-pipeline --steps stepA --steps stepB + + # Initialize with a specific number of steps + clarifai pipeline init --user_id=my_user --app_id=my_app --pipeline_id=my-pipeline --num_steps=3 + + # Initialize from a template + clarifai pipeline init --template=image-classification --user_id=my_user --app_id=my_app + + # Initialize from a template with custom parameters + clarifai pipeline init --template=image-classification --user_id=my_user --app_id=my_app --set model_name=resnet50 """ + # Resolve user_id and app_id from flag → global config → prompt + user_id = resolve_id(user_id, 'user_id', 'User ID') + app_id = resolve_id(app_id, 'app_id', 'App ID') + # Common setup logic pipeline_path = _prepare_pipeline_path(pipeline_path, template) if not pipeline_path: return # Error already shown in _prepare_pipeline_path + # Resolve step names: explicit --steps take precedence, then generate from --num_steps + if steps: + resolved_steps = [*steps] + else: + default_names = ["stepA", "stepB", "stepC", "stepD", "stepE", "stepF"] + resolved_steps = [ + default_names[i] if i < len(default_names) else f"step{i + 1}" + for i in range(num_steps) + ] + # Branch to specific initialization method if template: - success = _init_from_template(pipeline_path, template) + success = _init_from_template( + pipeline_path, + template, + user_id=user_id, + app_id=app_id, + pipeline_id=pipeline_id, + override_params=override_params, + ) else: - success = _init_interactive(pipeline_path) + success = _init_flag_based( + pipeline_path, + user_id=user_id, + app_id=app_id, + pipeline_id=pipeline_id, + step_names=resolved_steps, + ) # Common completion logic if success: @@ -370,20 +446,25 @@ def _show_completion_message(pipeline_path): logger.info("3. Run 'clarifai pipeline upload config.yaml' to upload your pipeline") -def _init_from_template(pipeline_path, template_name): +def _init_from_template( + pipeline_path, template_name, user_id=None, app_id=None, pipeline_id=None, override_params=None +): """Initialize pipeline from a template. Args: pipeline_path: Destination path for the pipeline (already prepared) template_name: Name of the template to use + user_id: User ID for the pipeline (optional, uses placeholder if not provided) + app_id: App ID for the pipeline (optional, uses placeholder if not provided) + pipeline_id: Pipeline ID (optional, defaults to template_name) + override_params: Iterable of "key=value" strings for template parameter overrides Returns: bool: True if successful, False otherwise """ from clarifai.utils.template_manager import TemplateManager - click.echo("Welcome to Clarifai Pipeline Template Initialization!") - click.echo(f"Using template: {template_name}") + click.echo(f"Initializing pipeline from template: {template_name}") click.echo() try: @@ -402,41 +483,46 @@ def _init_from_template(pipeline_path, template_name): parameters = template_info['parameters'] if parameters: - click.echo(f"Parameters: {len(parameters)} required") + click.echo(f"Parameters: {len(parameters)} available") click.echo() - # Collect basic pipeline information - click.echo("Please provide the following information:") - user_id = click.prompt("User ID", type=str) - app_id = click.prompt("App ID", type=str) - - # Use template name as default pipeline ID - default_pipeline_id = template_name - pipeline_id = click.prompt("Pipeline ID", default=default_pipeline_id, type=str) + # user_id and app_id already resolved by the init command caller + effective_user_id = user_id or "your_user_id" + effective_app_id = app_id or "your_app_id" + effective_pipeline_id = ( + pipeline_id if pipeline_id and pipeline_id != _DEFAULT_PIPELINE_ID else template_name + ) - # Collect template-specific parameters + # Build parameter substitutions from flags parameter_substitutions = {} + + # Parse --set overrides + if override_params: + for param in override_params: + if '=' not in param: + raise ValueError(f"Invalid --set format: '{param}'. Expected key=value.") + key, value = param.split('=', 1) + parameter_substitutions[key] = value + + # Warn about template parameters that were not overridden if parameters: - click.echo("\nTemplate Parameters:") + overridden_keys = set(parameter_substitutions.keys()) for param in parameters: param_name = param['name'] - default_value = param['default_value'] - - # Format prompt as "param_name (default: value)" - prompt_text = f"{param_name} (default: {default_value})" - value = click.prompt(prompt_text, default=default_value) - - # Map parameter name to user's new value for substitution - # Only add to substitutions if the value actually changed - if value != default_value: - parameter_substitutions[param_name] = value + if param_name not in overridden_keys: + default_value = param['default_value'] + logger.info( + f"Using default value for template parameter '{param_name}': {default_value}" + ) # Add basic info to parameter substitutions - parameter_substitutions['user_id'] = user_id - parameter_substitutions['app_id'] = app_id - parameter_substitutions['id'] = pipeline_id + parameter_substitutions['user_id'] = effective_user_id + parameter_substitutions['app_id'] = effective_app_id + parameter_substitutions['id'] = effective_pipeline_id - click.echo(f"\nCreating pipeline '{pipeline_id}' from template '{template_name}'...") + click.echo( + f"Creating pipeline '{effective_pipeline_id}' from template '{template_name}'..." + ) # Copy template with substitutions success = template_manager.copy_template( @@ -454,11 +540,17 @@ def _init_from_template(pipeline_path, template_name): return False -def _init_interactive(pipeline_path): - """Interactive pipeline initialization (original behavior). +def _init_flag_based( + pipeline_path, user_id=None, app_id=None, pipeline_id=_DEFAULT_PIPELINE_ID, step_names=None +): + """Flag-based pipeline initialization. Args: pipeline_path: Destination path for the pipeline (already prepared) + user_id: User ID for the pipeline (optional, uses placeholder if not provided) + app_id: App ID for the pipeline (optional, uses placeholder if not provided) + pipeline_id: Pipeline ID (default: 'hello-world-pipeline') + step_names: List of pipeline step names (default: ['stepA', 'stepB']) Returns: bool: True if successful, False otherwise @@ -471,26 +563,15 @@ def _init_interactive(pipeline_path): get_readme_template, ) - try: - # Prompt for user inputs - click.echo("Welcome to Clarifai Pipeline Initialization!") - click.echo("Please provide the following information:") - - user_id = click.prompt("User ID", type=str) - app_id = click.prompt("App ID", type=str) - pipeline_id = click.prompt("Pipeline ID", default="hello-world-pipeline", type=str) - num_steps = click.prompt("Number of pipeline steps", default=2, type=int) + if step_names is None: + step_names = ["stepA", "stepB"] - # Get step names - step_names = [] - default_names = ["stepA", "stepB", "stepC", "stepD", "stepE", "stepF"] - - for i in range(num_steps): - default_name = default_names[i] if i < len(default_names) else f"step{i + 1}" - step_name = click.prompt(f"Name for step {i + 1}", default=default_name, type=str) - step_names.append(step_name) + # user_id and app_id already resolved by the init command caller + effective_user_id = user_id or "your_user_id" + effective_app_id = app_id or "your_app_id" - click.echo(f"\nCreating pipeline '{pipeline_id}' with steps: {', '.join(step_names)}") + try: + click.echo(f"Creating pipeline '{pipeline_id}' with steps: {', '.join(step_names)}") # Create pipeline config.yaml config_path = os.path.join(pipeline_path, "config.yaml") @@ -498,7 +579,10 @@ def _init_interactive(pipeline_path): logger.warning(f"File {config_path} already exists, skipping...") else: config_template = get_pipeline_config_template( - pipeline_id=pipeline_id, user_id=user_id, app_id=app_id, step_names=step_names + pipeline_id=pipeline_id, + user_id=effective_user_id, + app_id=effective_app_id, + step_names=step_names, ) with open(config_path, 'w', encoding='utf-8') as f: f.write(config_template) @@ -529,7 +613,7 @@ def _init_interactive(pipeline_path): logger.warning(f"File {step_config_path} already exists, skipping...") else: step_config_template = get_pipeline_step_config_template( - step_id=step_id, user_id=user_id, app_id=app_id + step_id=step_id, user_id=effective_user_id, app_id=effective_app_id ) with open(step_config_path, 'w', encoding='utf-8') as f: f.write(step_config_template) @@ -558,7 +642,7 @@ def _init_interactive(pipeline_path): return True except Exception as e: - logger.error(f"Interactive initialization error: {e}") + logger.error(f"Pipeline initialization error: {e}") click.echo(f"Error: {e}", err=True) return False diff --git a/clarifai/cli/pipeline_step.py b/clarifai/cli/pipeline_step.py index 21de508c..828cdfc5 100644 --- a/clarifai/cli/pipeline_step.py +++ b/clarifai/cli/pipeline_step.py @@ -8,6 +8,7 @@ AliasedGroup, convert_timestamp_to_string, display_co_resources, + resolve_id, validate_context, ) from clarifai.utils.logging import logger @@ -46,7 +47,14 @@ def upload(pipeline_step_path, skip_dockerfile): required=False, default=".", ) -def init(pipeline_step_path): +@click.option('--user_id', required=False, help='User ID for the pipeline step.') +@click.option('--app_id', required=False, help='App ID for the pipeline step.') +@click.option( + '--step_id', + required=False, + help='Pipeline step ID.', +) +def init(pipeline_step_path, user_id, app_id, step_id): """Initialize a new pipeline step directory structure. Creates the following structure in the specified directory: @@ -56,6 +64,17 @@ def init(pipeline_step_path): └── config.yaml PIPELINE_STEP_PATH: Path where to create the pipeline step directory structure. If not specified, the current directory is used by default. + + Examples: + + # Basic initialization with defaults (user_id/app_id from global config) + clarifai pipelinestep init + + # Initialize with explicit IDs + clarifai pipelinestep init --user_id=my_user --app_id=my_app --step_id=my-step + + # Initialize in a specific directory + clarifai pipelinestep init ./my-step --user_id=my_user --app_id=my_app --step_id=my-step """ from clarifai.cli.templates.pipeline_step_templates import ( get_config_template, @@ -63,6 +82,10 @@ def init(pipeline_step_path): get_requirements_template, ) + # Resolve user_id and app_id from flag → global config → prompt + user_id = resolve_id(user_id, 'user_id', 'User ID') + app_id = resolve_id(app_id, 'app_id', 'App ID') + # Resolve the absolute path pipeline_step_path = os.path.abspath(pipeline_step_path) @@ -79,7 +102,7 @@ def init(pipeline_step_path): logger.warning(f"File {pipeline_step_py_path} already exists, skipping...") else: pipeline_step_template = get_pipeline_step_template() - with open(pipeline_step_py_path, 'w') as f: + with open(pipeline_step_py_path, 'w', encoding='utf-8') as f: f.write(pipeline_step_template) logger.info(f"Created {pipeline_step_py_path}") @@ -89,7 +112,7 @@ def init(pipeline_step_path): logger.warning(f"File {requirements_path} already exists, skipping...") else: requirements_template = get_requirements_template() - with open(requirements_path, 'w') as f: + with open(requirements_path, 'w', encoding='utf-8') as f: f.write(requirements_template) logger.info(f"Created {requirements_path}") @@ -98,17 +121,24 @@ def init(pipeline_step_path): if os.path.exists(config_path): logger.warning(f"File {config_path} already exists, skipping...") else: - config_template = get_config_template() - with open(config_path, 'w') as f: + config_template = get_config_template( + user_id=user_id, app_id=app_id, **({'step_id': step_id} if step_id else {}) + ) + with open(config_path, 'w', encoding='utf-8') as f: f.write(config_template) logger.info(f"Created {config_path}") logger.info(f"Pipeline step initialization complete in {pipeline_step_path}") - logger.info("Next steps:") - logger.info("1. Search for '# TODO: please fill in' comments in the generated files") - logger.info("2. Update the pipeline step configuration in config.yaml") - logger.info("3. Add your pipeline step dependencies to requirements.txt") - logger.info("4. Implement your pipeline step logic in 1/pipeline_step.py") + if not step_id: + logger.info("Next steps:") + logger.info("1. Search for '# TODO: please fill in' comments in the generated files") + logger.info("2. Update the pipeline step configuration in config.yaml") + logger.info("3. Add your pipeline step dependencies to requirements.txt") + logger.info("4. Implement your pipeline step logic in 1/pipeline_step.py") + else: + logger.info("Next steps:") + logger.info("1. Add your pipeline step dependencies to requirements.txt") + logger.info("2. Implement your pipeline step logic in 1/pipeline_step.py") @pipeline_step.command(['ls']) diff --git a/clarifai/cli/templates/pipeline_step_templates.py b/clarifai/cli/templates/pipeline_step_templates.py index 78a5a7fb..20b72074 100644 --- a/clarifai/cli/templates/pipeline_step_templates.py +++ b/clarifai/cli/templates/pipeline_step_templates.py @@ -2,13 +2,25 @@ from clarifai.versions import CLIENT_VERSION +_DEFAULT_STEP_ID = "text-classifier-train-upload-step" +_DEFAULT_USER_ID = "your_user_id" +_DEFAULT_APP_ID = "your_app_id" -def get_config_template(): + +def get_config_template( + step_id=_DEFAULT_STEP_ID, + user_id=_DEFAULT_USER_ID, + app_id=_DEFAULT_APP_ID, +): """Get the config.yaml template for pipeline steps.""" - return """pipeline_step: - id: "text-classifier-train-upload-step" # TODO: please fill in - user_id: "your_user_id" # TODO: please fill in - app_id: "your_app_id" # TODO: please fill in + _todo = " # TODO: please fill in" + user_id_comment = "" if user_id != _DEFAULT_USER_ID else _todo + app_id_comment = "" if app_id != _DEFAULT_APP_ID else _todo + step_id_comment = "" if step_id != _DEFAULT_STEP_ID else _todo + return f"""pipeline_step: + id: "{step_id}"{step_id_comment} + user_id: "{user_id}"{user_id_comment} + app_id: "{app_id}"{app_id_comment} # Optional: visibility for the pipeline step # gettable values: PRIVATE(10), ORG(30), PUBLIC(50) visibility: diff --git a/clarifai/utils/cli.py b/clarifai/utils/cli.py index 4cb14c3a..8317a158 100644 --- a/clarifai/utils/cli.py +++ b/clarifai/utils/cli.py @@ -914,3 +914,42 @@ def print_field_help(name: str, description: str) -> None: click.echo(click.style(f"➤ {name}", fg="bright_green", bold=True)) if description: click.echo(click.style(f" {description}", fg="green")) + + +def resolve_id(value, config_key, prompt_text): + """Resolve a user/app ID value from a CLI flag, global config, or an interactive prompt. + + Resolution order: + 1. Use ``value`` directly if provided (non-empty). + 2. Try ``Config.from_yaml().current.get(config_key)`` from the active context in + ``~/.clarifai/config.yaml``. + 3. Fall back to ``click.prompt(prompt_text)``. + + Args: + value: Value passed via CLI flag (may be None or empty). + config_key: Key to look up in the current context (e.g. ``'user_id'``). + prompt_text: Text shown to the user when prompting interactively. + + Returns: + str: Resolved non-empty value. + """ + import logging + + _logger = logging.getLogger(__name__) + + if value: + return value + + # Try global config + try: + from clarifai.utils.config import Config + + config_value = Config.from_yaml().current.get(config_key) + if config_value and config_value != '_empty_': + _logger.debug(f"Using {config_key} from global config: {config_value}") + return config_value + except Exception: + pass + + # Fall back to interactive prompt + return click.prompt(prompt_text, type=str) diff --git a/tests/cli/test_pipeline.py b/tests/cli/test_pipeline.py index 0241a78f..972bb6c4 100644 --- a/tests/cli/test_pipeline.py +++ b/tests/cli/test_pipeline.py @@ -827,9 +827,22 @@ def test_init_command_creates_expected_structure(self): runner = CliRunner(env={"PYTHONIOENCODING": "utf-8"}) with runner.isolated_filesystem(): - # Provide inputs for the interactive prompts - inputs = "test-user\ntest-app\nhello-world-pipeline\n2\nstepA\nstepB\n" - result = runner.invoke(init, ['.'], input=inputs) + result = runner.invoke( + init, + [ + '.', + '--user_id', + 'test-user', + '--app_id', + 'test-app', + '--pipeline_id', + 'hello-world-pipeline', + '--steps', + 'stepA', + '--steps', + 'stepB', + ], + ) assert result.exit_code == 0 @@ -849,13 +862,28 @@ def test_init_command_creates_expected_structure(self): assert os.path.exists(file_path), f"Expected file {file_path} was not created" def test_init_command_with_custom_inputs(self): - """Test that init command works with custom user inputs.""" + """Test that init command works with custom flag inputs.""" runner = CliRunner(env={"PYTHONIOENCODING": "utf-8"}) with runner.isolated_filesystem(): - # Provide custom inputs - inputs = "custom-user\ncustom-app\ncustom-pipeline\n3\ndata-prep\nmodel-train\nmodel-deploy\n" - result = runner.invoke(init, ['.'], input=inputs) + result = runner.invoke( + init, + [ + '.', + '--user_id', + 'custom-user', + '--app_id', + 'custom-app', + '--pipeline_id', + 'custom-pipeline', + '--steps', + 'data-prep', + '--steps', + 'model-train', + '--steps', + 'model-deploy', + ], + ) assert result.exit_code == 0 @@ -882,8 +910,20 @@ def test_init_command_with_custom_path(self): runner = CliRunner(env={"PYTHONIOENCODING": "utf-8"}) with runner.isolated_filesystem(): - inputs = "test-user\ntest-app\nhello-world-pipeline\n2\nstepA\nstepB\n" - result = runner.invoke(init, ['my_pipeline'], input=inputs) + result = runner.invoke( + init, + [ + 'my_pipeline', + '--user_id', + 'test-user', + '--app_id', + 'test-app', + '--steps', + 'stepA', + '--steps', + 'stepB', + ], + ) assert result.exit_code == 0 @@ -901,8 +941,20 @@ def test_init_command_skips_existing_files(self): with open('config.yaml', 'w') as f: f.write('existing content') - inputs = "test-user\ntest-app\nhello-world-pipeline\n2\nstepA\nstepB\n" - result = runner.invoke(init, ['.'], input=inputs) + result = runner.invoke( + init, + [ + '.', + '--user_id', + 'test-user', + '--app_id', + 'test-app', + '--steps', + 'stepA', + '--steps', + 'stepB', + ], + ) assert result.exit_code == 0 @@ -920,8 +972,22 @@ def test_init_command_creates_valid_pipeline_config(self): runner = CliRunner(env={"PYTHONIOENCODING": "utf-8"}) with runner.isolated_filesystem(): - inputs = "test-user\ntest-app\ntest-pipeline\n2\nstepA\nstepB\n" - result = runner.invoke(init, ['.'], input=inputs) + result = runner.invoke( + init, + [ + '.', + '--user_id', + 'test-user', + '--app_id', + 'test-app', + '--pipeline_id', + 'test-pipeline', + '--steps', + 'stepA', + '--steps', + 'stepB', + ], + ) assert result.exit_code == 0 @@ -955,8 +1021,22 @@ def test_init_command_creates_valid_step_configs(self): runner = CliRunner(env={"PYTHONIOENCODING": "utf-8"}) with runner.isolated_filesystem(): - inputs = "test-user\ntest-app\ntest-pipeline\n2\nstepA\nstepB\n" - result = runner.invoke(init, ['.'], input=inputs) + result = runner.invoke( + init, + [ + '.', + '--user_id', + 'test-user', + '--app_id', + 'test-app', + '--pipeline_id', + 'test-pipeline', + '--steps', + 'stepA', + '--steps', + 'stepB', + ], + ) assert result.exit_code == 0 @@ -981,8 +1061,22 @@ def test_init_command_includes_helpful_messages(self): runner = CliRunner(env={"PYTHONIOENCODING": "utf-8"}) with runner.isolated_filesystem(): - inputs = "test-user\ntest-app\ntest-pipeline\n2\nstepA\nstepB\n" - result = runner.invoke(init, ['.'], input=inputs) + result = runner.invoke( + init, + [ + '.', + '--user_id', + 'test-user', + '--app_id', + 'test-app', + '--pipeline_id', + 'test-pipeline', + '--steps', + 'stepA', + '--steps', + 'stepB', + ], + ) assert result.exit_code == 0 @@ -1006,8 +1100,22 @@ def test_init_command_creates_workflow_arguments_template(self): runner = CliRunner(env={"PYTHONIOENCODING": "utf-8"}) with runner.isolated_filesystem(): - inputs = "test-user\ntest-app\ntest-pipeline\n2\nstepA\nstepB\n" - result = runner.invoke(init, ['.'], input=inputs) + result = runner.invoke( + init, + [ + '.', + '--user_id', + 'test-user', + '--app_id', + 'test-app', + '--pipeline_id', + 'test-pipeline', + '--steps', + 'stepA', + '--steps', + 'stepB', + ], + ) assert result.exit_code == 0 @@ -1057,33 +1165,49 @@ def test_init_command_with_template_option(self): mock_prepare.return_value = '/test/path' mock_template_init.return_value = True - runner.invoke(init, ['--template', 'image-classification', '.']) + runner.invoke( + init, + ['--template', 'image-classification', '.', '--user_id', 'u', '--app_id', 'a'], + ) - # Should call template initialization with prepared path + # Should call template initialization with prepared path and flags mock_prepare.assert_called_once_with('.', 'image-classification') - mock_template_init.assert_called_once_with('/test/path', 'image-classification') + mock_template_init.assert_called_once_with( + '/test/path', + 'image-classification', + user_id='u', + app_id='a', + pipeline_id='hello-world-pipeline', + override_params=(), + ) mock_completion.assert_called_once_with('/test/path') - def test_init_command_without_template_calls_interactive(self): - """Test that init command without --template calls interactive flow.""" + def test_init_command_without_template_calls_flag_based(self): + """Test that init command without --template calls flag-based flow.""" runner = CliRunner(env={"PYTHONIOENCODING": "utf-8"}) with runner.isolated_filesystem(): # Mock helper functions with ( patch('clarifai.cli.pipeline._prepare_pipeline_path') as mock_prepare, - patch('clarifai.cli.pipeline._init_interactive') as mock_interactive, + patch('clarifai.cli.pipeline._init_flag_based') as mock_flag_based, patch('clarifai.cli.pipeline._init_from_template') as mock_template, patch('clarifai.cli.pipeline._show_completion_message') as mock_completion, ): mock_prepare.return_value = '/test/path' - mock_interactive.return_value = True + mock_flag_based.return_value = True - runner.invoke(init, ['.']) + runner.invoke(init, ['.', '--user_id', 'u', '--app_id', 'a']) - # Should call interactive initialization with prepared path + # Should call flag-based initialization with prepared path and defaults mock_prepare.assert_called_once_with('.', None) - mock_interactive.assert_called_once_with('/test/path') + mock_flag_based.assert_called_once_with( + '/test/path', + user_id='u', + app_id='a', + pipeline_id='hello-world-pipeline', + step_names=['stepA', 'stepB'], + ) mock_template.assert_not_called() mock_completion.assert_called_once_with('/test/path') @@ -1120,38 +1244,35 @@ def test_init_from_template_success(self, mock_template_manager_class): # Import the function to test directly from clarifai.cli.pipeline import _init_from_template - # Mock click.prompt to simulate user input - with patch('click.prompt') as mock_prompt: - # Setup prompt responses - mock_prompt.side_effect = [ - 'test-user', # User ID - 'test-app', # App ID - 'my-pipeline', # Pipeline ID - '/custom/path', # Example Path parameter - '32', # Example Size parameter - ] - - result = _init_from_template('/test/path', 'test-template') - - # Verify template manager was called correctly - mock_manager.get_template_info.assert_called_once_with('test-template') - mock_manager.copy_template.assert_called_once() - - # Check that substitutions were passed - call_args = mock_manager.copy_template.call_args - assert call_args[0][0] == 'test-template' # template name - assert call_args[0][1] == '/test/path' # destination path - - substitutions = call_args[0][2] # substitutions dict - assert 'user_id' in substitutions # Basic substitutions - assert 'app_id' in substitutions - assert 'id' in substitutions - assert substitutions['user_id'] == 'test-user' - assert substitutions['app_id'] == 'test-app' - assert substitutions['id'] == 'my-pipeline' - - # Should return True for success - assert result is True + # Call with flags instead of interactive prompts + result = _init_from_template( + '/test/path', + 'test-template', + user_id='test-user', + app_id='test-app', + pipeline_id='my-pipeline', + override_params=['EXAMPLE_PATH=/custom/path', 'EXAMPLE_SIZE=32'], + ) + + # Verify template manager was called correctly + mock_manager.get_template_info.assert_called_once_with('test-template') + mock_manager.copy_template.assert_called_once() + + # Check that substitutions were passed + call_args = mock_manager.copy_template.call_args + assert call_args[0][0] == 'test-template' # template name + assert call_args[0][1] == '/test/path' # destination path + + substitutions = call_args[0][2] # substitutions dict + assert 'user_id' in substitutions # Basic substitutions + assert 'app_id' in substitutions + assert 'id' in substitutions + assert substitutions['user_id'] == 'test-user' + assert substitutions['app_id'] == 'test-app' + assert substitutions['id'] == 'my-pipeline' + + # Should return True for success + assert result is True @patch('clarifai.utils.template_manager.TemplateManager') def test_init_from_template_not_found(self, mock_template_manager_class): @@ -1191,14 +1312,16 @@ def test_init_from_template_with_copy_failure(self, mock_template_manager_class) # Import the function to test directly from clarifai.cli.pipeline import _init_from_template - # Mock click.prompt for basic inputs - with patch('click.prompt') as mock_prompt: - mock_prompt.side_effect = ['test-user', 'test-app', 'my-pipeline'] - - result = _init_from_template('/test/path', 'test-template') + result = _init_from_template( + '/test/path', + 'test-template', + user_id='test-user', + app_id='test-app', + pipeline_id='my-pipeline', + ) - # Should return False for copy failure - assert result is False + # Should return False for copy failure + assert result is False @patch('clarifai.utils.template_manager.TemplateManager') def test_init_from_template_with_parameters(self, mock_template_manager_class): @@ -1238,44 +1361,44 @@ def test_init_from_template_with_parameters(self, mock_template_manager_class): # Import the function to test directly from clarifai.cli.pipeline import _init_from_template - # Mock click.prompt for all inputs - with patch('click.prompt') as mock_prompt: - mock_prompt.side_effect = [ - 'user', # User ID - 'app', # App ID - 'pipeline', # Pipeline ID - '/new/input', # Param A - '/new/output', # Param B - 'new_value', # Param C - '0.002', # Param D - ] - - result = _init_from_template('/test/path', 'complex-template') - - # Verify the function succeeded - assert result is True + # Call with --set overrides for the parameters + result = _init_from_template( + '/test/path', + 'complex-template', + user_id='user', + app_id='app', + pipeline_id='pipeline', + override_params=[ + 'PARAM_A=/new/input', + 'PARAM_B=/new/output', + 'PARAM_C=new_value', + 'PARAM_D=0.002', + ], + ) - # Verify copy_template was called - assert mock_manager.copy_template.called - call_args = mock_manager.copy_template.call_args - substitutions = call_args[0][2] + # Verify the function succeeded + assert result is True - # Check that basic substitutions are present - assert 'user_id' in substitutions - assert 'app_id' in substitutions - assert 'id' in substitutions - assert substitutions['user_id'] == 'user' - assert substitutions['app_id'] == 'app' - assert substitutions['id'] == 'pipeline' - - # Check that parameter substitutions are present (only if different from default) - # Since all inputs differ from defaults, they should be in substitutions - assert 'PARAM_A' in substitutions - assert substitutions['PARAM_A'] == '/new/input' - assert 'PARAM_C' in substitutions - assert substitutions['PARAM_C'] == 'new_value' - assert 'PARAM_D' in substitutions - assert substitutions['PARAM_D'] == '0.002' + # Verify copy_template was called + assert mock_manager.copy_template.called + call_args = mock_manager.copy_template.call_args + substitutions = call_args[0][2] + + # Check that basic substitutions are present + assert 'user_id' in substitutions + assert 'app_id' in substitutions + assert 'id' in substitutions + assert substitutions['user_id'] == 'user' + assert substitutions['app_id'] == 'app' + assert substitutions['id'] == 'pipeline' + + # Check that parameter substitutions are present + assert 'PARAM_A' in substitutions + assert substitutions['PARAM_A'] == '/new/input' + assert 'PARAM_C' in substitutions + assert substitutions['PARAM_C'] == 'new_value' + assert 'PARAM_D' in substitutions + assert substitutions['PARAM_D'] == '0.002' @patch('clarifai.utils.template_manager.TemplateManager') def test_init_from_template_custom_pipeline_id(self, mock_template_manager_class): @@ -1297,28 +1420,27 @@ def test_init_from_template_custom_pipeline_id(self, mock_template_manager_class # Import the function to test directly from clarifai.cli.pipeline import _init_from_template - # Mock click.prompt for inputs with custom pipeline name - with patch('click.prompt') as mock_prompt: - mock_prompt.side_effect = [ - 'user', # User ID - 'app', # App ID - 'custom-pipeline-name', # Custom Pipeline ID - ] + # Call with a custom pipeline ID + result = _init_from_template( + '/test/path', + 'original-template', + user_id='user', + app_id='app', + pipeline_id='custom-pipeline-name', + ) - result = _init_from_template('/test/path', 'original-template') + # Verify pipeline ID substitution was included + call_args = mock_manager.copy_template.call_args + substitutions = call_args[0][2] - # Verify pipeline ID substitution was included - call_args = mock_manager.copy_template.call_args - substitutions = call_args[0][2] + # The new system stores basic fields directly + assert 'user_id' in substitutions + assert 'app_id' in substitutions + assert 'id' in substitutions + assert substitutions['id'] == 'custom-pipeline-name' - # The new system stores basic fields directly - assert 'user_id' in substitutions - assert 'app_id' in substitutions - assert 'id' in substitutions - assert substitutions['id'] == 'custom-pipeline-name' - - # Should return True for success - assert result is True + # Should return True for success + assert result is True class TestPipelineRunCommand: diff --git a/tests/cli/test_pipeline_step.py b/tests/cli/test_pipeline_step.py index 3948b4a9..33a93444 100644 --- a/tests/cli/test_pipeline_step.py +++ b/tests/cli/test_pipeline_step.py @@ -19,7 +19,7 @@ def test_init_command_creates_expected_structure(self): runner = CliRunner(env={"PYTHONIOENCODING": "utf-8"}) with runner.isolated_filesystem(): - result = runner.invoke(init, ['.']) + result = runner.invoke(init, ['.', '--user_id', 'test-user', '--app_id', 'test-app']) assert result.exit_code == 0 @@ -39,7 +39,9 @@ def test_init_command_with_custom_path(self): with runner.isolated_filesystem(): custom_path = 'my_pipeline_step' - result = runner.invoke(init, [custom_path]) + result = runner.invoke( + init, [custom_path, '--user_id', 'test-user', '--app_id', 'test-app'] + ) assert result.exit_code == 0 @@ -62,7 +64,7 @@ def test_init_command_skips_existing_files(self): with open('config.yaml', 'w') as f: f.write('existing content') - result = runner.invoke(init, ['.']) + result = runner.invoke(init, ['.', '--user_id', 'test-user', '--app_id', 'test-app']) assert result.exit_code == 0 @@ -80,7 +82,7 @@ def test_init_command_creates_valid_config_content(self): runner = CliRunner(env={"PYTHONIOENCODING": "utf-8"}) with runner.isolated_filesystem(): - result = runner.invoke(init, ['.']) + result = runner.invoke(init, ['.', '--user_id', 'test-user', '--app_id', 'test-app']) assert result.exit_code == 0 @@ -100,7 +102,7 @@ def test_init_command_creates_valid_pipeline_step_content(self): runner = CliRunner(env={"PYTHONIOENCODING": "utf-8"}) with runner.isolated_filesystem(): - result = runner.invoke(init, ['.']) + result = runner.invoke(init, ['.', '--user_id', 'test-user', '--app_id', 'test-app']) assert result.exit_code == 0 @@ -118,7 +120,8 @@ def test_init_command_includes_helpful_messages(self, caplog): caplog.set_level(logging.INFO) with runner.isolated_filesystem(): - result = runner.invoke(init, ['.']) + # Provide user_id and app_id but not step_id so TODO message is shown + result = runner.invoke(init, ['.', '--user_id', 'test-user', '--app_id', 'test-app']) assert result.exit_code == 0, result.output @@ -360,8 +363,10 @@ def test_init_and_upload_integration(self): runner = CliRunner(env={"PYTHONIOENCODING": "utf-8"}) with runner.isolated_filesystem(): - # Initialize pipeline step - init_result = runner.invoke(init, ['.']) + # Initialize pipeline step with required IDs + init_result = runner.invoke( + init, ['.', '--user_id', 'test-user', '--app_id', 'test-app'] + ) assert init_result.exit_code == 0 # Verify that the created structure would be valid for upload diff --git a/tests/test_pipeline_templates.py b/tests/test_pipeline_templates.py index c128befd..6cf05946 100644 --- a/tests/test_pipeline_templates.py +++ b/tests/test_pipeline_templates.py @@ -150,38 +150,54 @@ def setup_method(self): self.runner = CliRunner() @patch('clarifai.cli.pipeline._init_from_template') - @patch('clarifai.cli.pipeline._init_interactive') + @patch('clarifai.cli.pipeline._init_flag_based') @patch('clarifai.cli.pipeline._prepare_pipeline_path') @patch('clarifai.cli.pipeline._show_completion_message') def test_init_with_template_option( - self, mock_completion, mock_prepare_path, mock_interactive, mock_template + self, mock_completion, mock_prepare_path, mock_flag_based, mock_template ): """Test that --template option calls template initialization.""" mock_prepare_path.return_value = '/test/path' mock_template.return_value = True - self.runner.invoke(init, ['--template', 'image-classification', '.']) + self.runner.invoke( + init, + ['--template', 'image-classification', '.', '--user_id', 'u', '--app_id', 'a'], + ) mock_prepare_path.assert_called_once_with('.', 'image-classification') - mock_template.assert_called_once_with('/test/path', 'image-classification') - mock_interactive.assert_not_called() + mock_template.assert_called_once_with( + '/test/path', + 'image-classification', + user_id='u', + app_id='a', + pipeline_id='hello-world-pipeline', + override_params=(), + ) + mock_flag_based.assert_not_called() mock_completion.assert_called_once_with('/test/path') @patch('clarifai.cli.pipeline._init_from_template') - @patch('clarifai.cli.pipeline._init_interactive') + @patch('clarifai.cli.pipeline._init_flag_based') @patch('clarifai.cli.pipeline._prepare_pipeline_path') @patch('clarifai.cli.pipeline._show_completion_message') def test_init_without_template_option( - self, mock_completion, mock_prepare_path, mock_interactive, mock_template + self, mock_completion, mock_prepare_path, mock_flag_based, mock_template ): - """Test that without --template option calls interactive initialization.""" + """Test that without --template option calls flag-based initialization.""" mock_prepare_path.return_value = '/test/path' - mock_interactive.return_value = True + mock_flag_based.return_value = True - self.runner.invoke(init, ['.']) + self.runner.invoke(init, ['.', '--user_id', 'u', '--app_id', 'a']) mock_prepare_path.assert_called_once_with('.', None) - mock_interactive.assert_called_once_with('/test/path') + mock_flag_based.assert_called_once_with( + '/test/path', + user_id='u', + app_id='a', + pipeline_id='hello-world-pipeline', + step_names=['stepA', 'stepB'], + ) mock_template.assert_not_called() mock_completion.assert_called_once_with('/test/path')