From 4d5c03bdfcbbe5eb456d5fe6037ad3952c5b990f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20J=2E=20Gajda?= Date: Sat, 17 Jan 2026 21:47:07 +0100 Subject: [PATCH 1/2] feat: Add enhanced completion with caching for OCI CLI - Implement caching mechanism for compartment IDs and OCIDs - Add CompartmentCompleter class with 24-hour TTL cache - Cache compartment listings from 'oci iam compartment list' - Add cache management commands: status, clear, refresh - Integrate enhanced completions into interactive mode - Store cache in ~/.oci/completion_cache/ directory - Improve autocompletion performance for --compartment-id parameter --- src/interactive/cli_interactive.py | 4 + .../enable_enhanced_completions.py | 42 +++ src/interactive/enhanced_completions.py | 356 ++++++++++++++++++ src/oci_cli/cli_setup.py | 80 ++++ .../custom_types/cli_completion_cache.py | 109 ++++++ 5 files changed, 591 insertions(+) create mode 100644 src/interactive/enable_enhanced_completions.py create mode 100644 src/interactive/enhanced_completions.py create mode 100644 src/oci_cli/custom_types/cli_completion_cache.py diff --git a/src/interactive/cli_interactive.py b/src/interactive/cli_interactive.py index 9021eb5e..65fb440d 100644 --- a/src/interactive/cli_interactive.py +++ b/src/interactive/cli_interactive.py @@ -17,6 +17,7 @@ from interactive.key_bindings import override_key_binding from interactive.prompt_session import create_oci_prompt_session +from interactive.enable_enhanced_completions import enable_enhanced_completions import os import sys @@ -24,6 +25,9 @@ def start_interactive_shell(ctx, top_level_invoke_name, service_mapping, dynamic_loader): + # Enable enhanced completions with caching + enable_enhanced_completions() + # print help message like you tube vedios for first time print_suggestion_message() # Getting the commands before --cli-auto-prompt, for example if the user execute oci --profile X compute instance --cli-auto-prompt, diff --git a/src/interactive/enable_enhanced_completions.py b/src/interactive/enable_enhanced_completions.py new file mode 100644 index 00000000..a551c5d9 --- /dev/null +++ b/src/interactive/enable_enhanced_completions.py @@ -0,0 +1,42 @@ +# coding: utf-8 +# Copyright (c) 2016, 2026, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. + +""" +Enable enhanced completions with caching in the OCI CLI. + +This module patches the existing completion system to add caching support. +""" + +import os +from pathlib import Path + +def enable_enhanced_completions(): + """ + Enable enhanced completions by modifying the existing completion imports. + + This function should be called early in the CLI initialization. + """ + try: + # Import both modules + import interactive.oci_resources_completions as orig_module + from interactive.enhanced_completions import get_enhanced_oci_resources + + # Save original function if not already saved + if not hasattr(orig_module, '_original_get_oci_resources'): + orig_module._original_get_oci_resources = orig_module.get_oci_resources + + # Replace with enhanced version + orig_module.get_oci_resources = get_enhanced_oci_resources + + # Set environment variable to indicate enhanced completions are enabled + os.environ['OCI_CLI_ENHANCED_COMPLETIONS'] = '1' + + return True + except ImportError: + return False + + +def is_enhanced_completions_enabled(): + """Check if enhanced completions are enabled.""" + return os.environ.get('OCI_CLI_ENHANCED_COMPLETIONS') == '1' \ No newline at end of file diff --git a/src/interactive/enhanced_completions.py b/src/interactive/enhanced_completions.py new file mode 100644 index 00000000..2710eea5 --- /dev/null +++ b/src/interactive/enhanced_completions.py @@ -0,0 +1,356 @@ +# coding: utf-8 +# Copyright (c) 2016, 2026, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. + +""" +Enhanced completion module for OCI CLI with caching support for compartments and OCIDs. + +This module provides: +1. Caching of compartment listings from 'oci iam compartment list' +2. Smart completion for --compartment-id parameters +3. Persistent cache storage for frequently used OCIDs +4. Time-based cache invalidation +""" + +import json +import os +import time +from datetime import datetime, timedelta +from pathlib import Path +from typing import Dict, List, Optional, Any, Tuple +from prompt_toolkit.completion import Completion +from oci_cli import cli_util +from interactive.error_messages import get_error_message +import hashlib + + +class OCIDCache: + """ + Manages a persistent cache for OCIDs to speed up autocompletion. + """ + + CACHE_DIR = os.path.join(os.path.expanduser("~"), ".oci", "completion_cache") + CACHE_TTL_HOURS = 24 # Default cache validity period + + def __init__(self, cache_ttl_hours: int = 24): + """ + Initialize the OCID cache. + + Args: + cache_ttl_hours: Number of hours before cache expires + """ + self.cache_dir = Path(self.CACHE_DIR) + self.cache_ttl = timedelta(hours=cache_ttl_hours) + self._ensure_cache_dir() + + def _ensure_cache_dir(self): + """Create cache directory if it doesn't exist.""" + self.cache_dir.mkdir(parents=True, exist_ok=True) + + def _get_cache_file(self, cache_key: str) -> Path: + """Get the cache file path for a given key.""" + # Use hash to ensure valid filename + hashed_key = hashlib.md5(cache_key.encode()).hexdigest() + return self.cache_dir / f"{hashed_key}.json" + + def _is_cache_valid(self, cache_file: Path) -> bool: + """Check if cache file is still valid based on TTL.""" + if not cache_file.exists(): + return False + + modified_time = datetime.fromtimestamp(cache_file.stat().st_mtime) + return datetime.now() - modified_time < self.cache_ttl + + def get(self, cache_key: str) -> Optional[List[Dict[str, Any]]]: + """ + Retrieve cached data if available and valid. + + Args: + cache_key: Unique identifier for the cached data + + Returns: + Cached data if valid, None otherwise + """ + cache_file = self._get_cache_file(cache_key) + + if not self._is_cache_valid(cache_file): + return None + + try: + with open(cache_file, 'r') as f: + data = json.load(f) + return data.get('items', []) + except (json.JSONDecodeError, IOError): + # Invalid cache file, remove it + cache_file.unlink(missing_ok=True) + return None + + def set(self, cache_key: str, items: List[Dict[str, Any]]): + """ + Store data in cache. + + Args: + cache_key: Unique identifier for the cached data + items: List of items to cache + """ + cache_file = self._get_cache_file(cache_key) + + try: + cache_data = { + 'timestamp': datetime.now().isoformat(), + 'items': items + } + with open(cache_file, 'w') as f: + json.dump(cache_data, f, indent=2) + except IOError: + # Silently fail if we can't write cache + pass + + def clear(self): + """Clear all cached data.""" + for cache_file in self.cache_dir.glob("*.json"): + cache_file.unlink(missing_ok=True) + + def clear_expired(self): + """Remove only expired cache files.""" + for cache_file in self.cache_dir.glob("*.json"): + if not self._is_cache_valid(cache_file): + cache_file.unlink(missing_ok=True) + + +class CompartmentCompleter: + """ + Provides compartment-aware completion for OCI CLI. + """ + + def __init__(self, ctx, cache_ttl_hours: int = 24): + """ + Initialize the compartment completer. + + Args: + ctx: Click context object + cache_ttl_hours: Hours before cache expires + """ + self.ctx = ctx + self.cache = OCIDCache(cache_ttl_hours) + + def _get_profile_key(self) -> str: + """Generate a unique key based on the current OCI profile.""" + profile = self.ctx.obj.get('profile', 'DEFAULT') + region = self.ctx.obj.get('region', 'us-phoenix-1') + return f"profile_{profile}_region_{region}" + + def _fetch_compartments(self) -> List[Dict[str, Any]]: + """ + Fetch compartments from OCI API. + + Returns: + List of compartment dictionaries + """ + try: + identity_client = cli_util.build_client("identity", "identity", self.ctx) + tenancy_id = cli_util.get_tenancy_from_config(self.ctx) + + # Get all compartments in tenancy + compartments = [] + + # Add root compartment (tenancy) + root_compartment = identity_client.get_compartment(compartment_id=tenancy_id) + compartments.append({ + 'id': root_compartment.data.id, + 'name': root_compartment.data.name, + 'description': root_compartment.data.description or "Root compartment", + 'lifecycle_state': root_compartment.data.lifecycle_state, + 'is_root': True + }) + + # List all child compartments + response = identity_client.list_compartments( + compartment_id=tenancy_id, + compartment_id_in_subtree=True, + lifecycle_state='ACTIVE', + limit=1000 + ) + + for compartment in response.data: + compartments.append({ + 'id': compartment.id, + 'name': compartment.name, + 'description': compartment.description or "", + 'lifecycle_state': compartment.lifecycle_state, + 'is_root': False + }) + + return compartments + except Exception as e: + # Log error but don't crash + print(f"Warning: Could not fetch compartments: {e}") + return [] + + def get_compartment_completions( + self, + word_before_cursor: str, + bottom_toolbar=None, + sub_string: str = "" + ) -> List[Completion]: + """ + Get compartment completions with caching support. + + Args: + word_before_cursor: Current partial input + bottom_toolbar: Optional toolbar for error messages + sub_string: Substring to filter compartments + + Returns: + List of Completion objects + """ + cache_key = f"compartments_{self._get_profile_key()}" + + # Try to get from cache first + compartments = self.cache.get(cache_key) + + if compartments is None: + # Cache miss, fetch from API + if bottom_toolbar: + bottom_toolbar.set_toolbar_text("Fetching compartments...", is_error=False) + + compartments = self._fetch_compartments() + + if compartments: + # Store in cache + self.cache.set(cache_key, compartments) + elif bottom_toolbar: + bottom_toolbar.set_toolbar_text( + get_error_message("no_items_found"), + is_error=True + ) + return [] + + # Filter and create completions + completions = [] + for compartment in compartments: + # Filter by substring if provided + if sub_string and sub_string.lower() not in compartment['name'].lower(): + continue + + # Create display text with additional info + display_text = compartment['name'] + if compartment.get('is_root'): + display_text += " (root)" + if compartment.get('description'): + display_text += f" - {compartment['description'][:50]}" + + completions.append( + Completion( + compartment['id'], + -len(word_before_cursor), + display=display_text + ) + ) + + return completions + + +def get_enhanced_oci_resources( + ctx, + param_name: str, + word_before_cursor: str, + bottom_toolbar=None, + sub_string: str = "" +) -> List[Completion]: + """ + Enhanced resource completion with caching support. + + This function extends the existing get_oci_resources with: + 1. Compartment caching for --compartment-id + 2. Smarter filtering and sorting + 3. Better error handling + + Args: + ctx: Click context + param_name: Parameter name (e.g., '--compartment-id') + word_before_cursor: Current partial input + bottom_toolbar: Optional toolbar for status messages + sub_string: Substring filter + + Returns: + List of Completion objects + """ + # Handle compartment-id specially with caching + if param_name in ['--compartment-id', '-c']: + completer = CompartmentCompleter(ctx) + return completer.get_compartment_completions( + word_before_cursor, + bottom_toolbar, + sub_string + ) + + # For other resources, fall back to the original implementation + # Import here to avoid circular dependency + from interactive.oci_resources_completions import get_oci_resources + return get_oci_resources(ctx, param_name, word_before_cursor, bottom_toolbar, sub_string) + + +def clear_completion_cache(): + """ + Clear all cached completion data. + + This can be called when the user wants to force a refresh. + """ + cache = OCIDCache() + cache.clear() + print("Completion cache cleared successfully.") + + +def clear_expired_cache(): + """ + Clear only expired cache entries. + + This should be called periodically to clean up old data. + """ + cache = OCIDCache() + cache.clear_expired() + + +# Additional helper functions for integration with existing CLI + +def setup_enhanced_completions(): + """ + Setup enhanced completions by patching the existing completion system. + + This function should be called during CLI initialization. + """ + # Import and patch the existing module + import interactive.oci_resources_completions as orig_module + + # Save original function + if not hasattr(orig_module, '_original_get_oci_resources'): + orig_module._original_get_oci_resources = orig_module.get_oci_resources + + # Replace with enhanced version + orig_module.get_oci_resources = get_enhanced_oci_resources + + print("Enhanced completions with caching enabled.") + + +def get_cache_info() -> Dict[str, Any]: + """ + Get information about the current cache state. + + Returns: + Dictionary with cache statistics + """ + cache = OCIDCache() + cache_files = list(cache.cache_dir.glob("*.json")) + + total_size = sum(f.stat().st_size for f in cache_files) + valid_count = sum(1 for f in cache_files if cache._is_cache_valid(f)) + + return { + 'cache_directory': str(cache.cache_dir), + 'total_files': len(cache_files), + 'valid_files': valid_count, + 'expired_files': len(cache_files) - valid_count, + 'total_size_bytes': total_size, + 'ttl_hours': cache.cache_ttl.total_seconds() / 3600 + } \ No newline at end of file diff --git a/src/oci_cli/cli_setup.py b/src/oci_cli/cli_setup.py index d6f863ed..5d741248 100644 --- a/src/oci_cli/cli_setup.py +++ b/src/oci_cli/cli_setup.py @@ -636,6 +636,86 @@ def setup_autocomplete_non_windows(): return +@setup_group.command('completion-cache-status', help="""Show current completion cache status and statistics.""") +@cli_util.help_option +@click.pass_context +def completion_cache_status(ctx): + """Display information about the completion cache.""" + from interactive.enhanced_completions import get_cache_info + + info = get_cache_info() + click.echo("Completion Cache Status") + click.echo("=" * 50) + click.echo(f"Cache Directory: {info['cache_directory']}") + click.echo(f"Total Cache Files: {info['total_files']}") + click.echo(f"Valid Cache Files: {info['valid_files']}") + click.echo(f"Expired Cache Files: {info['expired_files']}") + click.echo(f"Total Cache Size: {info['total_size_bytes'] / 1024:.2f} KB") + click.echo(f"Cache TTL: {info['ttl_hours']} hours") + + if info['valid_files'] > 0: + click.echo("\nCache is active and contains valid entries.") + else: + click.echo("\nCache is empty or all entries are expired.") + + +@setup_group.command('completion-cache-clear', help="""Clear cached completion data for faster autocompletion.""") +@cli_util.option('--expired-only', is_flag=True, help='Clear only expired cache entries') +@cli_util.option('--force', '-f', is_flag=True, help='Skip confirmation prompt') +@cli_util.help_option +@click.pass_context +def completion_cache_clear(ctx, expired_only, force): + """Clear the completion cache.""" + from interactive.enhanced_completions import clear_completion_cache, clear_expired_cache + + if expired_only: + clear_expired_cache() + click.echo("Expired cache entries cleared successfully.") + else: + if not force: + if not click.confirm("This will clear all cached completion data. Continue?"): + click.echo("Cache clear cancelled.") + return + + clear_completion_cache() + + +@setup_group.command('completion-cache-refresh', help="""Refresh compartment cache by fetching latest data from OCI.""") +@cli_util.help_option +@click.pass_context +def completion_cache_refresh(ctx): + """Force refresh the compartment cache.""" + from interactive.enhanced_completions import OCIDCache, CompartmentCompleter + + try: + # Clear existing compartment cache + cache = OCIDCache() + profile = ctx.obj.get('profile', 'DEFAULT') + region = ctx.obj.get('region', 'us-phoenix-1') + cache_key = f"compartments_profile_{profile}_region_{region}" + + # Clear specific cache entry + cache_file = cache._get_cache_file(cache_key) + if cache_file.exists(): + cache_file.unlink() + + click.echo("Fetching fresh compartment data...") + + # Use the compartment completer to refresh + completer = CompartmentCompleter(ctx) + compartments = completer._fetch_compartments() + + if compartments: + cache.set(cache_key, compartments) + click.echo(f"Successfully cached {len(compartments)} compartments.") + else: + click.echo("Warning: No compartments found or error occurred.") + + except Exception as e: + click.echo(f"Error refreshing compartment cache: {e}") + ctx.exit(1) + + @setup_group.command('repair-file-permissions', help="""Resets permissions on a given file to an appropriate access level for sensitive files. Generally this is used to fix permissions on a private key file or config file to meet the requirements of the CLI. On Windows, full control will be given to System, Administrators, and the current user. On Unix, Read / Write permissions will be given to the current user.""") @cli_util.option('--file', required=True, help="""The file to repair permissions on.""") diff --git a/src/oci_cli/custom_types/cli_completion_cache.py b/src/oci_cli/custom_types/cli_completion_cache.py new file mode 100644 index 00000000..d24cea5d --- /dev/null +++ b/src/oci_cli/custom_types/cli_completion_cache.py @@ -0,0 +1,109 @@ +# coding: utf-8 +# Copyright (c) 2016, 2026, Oracle and/or its affiliates. All rights reserved. +# This software is dual-licensed to you under the Universal Permissive License (UPL) 1.0 as shown at https://oss.oracle.com/licenses/upl or Apache License 2.0 as shown at http://www.apache.org/licenses/LICENSE-2.0. You may choose either license. + +""" +CLI commands for managing the completion cache. + +This module provides commands to: +- View cache status +- Clear the cache +- Configure cache TTL +""" + +import click +import json +from interactive.enhanced_completions import ( + OCIDCache, + clear_completion_cache, + clear_expired_cache, + get_cache_info +) + + +@click.group('completion-cache', help='Manage the OCI CLI completion cache for faster autocompletion') +@click.pass_context +def completion_cache_group(ctx): + """Commands for managing the completion cache.""" + pass + + +@completion_cache_group.command('status', help='Show current cache status and statistics') +@click.pass_context +def cache_status(ctx): + """Display information about the completion cache.""" + info = get_cache_info() + + click.echo("Completion Cache Status") + click.echo("=" * 50) + click.echo(f"Cache Directory: {info['cache_directory']}") + click.echo(f"Total Cache Files: {info['total_files']}") + click.echo(f"Valid Cache Files: {info['valid_files']}") + click.echo(f"Expired Cache Files: {info['expired_files']}") + click.echo(f"Total Cache Size: {info['total_size_bytes'] / 1024:.2f} KB") + click.echo(f"Cache TTL: {info['ttl_hours']} hours") + + if info['valid_files'] > 0: + click.echo("\nCache is active and contains valid entries.") + else: + click.echo("\nCache is empty or all entries are expired.") + + +@completion_cache_group.command('clear', help='Clear all cached completion data') +@click.option('--expired-only', is_flag=True, help='Clear only expired cache entries') +@click.option('--force', '-f', is_flag=True, help='Skip confirmation prompt') +@click.pass_context +def cache_clear(ctx, expired_only, force): + """Clear the completion cache.""" + if expired_only: + clear_expired_cache() + click.echo("Expired cache entries cleared successfully.") + else: + if not force: + if not click.confirm("This will clear all cached completion data. Continue?"): + click.echo("Cache clear cancelled.") + return + + clear_completion_cache() + click.echo("All completion cache cleared successfully.") + + +@completion_cache_group.command('refresh', help='Refresh compartment cache by fetching latest data') +@click.pass_context +def cache_refresh(ctx): + """Force refresh the compartment cache.""" + from oci_cli import cli_util + + try: + # Clear existing compartment cache + cache = OCIDCache() + profile = ctx.obj.get('profile', 'DEFAULT') + region = ctx.obj.get('region', 'us-phoenix-1') + cache_key = f"compartments_profile_{profile}_region_{region}" + + # Clear specific cache entry + cache_file = cache._get_cache_file(cache_key) + if cache_file.exists(): + cache_file.unlink() + + click.echo("Fetching fresh compartment data...") + + # Import and use the compartment completer to refresh + from interactive.enhanced_completions import CompartmentCompleter + completer = CompartmentCompleter(ctx) + compartments = completer._fetch_compartments() + + if compartments: + cache.set(cache_key, compartments) + click.echo(f"Successfully cached {len(compartments)} compartments.") + else: + click.echo("Warning: No compartments found or error occurred.") + + except Exception as e: + click.echo(f"Error refreshing compartment cache: {e}") + ctx.exit(1) + + +def add_cache_commands(cli): + """Add cache management commands to the CLI.""" + cli.add_command(completion_cache_group) \ No newline at end of file From 5eef2817377009c5df1ed5226db6636740cbefca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20J=2E=20Gajda?= Date: Thu, 22 Jan 2026 13:47:16 +0100 Subject: [PATCH 2/2] feat: Increase compartment cache TTL from 24 hours to 1 week Compartments change infrequently, so a longer cache duration reduces API calls and improves completion performance. --- src/interactive/enhanced_completions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/interactive/enhanced_completions.py b/src/interactive/enhanced_completions.py index 2710eea5..0909e9c1 100644 --- a/src/interactive/enhanced_completions.py +++ b/src/interactive/enhanced_completions.py @@ -30,9 +30,9 @@ class OCIDCache: """ CACHE_DIR = os.path.join(os.path.expanduser("~"), ".oci", "completion_cache") - CACHE_TTL_HOURS = 24 # Default cache validity period + CACHE_TTL_HOURS = 168 # Default cache validity period (1 week) - def __init__(self, cache_ttl_hours: int = 24): + def __init__(self, cache_ttl_hours: int = 168): """ Initialize the OCID cache. @@ -123,13 +123,13 @@ class CompartmentCompleter: Provides compartment-aware completion for OCI CLI. """ - def __init__(self, ctx, cache_ttl_hours: int = 24): + def __init__(self, ctx, cache_ttl_hours: int = 168): """ Initialize the compartment completer. Args: ctx: Click context object - cache_ttl_hours: Hours before cache expires + cache_ttl_hours: Hours before cache expires (default: 1 week) """ self.ctx = ctx self.cache = OCIDCache(cache_ttl_hours)