From 5fc27cc0990ae350111a8873eb76af7b884025e0 Mon Sep 17 00:00:00 2001 From: Anuj Hydrabadi Date: Mon, 30 Jun 2025 14:39:11 +0530 Subject: [PATCH 1/3] Add namespace support to output commands - Update add_input to support @namespace/name format - Add namespace support to get_output_types - Rename get_output_details to get_output_type_details with namespace support - Add comprehensive test coverage for all changes --- ftf_cli/cli.py | 4 +- ftf_cli/commands/__init__.py | 4 +- ftf_cli/commands/add_input.py | 69 +++-- ftf_cli/commands/get_output_details.py | 73 ----- ftf_cli/commands/get_output_type_details.py | 122 ++++++++ ftf_cli/commands/get_output_types.py | 6 +- tests/commands/test_add_input.py | 179 +++++++++++- .../commands/test_get_output_type_details.py | 261 ++++++++++++++++++ tests/commands/test_get_output_types.py | 161 +++++++++++ 9 files changed, 774 insertions(+), 105 deletions(-) delete mode 100644 ftf_cli/commands/get_output_details.py create mode 100644 ftf_cli/commands/get_output_type_details.py create mode 100644 tests/commands/test_get_output_type_details.py create mode 100644 tests/commands/test_get_output_types.py diff --git a/ftf_cli/cli.py b/ftf_cli/cli.py index 20be14c..e05e6d8 100644 --- a/ftf_cli/cli.py +++ b/ftf_cli/cli.py @@ -8,7 +8,7 @@ from ftf_cli.commands.add_input import add_input from ftf_cli.commands.delete_module import delete_module from ftf_cli.commands.get_output_types import get_output_types -from ftf_cli.commands.get_output_details import get_output_lookup_tree +from ftf_cli.commands.get_output_type_details import get_output_type_details from ftf_cli.commands.validate_facets import validate_facets from ftf_cli.commands.register_output_type import register_output_type from ftf_cli.commands.add_import import add_import @@ -28,7 +28,7 @@ def cli(): cli.add_command(expose_provider) cli.add_command(generate_module) cli.add_command(get_output_types) -cli.add_command(get_output_lookup_tree) +cli.add_command(get_output_type_details) cli.add_command(login) cli.add_command(preview_module) cli.add_command(register_output_type) diff --git a/ftf_cli/commands/__init__.py b/ftf_cli/commands/__init__.py index 45ee59f..b0c197e 100644 --- a/ftf_cli/commands/__init__.py +++ b/ftf_cli/commands/__init__.py @@ -7,7 +7,7 @@ from .login import login from .preview_module import preview_module from .get_output_types import get_output_types -from .get_output_details import get_output_lookup_tree +from .get_output_type_details import get_output_type_details from .register_output_type import register_output_type from .add_import import add_import from .get_resources import get_resources @@ -23,7 +23,7 @@ "generate_module", "get_output_types", "register_output_type", - "get_output_lookup_tree", + "get_output_type_details", "login", "preview_module", "validate_directory", diff --git a/ftf_cli/commands/add_input.py b/ftf_cli/commands/add_input.py index 17db061..d334a34 100644 --- a/ftf_cli/commands/add_input.py +++ b/ftf_cli/commands/add_input.py @@ -17,6 +17,27 @@ ) +def parse_namespace_and_name(output_type): + """Parse output_type into namespace and name components. + + Args: + output_type (str): Format should be @namespace/name + + Returns: + tuple: (namespace, name) + + Raises: + click.UsageError: If format is invalid + """ + pattern = r"(@[^/]+)/(.*)" + match = re.match(pattern, output_type) + if not match: + raise click.UsageError( + f"❌ Invalid format '{output_type}'. Expected format: @namespace/name (e.g., @outputs/vpc, @anuj/sqs)" + ) + return match.group(1), match.group(2) + + @click.command() @click.argument("path", type=click.Path(exists=True)) @click.option( @@ -51,7 +72,7 @@ "--output-type", prompt="Output Type", type=str, - help="The type of registered output to be added as input for terraform module.", + help="The type of registered output to be added as input for terraform module. Format: @namespace/name (e.g., @outputs/vpc, @anuj/sqs)", ) def add_input(path, profile, name, display_name, description, output_type): """Add an existing registered output as a input in facets.yaml and populate the attributes in variables.tf exposed by selected output.""" @@ -76,13 +97,13 @@ def add_input(path, profile, name, display_name, description, output_type): required_inputs = facets_data.get("inputs", {}) required_inputs_map = {} - pattern = r"@outputs/(.*)" + pattern = r"(@[^/]+)/(.*)" for key, value in required_inputs.items(): required_input = value.get("type", "") if required_input and required_input != "": match = re.search(pattern, required_input) if match: - required_inputs_map[key] = match.group(1) + required_inputs_map[key] = required_input # Store full @namespace/name if name in required_inputs_map: click.echo( @@ -95,7 +116,7 @@ def add_input(path, profile, name, display_name, description, output_type): required_inputs.update( { name: { - "type": f"@outputs/{output_type}", + "type": output_type, "displayName": display_name, "description": description, } @@ -105,6 +126,9 @@ def add_input(path, profile, name, display_name, description, output_type): # update the facets yaml with the new input facets_data.update({"inputs": required_inputs}) + # Validate output_type format + parse_namespace_and_name(output_type) + # check if profile is set click.echo(f"Profile selected: {profile}") credentials = is_logged_in(profile) @@ -122,46 +146,55 @@ def add_input(path, profile, name, display_name, description, output_type): f"{control_plane_url}/cc-ui/v1/tf-outputs", auth=(username, token) ) - registered_outputs = {output["name"]: output for output in response.json()} - registered_output_names = list(registered_outputs.keys()) + registered_outputs = {(output["namespace"], output["name"]): output for output in response.json()} + available_output_types = [f'{namespace}/{name}' for namespace, name in registered_outputs.keys()] # make sure all outputs are registered - for output in required_inputs_map.values(): - if output not in registered_output_names: + for output_type_value in required_inputs_map.values(): + namespace, name = parse_namespace_and_name(output_type_value) + if (namespace, name) not in registered_outputs: raise click.UsageError( - f"❌ {output} not found in registered outputs. Please select a valid output type from {registered_output_names}." + f"❌ {output_type_value} not found in registered outputs. Please select a valid output type from {available_output_types}." ) # get properties for each output and transform them output_schemas = {} - for output_name, output in required_inputs_map.items(): - properties = registered_outputs[output].get("properties") + for output_name, output_type_value in required_inputs_map.items(): + namespace, name = parse_namespace_and_name(output_type_value) + output_data = registered_outputs[(namespace, name)] + properties = output_data.get("properties") if properties: try: - # Assume properties has the expected structure with attributes and interfaces - if (properties.get("type") == "object" and + # Try direct structure first: properties.{attributes, interfaces} + if "attributes" in properties and "interfaces" in properties: + attributes_schema = properties["attributes"] + interfaces_schema = properties["interfaces"] + output_schemas[output_name] = { + "attributes": attributes_schema, + "interfaces": interfaces_schema + } + # Try nested structure: properties.properties.{attributes, interfaces} + elif (properties.get("type") == "object" and "properties" in properties and "attributes" in properties["properties"] and "interfaces" in properties["properties"]): - attributes_schema = properties["properties"]["attributes"] interfaces_schema = properties["properties"]["interfaces"] - output_schemas[output_name] = { "attributes": attributes_schema, "interfaces": interfaces_schema } else: click.echo( - f"⚠️ Output {output} does not have expected structure (attributes/interfaces). Using default empty structure.") + f"⚠️ Output {output_type_value} does not have expected structure (attributes/interfaces). Using default empty structure.") output_schemas[output_name] = {"attributes": {}, "interfaces": {}} except Exception as e: - click.echo(f"⚠️ Error parsing properties for output {output}: {e}. Using default empty structure.") + click.echo(f"⚠️ Error parsing properties for output {output_type_value}: {e}. Using default empty structure.") output_schemas[output_name] = {"attributes": {}, "interfaces": {}} else: - click.echo(f"⚠️ Output {output} has no properties defined. Using default empty structure.") + click.echo(f"⚠️ Output {output_type_value} has no properties defined. Using default empty structure.") output_schemas[output_name] = {"attributes": {}, "interfaces": {}} inputs_var = generate_inputs_variable(output_schemas) diff --git a/ftf_cli/commands/get_output_details.py b/ftf_cli/commands/get_output_details.py deleted file mode 100644 index f099cff..0000000 --- a/ftf_cli/commands/get_output_details.py +++ /dev/null @@ -1,73 +0,0 @@ -import json -import os -import traceback -import click -import requests - -from ftf_cli.utils import is_logged_in, get_profile_with_priority - - -@click.command() # Add this decorator to register the function as a Click command -@click.option( - "-p", - "--profile", - default=get_profile_with_priority(), - help="The profile name to use (defaults to the current default profile)", -) -@click.option( - "-o", - "--output", - prompt="Name of the output type to get details for", - type=str, - help="The profile name to use or defaults to environment variable FACETS_PROFILE if set.", -) -def get_output_lookup_tree(profile, output): - """Get the lookup tree of a registered output type from the control plane""" - try: - # Check if profile is set - click.echo(f"Profile selected: {profile}") - credentials = is_logged_in(profile) - if not credentials: - raise click.UsageError( - f"❌ Not logged in under profile {profile}. Please login first." - ) - - # Extract credentials - control_plane_url = credentials["control_plane_url"] - username = credentials["username"] - token = credentials["token"] - - # Make a request to fetch output types - response = requests.get( - f"{control_plane_url}/cc-ui/v1/tf-outputs", auth=(username, token) - ) - - if response.status_code == 200: - registered_output_types = {} - for registered_output_type in response.json(): - registered_output_types[registered_output_type["name"]] = ( - registered_output_type - ) - - required_output_type = registered_output_types.get(output) - - if not required_output_type: - raise click.UsageError(f"❌ Output type {output} not found.") - - if "lookupTree" not in required_output_type: - lookup_tree = {"out": {"attributes": {}, "interfaces": {}}} - else: - lookup_tree = json.loads(required_output_type["lookupTree"]) - click.echo( - f"Output type lookup tree for {output}:\n{json.dumps(lookup_tree, indent=2, sort_keys=True)}" - ) - - else: - raise click.UsageError( - f"❌ Failed to fetch output types. Status code: {response.status_code}" - ) - except Exception as e: - traceback.print_exc() - raise click.UsageError( - f"❌ An error occurred while getting output details: {e}" - ) diff --git a/ftf_cli/commands/get_output_type_details.py b/ftf_cli/commands/get_output_type_details.py new file mode 100644 index 0000000..2c80466 --- /dev/null +++ b/ftf_cli/commands/get_output_type_details.py @@ -0,0 +1,122 @@ +import json +import os +import re +import traceback +import click +import requests + +from ftf_cli.utils import is_logged_in, get_profile_with_priority + + +def parse_namespace_and_name(output_type): + """Parse output_type into namespace and name components. + + Args: + output_type (str): Format should be @namespace/name + + Returns: + tuple: (namespace, name) + + Raises: + click.UsageError: If format is invalid + """ + pattern = r"(@[^/]+)/(.*)" + match = re.match(pattern, output_type) + if not match: + raise click.UsageError( + f"❌ Invalid format '{output_type}'. Expected format: @namespace/name (e.g., @outputs/vpc, @anuj/sqs)" + ) + return match.group(1), match.group(2) + + +@click.command() +@click.option( + "-p", + "--profile", + default=get_profile_with_priority(), + help="The profile name to use (defaults to the current default profile)", +) +@click.option( + "-o", + "--output-type", + prompt="Output type to get details for", + type=str, + help="The output type to get details for. Format: @namespace/name (e.g., @outputs/vpc, @anuj/sqs)", +) +def get_output_type_details(profile, output_type): + """Get the details of a registered output type from the control plane""" + try: + # Validate output_type format + namespace, name = parse_namespace_and_name(output_type) + + # Check if profile is set + click.echo(f"Profile selected: {profile}") + credentials = is_logged_in(profile) + if not credentials: + raise click.UsageError( + f"❌ Not logged in under profile {profile}. Please login first." + ) + + # Extract credentials + control_plane_url = credentials["control_plane_url"] + username = credentials["username"] + token = credentials["token"] + + # Make a request to fetch output types + response = requests.get( + f"{control_plane_url}/cc-ui/v1/tf-outputs", auth=(username, token) + ) + + if response.status_code == 200: + # Create lookup by (namespace, name) tuple + registered_outputs = {(output["namespace"], output["name"]): output for output in response.json()} + + required_output = registered_outputs.get((namespace, name)) + + if not required_output: + available_outputs = [f'{ns}/{nm}' for ns, nm in registered_outputs.keys()] + raise click.UsageError( + f"❌ Output type {output_type} not found. Available outputs: {available_outputs}" + ) + + click.echo(f"=== Output Type Details: {output_type} ===\n") + + # Display basic information + click.echo(f"Name: {required_output['name']}") + click.echo(f"Namespace: {required_output['namespace']}") + if 'source' in required_output: + click.echo(f"Source: {required_output['source']}") + if 'inferredFromModule' in required_output: + click.echo(f"Inferred from Module: {required_output['inferredFromModule']}") + + # Display properties if present + if "properties" in required_output and required_output["properties"]: + click.echo(f"\n--- Properties ---") + properties = required_output["properties"] + click.echo(json.dumps(properties, indent=2, sort_keys=True)) + else: + click.echo(f"\n--- Properties ---") + click.echo("No properties defined.") + + # Display lookup tree if present + if "lookupTree" in required_output and required_output["lookupTree"]: + click.echo(f"\n--- Lookup Tree ---") + try: + lookup_tree = json.loads(required_output["lookupTree"]) + click.echo(json.dumps(lookup_tree, indent=2, sort_keys=True)) + except json.JSONDecodeError: + click.echo("Invalid JSON in lookup tree.") + else: + click.echo(f"\n--- Lookup Tree ---") + lookup_tree = {"out": {"attributes": {}, "interfaces": {}}} + click.echo(json.dumps(lookup_tree, indent=2, sort_keys=True)) + + else: + raise click.UsageError( + f"❌ Failed to fetch output types. Status code: {response.status_code}" + ) + except Exception as e: + traceback.print_exc() + raise click.UsageError( + f"❌ An error occurred while getting output details: {e}" + ) diff --git a/ftf_cli/commands/get_output_types.py b/ftf_cli/commands/get_output_types.py index 4cd1334..2fe3262 100644 --- a/ftf_cli/commands/get_output_types.py +++ b/ftf_cli/commands/get_output_types.py @@ -5,7 +5,7 @@ from ftf_cli.utils import is_logged_in, get_profile_with_priority -@click.command() # Add this decorator to register the function as a Click command +@click.command() @click.option( "-p", "--profile", @@ -36,7 +36,9 @@ def get_output_types(profile): if response.status_code == 200: registered_output_types = [] for output_type in response.json(): - registered_output_types.append(output_type["name"]) + namespace = output_type.get("namespace", "@outputs") # Default fallback + name = output_type["name"] + registered_output_types.append(f"{namespace}/{name}") registered_output_types.sort() if len(registered_output_types) == 0: click.echo("No output types registered.") diff --git a/tests/commands/test_add_input.py b/tests/commands/test_add_input.py index 81fe220..d4f7bfc 100644 --- a/tests/commands/test_add_input.py +++ b/tests/commands/test_add_input.py @@ -61,6 +61,7 @@ def sample_api_response(self): return [ { "name": "database", + "namespace": "@outputs", "properties": { "type": "object", "properties": { @@ -98,6 +99,7 @@ def sample_api_response(self): }, { "name": "cache", + "namespace": "@outputs", "properties": { "type": "object", "properties": { @@ -112,6 +114,18 @@ def sample_api_response(self): }, }, }, + { + "name": "sqs", + "namespace": "@anuj", + "properties": { + "attributes": { + "queue_arn": {"type": "string"}, + "queue_url": {"type": "string"}, + "queue_name": {"type": "string"}, + }, + "interfaces": {}, + }, + }, ] @pytest.fixture @@ -158,7 +172,7 @@ def test_successful_add_input( "--description", "Database connection configuration", "--output-type", - "database", + "@outputs/database", "--profile", "test", ], @@ -203,7 +217,7 @@ def test_missing_files_error(self, runner): "--description", "Test description", "--output-type", - "database", + "@outputs/database", ], ) @@ -226,7 +240,7 @@ def test_not_logged_in_error(self, runner, temp_dir): "--description", "Test description", "--output-type", - "database", + "@outputs/database", ], ) @@ -256,7 +270,7 @@ def test_output_not_found_error( "--description", "Test description", "--output-type", - "nonexistent", # This output doesn't exist + "@outputs/nonexistent", # This output doesn't exist "--profile", "test", ], @@ -271,6 +285,7 @@ def test_malformed_properties_fallback(self, runner, mock_credentials, temp_dir) malformed_api_response = [ { "name": "malformed", + "namespace": "@outputs", "properties": { "type": "object", "properties": { @@ -300,7 +315,7 @@ def test_malformed_properties_fallback(self, runner, mock_credentials, temp_dir) "--description", "Test description", "--output-type", - "malformed", + "@outputs/malformed", "--profile", "test", ], @@ -317,6 +332,7 @@ def test_missing_properties_fallback(self, runner, mock_credentials, temp_dir): no_properties_response = [ { "name": "no_props", + "namespace": "@outputs", # Missing properties field entirely } ] @@ -340,7 +356,7 @@ def test_missing_properties_fallback(self, runner, mock_credentials, temp_dir): "--description", "Test description", "--output-type", - "no_props", + "@outputs/no_props", "--profile", "test", ], @@ -402,7 +418,7 @@ def test_existing_input_overwrite_warning( "--description", "Updated description", "--output-type", - "database", + "@outputs/database", "--profile", "test", ], @@ -433,13 +449,160 @@ def test_nonexistent_path_error(self, runner): "--description", "Test description", "--output-type", - "database", + "@outputs/database", ], ) assert result.exit_code == 2 # Click validation error assert "does not exist" in result.output + def test_invalid_output_type_format(self, runner, temp_dir): + """Test error when output type format is invalid.""" + with patch( + "ftf_cli.commands.add_input.is_logged_in", + return_value={ + "control_plane_url": "test", + "username": "test", + "token": "test", + }, + ): + # Test missing @ prefix + result = runner.invoke( + add_input, + [ + temp_dir, + "--name", + "test_input", + "--display-name", + "Test Input", + "--description", + "Test description", + "--output-type", + "outputs/database", # Missing @ prefix + ], + ) + + assert result.exit_code != 0 + assert "Invalid format" in result.output + assert "Expected format: @namespace/name" in result.output + + def test_invalid_output_type_format_missing_slash(self, runner, temp_dir): + """Test error when output type format is missing slash.""" + with patch( + "ftf_cli.commands.add_input.is_logged_in", + return_value={ + "control_plane_url": "test", + "username": "test", + "token": "test", + }, + ): + # Test missing slash + result = runner.invoke( + add_input, + [ + temp_dir, + "--name", + "test_input", + "--display-name", + "Test Input", + "--description", + "Test description", + "--output-type", + "@outputs_database", # Missing slash + ], + ) + + assert result.exit_code != 0 + assert "Invalid format" in result.output + assert "Expected format: @namespace/name" in result.output + + def test_custom_namespace_success( + self, runner, mock_credentials, temp_dir, sample_api_response + ): + """Test successfully adding an input with custom namespace.""" + with patch( + "ftf_cli.commands.add_input.is_logged_in", return_value=mock_credentials + ), patch("requests.get") as mock_requests: + + # Setup API response + mock_response = MagicMock() + mock_response.json.return_value = sample_api_response + mock_requests.return_value = mock_response + + result = runner.invoke( + add_input, + [ + temp_dir, + "--name", + "queue_connection", + "--display-name", + "Queue Connection", + "--description", + "SQS queue connection configuration", + "--output-type", + "@anuj/sqs", # Custom namespace + "--profile", + "test", + ], + ) + + # Print output for debugging + if result.exit_code != 0: + print(f"Exit code: {result.exit_code}") + print(f"Output: {result.output}") + if result.exception: + print(f"Exception: {result.exception}") + + # Assertions + assert result.exit_code == 0 + assert "✅ Input added to the" in result.output + + def test_direct_properties_structure(self, runner, mock_credentials, temp_dir): + """Test handling outputs with direct attributes/interfaces in properties.""" + # API response with direct structure (like the sqs example) + direct_structure_response = [ + { + "name": "sqs", + "namespace": "@anuj", + "properties": { + "attributes": { + "queue_arn": {"type": "string"}, + "queue_url": {"type": "string"}, + }, + "interfaces": {}, + }, + } + ] + + with patch( + "ftf_cli.commands.add_input.is_logged_in", return_value=mock_credentials + ), patch("requests.get") as mock_requests: + # Setup API response + mock_response = MagicMock() + mock_response.json.return_value = direct_structure_response + mock_requests.return_value = mock_response + + result = runner.invoke( + add_input, + [ + temp_dir, + "--name", + "test_input", + "--display-name", + "Test Input", + "--description", + "Test description", + "--output-type", + "@anuj/sqs", + "--profile", + "test", + ], + ) + + # Should succeed without warnings + assert result.exit_code == 0 + assert "does not have expected structure" not in result.output + class TestGenerateInputsVariable: """Test cases for generate_inputs_variable function.""" diff --git a/tests/commands/test_get_output_type_details.py b/tests/commands/test_get_output_type_details.py new file mode 100644 index 0000000..6988783 --- /dev/null +++ b/tests/commands/test_get_output_type_details.py @@ -0,0 +1,261 @@ +import pytest +import json +from unittest.mock import patch, MagicMock +from click.testing import CliRunner + +from ftf_cli.commands.get_output_type_details import get_output_type_details + + +class TestGetOutputTypeDetailsCommand: + """Test cases for get_output_type_details command.""" + + @pytest.fixture + def runner(self): + return CliRunner() + + @pytest.fixture + def mock_credentials(self): + return { + "control_plane_url": "https://test.example.com", + "username": "testuser", + "token": "testtoken", + } + + @pytest.fixture + def sample_api_response(self): + return [ + { + "name": "database", + "namespace": "@outputs", + "source": "CUSTOM", + "inferredFromModule": False, + "properties": { + "type": "object", + "properties": { + "attributes": { + "type": "object", + "properties": { + "host": {"type": "string"}, + "port": {"type": "number"}, + }, + }, + "interfaces": { + "type": "object", + "properties": { + "reader": {"type": "object"}, + }, + }, + }, + }, + "lookupTree": '{"out": {"attributes": {"host": {}, "port": {}}, "interfaces": {"reader": {}}}}', + }, + { + "name": "sqs", + "namespace": "@anuj", + "source": "CUSTOM", + "inferredFromModule": True, + "properties": { + "attributes": { + "queue_arn": {"type": "string"}, + "queue_url": {"type": "string"}, + }, + "interfaces": {}, + }, + "lookupTree": '{"out": {"attributes": {"queue_arn": {}, "queue_url": {}}, "interfaces": {}}}', + }, + ] + + def test_successful_get_output_type_details( + self, runner, mock_credentials, sample_api_response + ): + """Test successfully getting output type details with namespace.""" + with patch( + "ftf_cli.commands.get_output_type_details.is_logged_in", return_value=mock_credentials + ), patch("requests.get") as mock_requests: + + # Setup API response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = sample_api_response + mock_requests.return_value = mock_response + + result = runner.invoke( + get_output_type_details, + ["--output-type", "@outputs/database", "--profile", "test"] + ) + + # Print output for debugging + if result.exit_code != 0: + print(f"Exit code: {result.exit_code}") + print(f"Output: {result.output}") + if result.exception: + print(f"Exception: {result.exception}") + + # Assertions + assert result.exit_code == 0 + assert "=== Output Type Details: @outputs/database ===" in result.output + assert "Name: database" in result.output + assert "Namespace: @outputs" in result.output + assert "Source: CUSTOM" in result.output + assert "Inferred from Module: False" in result.output + assert "--- Properties ---" in result.output + assert "--- Lookup Tree ---" in result.output + assert '"host"' in result.output + assert '"port"' in result.output + + def test_custom_namespace_output( + self, runner, mock_credentials, sample_api_response + ): + """Test getting details for custom namespace output.""" + with patch( + "ftf_cli.commands.get_output_type_details.is_logged_in", return_value=mock_credentials + ), patch("requests.get") as mock_requests: + + # Setup API response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = sample_api_response + mock_requests.return_value = mock_response + + result = runner.invoke( + get_output_type_details, + ["--output-type", "@anuj/sqs", "--profile", "test"] + ) + + assert result.exit_code == 0 + assert "=== Output Type Details: @anuj/sqs ===" in result.output + assert "Name: sqs" in result.output + assert "Namespace: @anuj" in result.output + assert "queue_arn" in result.output + + def test_output_not_found( + self, runner, mock_credentials, sample_api_response + ): + """Test error when output type is not found.""" + with patch( + "ftf_cli.commands.get_output_type_details.is_logged_in", return_value=mock_credentials + ), patch("requests.get") as mock_requests: + + # Setup API response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = sample_api_response + mock_requests.return_value = mock_response + + result = runner.invoke( + get_output_type_details, + ["--output-type", "@outputs/nonexistent", "--profile", "test"] + ) + + assert result.exit_code != 0 + assert "Output type @outputs/nonexistent not found" in result.output + assert "Available outputs:" in result.output + + def test_invalid_output_type_format(self, runner, mock_credentials): + """Test error when output type format is invalid.""" + with patch( + "ftf_cli.commands.get_output_type_details.is_logged_in", return_value=mock_credentials + ): + result = runner.invoke( + get_output_type_details, + ["--output-type", "invalid_format", "--profile", "test"] + ) + + assert result.exit_code != 0 + assert "Invalid format" in result.output + assert "Expected format: @namespace/name" in result.output + + def test_missing_properties(self, runner, mock_credentials): + """Test handling output with missing properties.""" + api_response_no_properties = [ + { + "name": "no_props", + "namespace": "@outputs", + "source": "CUSTOM", + # Missing properties + "lookupTree": '{"out": {"attributes": {}, "interfaces": {}}}', + } + ] + + with patch( + "ftf_cli.commands.get_output_type_details.is_logged_in", return_value=mock_credentials + ), patch("requests.get") as mock_requests: + + # Setup API response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = api_response_no_properties + mock_requests.return_value = mock_response + + result = runner.invoke( + get_output_type_details, + ["--output-type", "@outputs/no_props", "--profile", "test"] + ) + + assert result.exit_code == 0 + assert "No properties defined." in result.output + + def test_missing_lookup_tree(self, runner, mock_credentials): + """Test handling output with missing lookup tree.""" + api_response_no_lookup = [ + { + "name": "no_lookup", + "namespace": "@outputs", + "properties": {"type": "object"}, + # Missing lookupTree + } + ] + + with patch( + "ftf_cli.commands.get_output_type_details.is_logged_in", return_value=mock_credentials + ), patch("requests.get") as mock_requests: + + # Setup API response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = api_response_no_lookup + mock_requests.return_value = mock_response + + result = runner.invoke( + get_output_type_details, + ["--output-type", "@outputs/no_lookup", "--profile", "test"] + ) + + assert result.exit_code == 0 + assert "--- Lookup Tree ---" in result.output + # Should show default empty structure + assert '"attributes": {}' in result.output + assert '"interfaces": {}' in result.output + + def test_not_logged_in_error(self, runner): + """Test error when user is not logged in.""" + with patch( + "ftf_cli.commands.get_output_type_details.is_logged_in", return_value=False + ): + result = runner.invoke( + get_output_type_details, + ["--output-type", "@outputs/database", "--profile", "test"] + ) + + assert result.exit_code != 0 + assert "Not logged in" in result.output + + def test_api_error(self, runner, mock_credentials): + """Test error when API call fails.""" + with patch( + "ftf_cli.commands.get_output_type_details.is_logged_in", return_value=mock_credentials + ), patch("requests.get") as mock_requests: + + # Setup failed API response + mock_response = MagicMock() + mock_response.status_code = 500 + mock_requests.return_value = mock_response + + result = runner.invoke( + get_output_type_details, + ["--output-type", "@outputs/database", "--profile", "test"] + ) + + assert result.exit_code != 0 + assert "Failed to fetch output types" in result.output + assert "Status code: 500" in result.output diff --git a/tests/commands/test_get_output_types.py b/tests/commands/test_get_output_types.py new file mode 100644 index 0000000..565d102 --- /dev/null +++ b/tests/commands/test_get_output_types.py @@ -0,0 +1,161 @@ +import pytest +from unittest.mock import patch, MagicMock +from click.testing import CliRunner + +from ftf_cli.commands.get_output_types import get_output_types + + +class TestGetOutputTypesCommand: + """Test cases for get_output_types command.""" + + @pytest.fixture + def runner(self): + return CliRunner() + + @pytest.fixture + def mock_credentials(self): + return { + "control_plane_url": "https://test.example.com", + "username": "testuser", + "token": "testtoken", + } + + @pytest.fixture + def sample_api_response(self): + return [ + { + "name": "database", + "namespace": "@outputs", + "properties": {"type": "object"} + }, + { + "name": "cache", + "namespace": "@outputs", + "properties": {"type": "object"} + }, + { + "name": "sqs", + "namespace": "@anuj", + "properties": {"type": "object"} + }, + ] + + def test_successful_get_output_types( + self, runner, mock_credentials, sample_api_response + ): + """Test successfully getting output types with namespaces.""" + with patch( + "ftf_cli.commands.get_output_types.is_logged_in", return_value=mock_credentials + ), patch("requests.get") as mock_requests: + + # Setup API response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = sample_api_response + mock_requests.return_value = mock_response + + result = runner.invoke( + get_output_types, + ["--profile", "test"] + ) + + # Print output for debugging + if result.exit_code != 0: + print(f"Exit code: {result.exit_code}") + print(f"Output: {result.output}") + if result.exception: + print(f"Exception: {result.exception}") + + # Assertions + assert result.exit_code == 0 + assert "Registered output types:" in result.output + assert "- @anuj/sqs" in result.output + assert "- @outputs/cache" in result.output + assert "- @outputs/database" in result.output + + # Verify API call was made + mock_requests.assert_called_once_with( + "https://test.example.com/cc-ui/v1/tf-outputs", + auth=("testuser", "testtoken"), + ) + + def test_no_output_types(self, runner, mock_credentials): + """Test when no output types are registered.""" + with patch( + "ftf_cli.commands.get_output_types.is_logged_in", return_value=mock_credentials + ), patch("requests.get") as mock_requests: + + # Setup empty API response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [] + mock_requests.return_value = mock_response + + result = runner.invoke( + get_output_types, + ["--profile", "test"] + ) + + assert result.exit_code == 0 + assert "No output types registered." in result.output + + def test_missing_namespace_fallback(self, runner, mock_credentials): + """Test fallback to @outputs when namespace is missing.""" + api_response_no_namespace = [ + { + "name": "legacy_output", + # Missing namespace field + "properties": {"type": "object"} + } + ] + + with patch( + "ftf_cli.commands.get_output_types.is_logged_in", return_value=mock_credentials + ), patch("requests.get") as mock_requests: + + # Setup API response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = api_response_no_namespace + mock_requests.return_value = mock_response + + result = runner.invoke( + get_output_types, + ["--profile", "test"] + ) + + assert result.exit_code == 0 + assert "- @outputs/legacy_output" in result.output + + def test_not_logged_in_error(self, runner): + """Test error when user is not logged in.""" + with patch( + "ftf_cli.commands.get_output_types.is_logged_in", return_value=False + ): + result = runner.invoke( + get_output_types, + ["--profile", "test"] + ) + + assert result.exit_code != 0 + assert "Not logged in" in result.output + + def test_api_error(self, runner, mock_credentials): + """Test error when API call fails.""" + with patch( + "ftf_cli.commands.get_output_types.is_logged_in", return_value=mock_credentials + ), patch("requests.get") as mock_requests: + + # Setup failed API response + mock_response = MagicMock() + mock_response.status_code = 500 + mock_requests.return_value = mock_response + + result = runner.invoke( + get_output_types, + ["--profile", "test"] + ) + + assert result.exit_code != 0 + assert "Failed to fetch output types" in result.output + assert "Status code: 500" in result.output From 56e7c49e3d31385113a19f3d13110a04d511080d Mon Sep 17 00:00:00 2001 From: Anuj Hydrabadi Date: Mon, 30 Jun 2025 14:49:57 +0530 Subject: [PATCH 2/3] Fix transform_properties_to_terraform for direct properties structure - Handle both JSON Schema format and direct properties format - Fix issue where @anuj/sqs type outputs generated 'any' instead of proper types - Add support for empty interfaces/attributes objects --- ftf_cli/utils.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/ftf_cli/utils.py b/ftf_cli/utils.py index 552f74c..3fa0883 100644 --- a/ftf_cli/utils.py +++ b/ftf_cli/utils.py @@ -843,7 +843,7 @@ def transform_properties_to_terraform(properties_obj, level=1): Transform JSON Schema properties directly to Terraform-compatible schema. Args: - properties_obj: JSON Schema properties object + properties_obj: JSON Schema properties object or direct properties dict level: Current indentation level Returns: @@ -856,7 +856,7 @@ def transform_properties_to_terraform(properties_obj, level=1): if not isinstance(properties_obj, dict): return "any" - # Handle object with properties + # Handle JSON Schema object with type and properties if properties_obj.get("type") == "object" and "properties" in properties_obj: transformed_items = [] for key, value in properties_obj["properties"].items(): @@ -870,6 +870,24 @@ def transform_properties_to_terraform(properties_obj, level=1): object_block = f"object({{\n{joined_items}\n{current_indent}}})" return object_block + # Handle direct properties object (new case for @anuj/sqs type data) + elif "type" not in properties_obj and all(isinstance(v, dict) and "type" in v for v in properties_obj.values() if v): + # This is a direct properties object like {"queue_arn": {"type": "string"}, ...} + if not properties_obj: # Empty object + return "object({})" + + transformed_items = [] + for key, value in properties_obj.items(): + transformed_value = transform_properties_to_terraform(value, level + 1) + transformed_items.append(f"{next_indent}{key} = {transformed_value}") + + # Join the transformed items with a comma and newline + joined_items = ",\n".join(transformed_items) + + # Construct the object block with proper indentation + object_block = f"object({{\n{joined_items}\n{current_indent}}})" + return object_block + # Handle arrays elif properties_obj.get("type") == "array": if "items" in properties_obj: @@ -885,5 +903,7 @@ def transform_properties_to_terraform(properties_obj, level=1): elif properties_obj.get("type") == "boolean": return "bool" else: - # Fallback for unknown types + # Fallback for unknown types or empty objects + if not properties_obj: + return "object({})" return "any" \ No newline at end of file From eab05707b68182a82dfbfca8e1a329651daa2efc Mon Sep 17 00:00:00 2001 From: Anuj Hydrabadi Date: Mon, 30 Jun 2025 15:01:38 +0530 Subject: [PATCH 3/3] Review changes and misc fixes --- ftf_cli/commands/add_input.py | 24 ++----------- ftf_cli/commands/get_output_type_details.py | 35 ++++--------------- ftf_cli/utils.py | 23 +++++++++++- tests/commands/test_add_input.py | 8 ++--- .../commands/test_get_output_type_details.py | 9 +++-- tests/commands/test_get_output_types.py | 4 +-- 6 files changed, 40 insertions(+), 63 deletions(-) diff --git a/ftf_cli/commands/add_input.py b/ftf_cli/commands/add_input.py index d334a34..f7d2a07 100644 --- a/ftf_cli/commands/add_input.py +++ b/ftf_cli/commands/add_input.py @@ -14,30 +14,10 @@ transform_properties_to_terraform, ensure_formatting_for_object, get_profile_with_priority, + parse_namespace_and_name, ) -def parse_namespace_and_name(output_type): - """Parse output_type into namespace and name components. - - Args: - output_type (str): Format should be @namespace/name - - Returns: - tuple: (namespace, name) - - Raises: - click.UsageError: If format is invalid - """ - pattern = r"(@[^/]+)/(.*)" - match = re.match(pattern, output_type) - if not match: - raise click.UsageError( - f"❌ Invalid format '{output_type}'. Expected format: @namespace/name (e.g., @outputs/vpc, @anuj/sqs)" - ) - return match.group(1), match.group(2) - - @click.command() @click.argument("path", type=click.Path(exists=True)) @click.option( @@ -72,7 +52,7 @@ def parse_namespace_and_name(output_type): "--output-type", prompt="Output Type", type=str, - help="The type of registered output to be added as input for terraform module. Format: @namespace/name (e.g., @outputs/vpc, @anuj/sqs)", + help="The type of registered output to be added as input for terraform module. Format: @namespace/name (e.g., @outputs/vpc, @custom/sqs)", ) def add_input(path, profile, name, display_name, description, output_type): """Add an existing registered output as a input in facets.yaml and populate the attributes in variables.tf exposed by selected output.""" diff --git a/ftf_cli/commands/get_output_type_details.py b/ftf_cli/commands/get_output_type_details.py index 2c80466..8b8ded2 100644 --- a/ftf_cli/commands/get_output_type_details.py +++ b/ftf_cli/commands/get_output_type_details.py @@ -1,32 +1,9 @@ import json -import os -import re import traceback import click import requests -from ftf_cli.utils import is_logged_in, get_profile_with_priority - - -def parse_namespace_and_name(output_type): - """Parse output_type into namespace and name components. - - Args: - output_type (str): Format should be @namespace/name - - Returns: - tuple: (namespace, name) - - Raises: - click.UsageError: If format is invalid - """ - pattern = r"(@[^/]+)/(.*)" - match = re.match(pattern, output_type) - if not match: - raise click.UsageError( - f"❌ Invalid format '{output_type}'. Expected format: @namespace/name (e.g., @outputs/vpc, @anuj/sqs)" - ) - return match.group(1), match.group(2) +from ftf_cli.utils import is_logged_in, get_profile_with_priority, parse_namespace_and_name @click.command() @@ -41,7 +18,7 @@ def parse_namespace_and_name(output_type): "--output-type", prompt="Output type to get details for", type=str, - help="The output type to get details for. Format: @namespace/name (e.g., @outputs/vpc, @anuj/sqs)", + help="The output type to get details for. Format: @namespace/name (e.g., @outputs/vpc, @custom/sqs)", ) def get_output_type_details(profile, output_type): """Get the details of a registered output type from the control plane""" @@ -91,23 +68,23 @@ def get_output_type_details(profile, output_type): # Display properties if present if "properties" in required_output and required_output["properties"]: - click.echo(f"\n--- Properties ---") + click.echo("\n--- Properties ---") properties = required_output["properties"] click.echo(json.dumps(properties, indent=2, sort_keys=True)) else: - click.echo(f"\n--- Properties ---") + click.echo("\n--- Properties ---") click.echo("No properties defined.") # Display lookup tree if present if "lookupTree" in required_output and required_output["lookupTree"]: - click.echo(f"\n--- Lookup Tree ---") + click.echo("\n--- Lookup Tree ---") try: lookup_tree = json.loads(required_output["lookupTree"]) click.echo(json.dumps(lookup_tree, indent=2, sort_keys=True)) except json.JSONDecodeError: click.echo("Invalid JSON in lookup tree.") else: - click.echo(f"\n--- Lookup Tree ---") + click.echo("\n--- Lookup Tree ---") lookup_tree = {"out": {"attributes": {}, "interfaces": {}}} click.echo(json.dumps(lookup_tree, indent=2, sort_keys=True)) diff --git a/ftf_cli/utils.py b/ftf_cli/utils.py index 3fa0883..6fbd72b 100644 --- a/ftf_cli/utils.py +++ b/ftf_cli/utils.py @@ -18,6 +18,27 @@ REQUIRED_TF_FACETS_VARS = ["instance", "instance_name", "environment", "inputs"] +def parse_namespace_and_name(output_type): + """Parse output_type into namespace and name components. + + Args: + output_type (str): Format should be @namespace/name + + Returns: + tuple: (namespace, name) + + Raises: + click.UsageError: If format is invalid + """ + pattern = r"(@[^/]+)/(.*)" + match = re.match(pattern, output_type) + if not match: + raise click.UsageError( + f"❌ Invalid format '{output_type}'. Expected format: @namespace/name (e.g., @outputs/vpc, @custom/sqs)" + ) + return match.group(1), match.group(2) + + def validate_facets_yaml(path, filename="facets.yaml"): """Validate the existence and format of specified facets yaml file in the given path.""" yaml_path = os.path.join(path, filename) @@ -870,7 +891,7 @@ def transform_properties_to_terraform(properties_obj, level=1): object_block = f"object({{\n{joined_items}\n{current_indent}}})" return object_block - # Handle direct properties object (new case for @anuj/sqs type data) + # Handle direct properties object (new case for @custom/sqs type data) elif "type" not in properties_obj and all(isinstance(v, dict) and "type" in v for v in properties_obj.values() if v): # This is a direct properties object like {"queue_arn": {"type": "string"}, ...} if not properties_obj: # Empty object diff --git a/tests/commands/test_add_input.py b/tests/commands/test_add_input.py index d4f7bfc..f00c772 100644 --- a/tests/commands/test_add_input.py +++ b/tests/commands/test_add_input.py @@ -116,7 +116,7 @@ def sample_api_response(self): }, { "name": "sqs", - "namespace": "@anuj", + "namespace": "@custom", "properties": { "attributes": { "queue_arn": {"type": "string"}, @@ -540,7 +540,7 @@ def test_custom_namespace_success( "--description", "SQS queue connection configuration", "--output-type", - "@anuj/sqs", # Custom namespace + "@custom/sqs", # Custom namespace "--profile", "test", ], @@ -563,7 +563,7 @@ def test_direct_properties_structure(self, runner, mock_credentials, temp_dir): direct_structure_response = [ { "name": "sqs", - "namespace": "@anuj", + "namespace": "@custom", "properties": { "attributes": { "queue_arn": {"type": "string"}, @@ -593,7 +593,7 @@ def test_direct_properties_structure(self, runner, mock_credentials, temp_dir): "--description", "Test description", "--output-type", - "@anuj/sqs", + "@custom/sqs", "--profile", "test", ], diff --git a/tests/commands/test_get_output_type_details.py b/tests/commands/test_get_output_type_details.py index 6988783..066632c 100644 --- a/tests/commands/test_get_output_type_details.py +++ b/tests/commands/test_get_output_type_details.py @@ -1,5 +1,4 @@ import pytest -import json from unittest.mock import patch, MagicMock from click.testing import CliRunner @@ -51,7 +50,7 @@ def sample_api_response(self): }, { "name": "sqs", - "namespace": "@anuj", + "namespace": "@custom", "source": "CUSTOM", "inferredFromModule": True, "properties": { @@ -119,13 +118,13 @@ def test_custom_namespace_output( result = runner.invoke( get_output_type_details, - ["--output-type", "@anuj/sqs", "--profile", "test"] + ["--output-type", "@custom/sqs", "--profile", "test"] ) assert result.exit_code == 0 - assert "=== Output Type Details: @anuj/sqs ===" in result.output + assert "=== Output Type Details: @custom/sqs ===" in result.output assert "Name: sqs" in result.output - assert "Namespace: @anuj" in result.output + assert "Namespace: @custom" in result.output assert "queue_arn" in result.output def test_output_not_found( diff --git a/tests/commands/test_get_output_types.py b/tests/commands/test_get_output_types.py index 565d102..9a66cce 100644 --- a/tests/commands/test_get_output_types.py +++ b/tests/commands/test_get_output_types.py @@ -35,7 +35,7 @@ def sample_api_response(self): }, { "name": "sqs", - "namespace": "@anuj", + "namespace": "@custom", "properties": {"type": "object"} }, ] @@ -69,7 +69,7 @@ def test_successful_get_output_types( # Assertions assert result.exit_code == 0 assert "Registered output types:" in result.output - assert "- @anuj/sqs" in result.output + assert "- @custom/sqs" in result.output assert "- @outputs/cache" in result.output assert "- @outputs/database" in result.output