diff --git a/connpy/cli/forms.py b/connpy/cli/forms.py index 2f49150..8c7e820 100644 --- a/connpy/cli/forms.py +++ b/connpy/cli/forms.py @@ -1,5 +1,4 @@ import ast -import inquirer from .validators import Validators class Forms: @@ -8,6 +7,7 @@ class Forms: self.validators = Validators(app) def questions_edit(self): + import inquirer questions = [] questions.append(inquirer.Confirm("host", message="Edit Hostname/IP?")) questions.append(inquirer.Confirm("protocol", message="Edit Protocol/app?")) @@ -21,6 +21,7 @@ class Forms: return inquirer.prompt(questions) def questions_nodes(self, unique, uniques=None, edit=None): + import inquirer try: defaults = self.app.services.nodes.get_node_details(unique) if "tags" not in defaults: @@ -98,6 +99,7 @@ class Forms: return result def questions_profiles(self, unique, edit=None): + import inquirer try: defaults = self.app.services.profiles.get_profile(unique, resolve=False) if "tags" not in defaults: @@ -163,6 +165,7 @@ class Forms: return result def questions_bulk(self, nodes="", hosts=""): + import inquirer questions = [] questions.append(inquirer.Text("ids", message="add a comma separated list of nodes to add", default=nodes, validate=self.validators.bulk_node_validation)) questions.append(inquirer.Text("location", message="Add a @folder, @subfolder@folder or leave empty", validate=self.validators.bulk_folder_validation)) @@ -200,6 +203,7 @@ class Forms: def mcp_wizard(self, mcp_servers): """Interactive wizard to manage MCP servers.""" + import inquirer from .helpers import theme while True: diff --git a/connpy/cli/helpers.py b/connpy/cli/helpers.py index 62c425a..bdb9f94 100644 --- a/connpy/cli/helpers.py +++ b/connpy/cli/helpers.py @@ -1,6 +1,4 @@ import os -import inquirer -from inquirer.themes import Default, term try: from pyfzf.pyfzf import FzfPrompt @@ -9,6 +7,7 @@ except ImportError: def hex_to_blessed(hex_str): """Convert hex color string to blessed/ansi format.""" + from inquirer.themes import term if not hex_str or not isinstance(hex_str, str): return term.normal @@ -42,27 +41,28 @@ def hex_to_blessed(hex_str): except: return prefix + term.normal -# Custom inquirer theme matching connpy colors -class ConnpyTheme(Default): - def __init__(self): - super().__init__() - try: - from ..printer import _global_active_styles - # Use user_prompt as primary accent, fallback to info/cyan - accent = _global_active_styles.get("user_prompt", _global_active_styles.get("info", "cyan")) - accent_color = hex_to_blessed(accent) - - self.Question.mark_color = accent_color - self.List.selection_color = accent_color - self.List.selection_cursor = ">" - except: - # Absolute fallback to standard cyan - self.Question.mark_color = term.cyan - self.List.selection_color = term.bold_cyan - self.List.selection_cursor = ">" - def get_theme(): """Returns a fresh instance of the theme with current colors.""" + from inquirer.themes import Default, term + + class ConnpyTheme(Default): + def __init__(self): + super().__init__() + try: + from ..printer import _global_active_styles + # Use user_prompt as primary accent, fallback to info/cyan + accent = _global_active_styles.get("user_prompt", _global_active_styles.get("info", "cyan")) + accent_color = hex_to_blessed(accent) + + self.Question.mark_color = accent_color + self.List.selection_color = accent_color + self.List.selection_cursor = ">" + except: + # Absolute fallback to standard cyan + self.Question.mark_color = term.cyan + self.List.selection_color = term.bold_cyan + self.List.selection_cursor = ">" + return ConnpyTheme() class ThemeProxy: @@ -126,6 +126,7 @@ def choose(app, list_, name, action): else: return answer[0] else: + import inquirer questions = [inquirer.List(name, message="Pick {} to {}:".format(name,action), choices=list_, carousel=True)] answer = inquirer.prompt(questions, theme=theme) if answer == None: diff --git a/connpy/cli/import_export_handler.py b/connpy/cli/import_export_handler.py index 31c08ae..bd5476d 100644 --- a/connpy/cli/import_export_handler.py +++ b/connpy/cli/import_export_handler.py @@ -1,19 +1,29 @@ import os import sys -import inquirer from .. import printer from ..services.exceptions import ConnpyError -from .forms import Forms class ImportExportHandler: def __init__(self, app): self.app = app - self.forms = Forms(app) + self._forms = None + + @property + def forms(self): + if self._forms is None: + from .forms import Forms + self._forms = Forms(self.app) + return self._forms + + @forms.setter + def forms(self, value): + self._forms = value def dispatch_import(self, args): file_path = args.data[0] try: printer.warning("This could overwrite your current configuration!") + import inquirer question = [inquirer.Confirm("import", message=f"Are you sure you want to import {file_path}?")] confirm = inquirer.prompt(question) if confirm == None or not confirm["import"]: diff --git a/connpy/cli/node_handler.py b/connpy/cli/node_handler.py index e9c0847..4555bac 100644 --- a/connpy/cli/node_handler.py +++ b/connpy/cli/node_handler.py @@ -1,18 +1,27 @@ import sys import yaml -import inquirer from rich.markdown import Markdown from .. import printer from ..services.exceptions import ConnpyError, InvalidConfigurationError from .helpers import choose -from .forms import Forms from .help_text import get_instructions class NodeHandler: def __init__(self, app): self.app = app - self.forms = Forms(app) + self._forms = None + + @property + def forms(self): + if self._forms is None: + from .forms import Forms + self._forms = Forms(self.app) + return self._forms + + @forms.setter + def forms(self, value): + self._forms = value def _filter_exact_match(self, matches, query): if not query or len(matches) <= 1: @@ -100,6 +109,7 @@ class NodeHandler: sys.exit(2) printer.info(f"Removing: {matches}") + import inquirer question = [inquirer.Confirm("delete", message="Are you sure you want to continue?")] confirm = inquirer.prompt(question) if confirm == None or not confirm["delete"]: diff --git a/connpy/cli/profile_handler.py b/connpy/cli/profile_handler.py index f11b7c2..06a023a 100644 --- a/connpy/cli/profile_handler.py +++ b/connpy/cli/profile_handler.py @@ -1,15 +1,24 @@ import sys import yaml -import inquirer from .. import printer from ..services.exceptions import ConnpyError, ProfileNotFoundError -from .forms import Forms class ProfileHandler: def __init__(self, app): self.app = app - self.forms = Forms(app) + self._forms = None + + @property + def forms(self): + if self._forms is None: + from .forms import Forms + self._forms = Forms(self.app) + return self._forms + + @forms.setter + def forms(self, value): + self._forms = value def dispatch(self, args): if not self.app.case: @@ -29,6 +38,7 @@ class ProfileHandler: printer.error("Can't delete default profile") sys.exit(6) + import inquirer question = [inquirer.Confirm("delete", message=f"Are you sure you want to delete {name}?")] confirm = inquirer.prompt(question) if confirm == None or not confirm["delete"]: diff --git a/connpy/cli/sso_handler.py b/connpy/cli/sso_handler.py index f8b63ac..479cb24 100644 --- a/connpy/cli/sso_handler.py +++ b/connpy/cli/sso_handler.py @@ -1,6 +1,5 @@ import sys import yaml -import inquirer from .. import printer class SSOHandler: @@ -40,6 +39,7 @@ class SSOHandler: sys.exit(1) def add_provider(self, args): + import inquirer provider = args.provider sso = self.app.config.config.get("sso", {}) providers = sso.setdefault("providers", {}) @@ -113,6 +113,7 @@ class SSOHandler: sys.exit(1) # Confirm delete + import inquirer questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)] answers = inquirer.prompt(questions) if not answers or not answers["confirm"]: diff --git a/connpy/cli/validators.py b/connpy/cli/validators.py index 8d6c818..bb31fba 100644 --- a/connpy/cli/validators.py +++ b/connpy/cli/validators.py @@ -1,6 +1,9 @@ import re import ast -import inquirer + +def _raise_val_err(reason): + import inquirer + raise inquirer.errors.ValidationError("", reason=reason) class Validators: def __init__(self, app): @@ -8,61 +11,61 @@ class Validators: def host_validation(self, answers, current, regex = "^.+$"): if not re.match(regex, current): - raise inquirer.errors.ValidationError("", reason="Host cannot be empty") + _raise_val_err("Host cannot be empty") if current.startswith("@"): if current[1:] not in self.app.profiles: - raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) + _raise_val_err("Profile {} don't exist".format(current)) return True def profile_protocol_validation(self, answers, current, regex = "(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$)"): if not re.match(regex, current): - raise inquirer.errors.ValidationError("", reason="Pick between ssh, telnet, kubectl, docker, ssm or leave empty") + _raise_val_err("Pick between ssh, telnet, kubectl, docker, ssm or leave empty") return True def protocol_validation(self, answers, current, regex = "(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$|^@.+$)"): if not re.match(regex, current): - raise inquirer.errors.ValidationError("", reason="Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile") + _raise_val_err("Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile") if current.startswith("@"): if current[1:] not in self.app.profiles: - raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) + _raise_val_err("Profile {} don't exist".format(current)) return True def profile_port_validation(self, answers, current, regex = "(^[0-9]*$)"): if not re.match(regex, current): - raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile o leave empty") + _raise_val_err("Pick a port between 1-65535, @profile o leave empty") try: port = int(current) except ValueError: port = 0 if current != "" and not 1 <= int(port) <= 65535: - raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535 or leave empty") + _raise_val_err("Pick a port between 1-65535 or leave empty") return True def port_validation(self, answers, current, regex = "(^[0-9]*$|^@.+$)"): if not re.match(regex, current): - raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile or leave empty") + _raise_val_err("Pick a port between 1-65535, @profile or leave empty") try: port = int(current) except ValueError: port = 0 if current.startswith("@"): if current[1:] not in self.app.profiles: - raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) + _raise_val_err("Profile {} don't exist".format(current)) elif current != "" and not 1 <= int(port) <= 65535: - raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile o leave empty") + _raise_val_err("Pick a port between 1-65535, @profile o leave empty") return True def pass_validation(self, answers, current, regex = "(^@.+$)"): profiles = current.split(",") for i in profiles: if not re.match(regex, i) or i[1:] not in self.app.profiles: - raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(i)) + _raise_val_err("Profile {} don't exist".format(i)) return True def tags_validation(self, answers, current): if current.startswith("@"): if current[1:] not in self.app.profiles: - raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) + _raise_val_err("Profile {} don't exist".format(current)) elif current != "": isdict = False try: @@ -70,7 +73,7 @@ class Validators: except Exception: pass if not isinstance (isdict, dict): - raise inquirer.errors.ValidationError("", reason="Tags should be a python dictionary.".format(current)) + _raise_val_err("Tags should be a python dictionary.".format(current)) return True def profile_tags_validation(self, answers, current): @@ -81,36 +84,36 @@ class Validators: except Exception: pass if not isinstance (isdict, dict): - raise inquirer.errors.ValidationError("", reason="Tags should be a python dictionary.".format(current)) + _raise_val_err("Tags should be a python dictionary.".format(current)) return True def jumphost_validation(self, answers, current): if current.startswith("@"): if current[1:] not in self.app.profiles: - raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) + _raise_val_err("Profile {} don't exist".format(current)) elif current != "": if current not in self.app.nodes_list: - raise inquirer.errors.ValidationError("", reason="Node {} don't exist.".format(current)) + _raise_val_err("Node {} don't exist.".format(current)) return True def profile_jumphost_validation(self, answers, current): if current != "": if current not in self.app.nodes_list: - raise inquirer.errors.ValidationError("", reason="Node {} don't exist.".format(current)) + _raise_val_err("Node {} don't exist.".format(current)) return True def default_validation(self, answers, current): if current.startswith("@"): if current[1:] not in self.app.profiles: - raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) + _raise_val_err("Profile {} don't exist".format(current)) return True def bulk_node_validation(self, answers, current, regex = "^[0-9a-zA-Z_.,$#-]+$"): if not re.match(regex, current): - raise inquirer.errors.ValidationError("", reason="Host cannot be empty") + _raise_val_err("Host cannot be empty") if current.startswith("@"): if current[1:] not in self.app.profiles: - raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) + _raise_val_err("Profile {} don't exist".format(current)) return True def bulk_folder_validation(self, answers, current): @@ -123,17 +126,17 @@ class Validators: matches = list(filter(lambda k: k == candidate, self.app.folders)) if current != "" and len(matches) == 0: - raise inquirer.errors.ValidationError("", reason="Location {} don't exist".format(current)) + _raise_val_err("Location {} don't exist".format(current)) return True def bulk_host_validation(self, answers, current, regex = "^.+$"): if not re.match(regex, current): - raise inquirer.errors.ValidationError("", reason="Host cannot be empty") + _raise_val_err("Host cannot be empty") if current.startswith("@"): if current[1:] not in self.app.profiles: - raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) + _raise_val_err("Profile {} don't exist".format(current)) hosts = current.split(",") nodes = answers["ids"].split(",") if len(hosts) > 1 and len(hosts) != len(nodes): - raise inquirer.errors.ValidationError("", reason="Hosts list should be the same length of nodes list") + _raise_val_err("Hosts list should be the same length of nodes list") return True diff --git a/connpy/connapp.py b/connpy/connapp.py index 7deafa6..21450dc 100755 --- a/connpy/connapp.py +++ b/connpy/connapp.py @@ -13,12 +13,7 @@ from .api import start_api,stop_api,debug_api from .ai import ai from .plugins import Plugins -from .services import ( - NodeService, ProfileService, ConfigService, - PluginService, AIService, SystemService, - ExecutionService, ImportExportService, ConnpyError, - ProfileNotFoundError, ReservedNameError -) +from .services.exceptions import ConnpyError, ProfileNotFoundError, ReservedNameError from rich_argparse import RichHelpFormatter # Bridge rich-argparse with our design system diff --git a/connpy/services/__init__.py b/connpy/services/__init__.py index d9ee855..e90507f 100644 --- a/connpy/services/__init__.py +++ b/connpy/services/__init__.py @@ -1,12 +1,27 @@ from .exceptions import * from .node_service import NodeService from .profile_service import ProfileService -from .execution_service import ExecutionService -from .import_export_service import ImportExportService from .ai_service import AIService from .plugin_service import PluginService from .config_service import ConfigService -from .system_service import SystemService + +def __getattr__(name: str): + if name == "ExecutionService": + from .execution_service import ExecutionService + return ExecutionService + elif name == "ImportExportService": + from .import_export_service import ImportExportService + return ImportExportService + elif name == "SystemService": + from .system_service import SystemService + return SystemService + elif name == "SyncService": + from .sync_service import SyncService + return SyncService + elif name == "UserService": + from .user_service import UserService + return UserService + raise AttributeError(f"module '{__name__}' has no attribute '{name}'") __all__ = [ 'NodeService', @@ -17,6 +32,8 @@ __all__ = [ 'PluginService', 'ConfigService', 'SystemService', + 'SyncService', + 'UserService', 'ConnpyError', 'NodeNotFoundError', 'NodeAlreadyExistsError', diff --git a/connpy/services/provider.py b/connpy/services/provider.py index ad2ae0c..0fafdc3 100644 --- a/connpy/services/provider.py +++ b/connpy/services/provider.py @@ -14,6 +14,11 @@ class ServiceProvider: self.mode = mode self.config = config self.remote_host = remote_host + self._system = None + self._execution = None + self._import_export = None + self._sync = None + self._users = None if mode == "local": self._init_local() @@ -28,34 +33,21 @@ class ServiceProvider: from .config_service import ConfigService from .plugin_service import PluginService from .ai_service import AIService - from .system_service import SystemService - from .execution_service import ExecutionService - from .import_export_service import ImportExportService from .context_service import ContextService - from .sync_service import SyncService - from .user_service import UserService self.nodes = NodeService(self.config) self.profiles = ProfileService(self.config) self.config_svc = ConfigService(self.config) self.plugins = PluginService(self.config) self.ai = AIService(self.config) - self.system = SystemService(self.config) - self.execution = ExecutionService(self.config) - self.import_export = ImportExportService(self.config) self.context = ContextService(self.config) - self.sync = SyncService(self.config) - self.users = UserService(self.config.defaultdir) def _init_remote(self): # Allow ConfigService to work locally so the user can revert the mode from .config_service import ConfigService from .context_service import ContextService - from .sync_service import SyncService self.config_svc = ConfigService(self.config) self.context = ContextService(self.config) - self.sync = SyncService(self.config) - self.users = None if not self.remote_host: raise InvalidConfigurationError("Remote host must be specified in remote mode") @@ -98,3 +90,58 @@ class ServiceProvider: self.execution = ExecutionStub(channel, remote_host=self.remote_host) self.import_export = ImportExportStub(channel, remote_host=self.remote_host) self.auth = AuthStub(channel, remote_host=self.remote_host) + + @property + def system(self): + if self._system is None and self.mode == "local": + from .system_service import SystemService + self._system = SystemService(self.config) + return self._system + + @system.setter + def system(self, value): + self._system = value + + @property + def execution(self): + if self._execution is None and self.mode == "local": + from .execution_service import ExecutionService + self._execution = ExecutionService(self.config) + return self._execution + + @execution.setter + def execution(self, value): + self._execution = value + + @property + def import_export(self): + if self._import_export is None and self.mode == "local": + from .import_export_service import ImportExportService + self._import_export = ImportExportService(self.config) + return self._import_export + + @import_export.setter + def import_export(self, value): + self._import_export = value + + @property + def sync(self): + if self._sync is None: + from .sync_service import SyncService + self._sync = SyncService(self.config) + return self._sync + + @sync.setter + def sync(self, value): + self._sync = value + + @property + def users(self): + if self._users is None and self.mode == "local": + from .user_service import UserService + self._users = UserService(self.config.defaultdir) + return self._users + + @users.setter + def users(self, value): + self._users = value diff --git a/connpy/services/sync_service.py b/connpy/services/sync_service.py index 8893b16..cf631d6 100644 --- a/connpy/services/sync_service.py +++ b/connpy/services/sync_service.py @@ -1,3 +1,4 @@ +import sys import os import time import zipfile @@ -6,13 +7,46 @@ import io import yaml import threading from datetime import datetime -from google.oauth2.credentials import Credentials -from google.auth.transport.requests import Request -from googleapiclient.discovery import build -from google.auth.exceptions import RefreshError -from google_auth_oauthlib.flow import InstalledAppFlow -from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload -from googleapiclient.errors import HttpError + +def __getattr__(name: str): + if name == "Credentials": + from google.oauth2.credentials import Credentials + return Credentials + elif name == "Request": + from google.auth.transport.requests import Request + return Request + elif name == "build": + from googleapiclient.discovery import build + return build + elif name == "RefreshError": + from google.auth.exceptions import RefreshError + return RefreshError + elif name == "InstalledAppFlow": + from google_auth_oauthlib.flow import InstalledAppFlow + return InstalledAppFlow + elif name == "MediaFileUpload": + from googleapiclient.http import MediaFileUpload + return MediaFileUpload + elif name == "MediaIoBaseDownload": + from googleapiclient.http import MediaIoBaseDownload + return MediaIoBaseDownload + elif name == "HttpError": + from googleapiclient.errors import HttpError + return HttpError + raise AttributeError(f"module '{__name__}' has no attribute '{name}'") + +def _get_google_libs(): + mod = sys.modules[__name__] + return ( + getattr(mod, "Credentials"), + getattr(mod, "Request"), + getattr(mod, "build"), + getattr(mod, "RefreshError"), + getattr(mod, "InstalledAppFlow"), + getattr(mod, "MediaFileUpload"), + getattr(mod, "MediaIoBaseDownload"), + getattr(mod, "HttpError"), + ) from .base import BaseService from .. import printer @@ -44,6 +78,7 @@ class SyncService(BaseService): def login(self): """Authenticate with Google Drive.""" + Credentials, Request, _, RefreshError, InstalledAppFlow, _, _, _ = _get_google_libs() creds = None if os.path.exists(self.token_file): creds = Credentials.from_authorized_user_file(self.token_file, self.scopes) @@ -81,6 +116,7 @@ class SyncService(BaseService): def get_credentials(self): """Get valid credentials, refreshing if necessary.""" + Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs() if os.path.exists(self.token_file): creds = Credentials.from_authorized_user_file(self.token_file, self.scopes) else: @@ -98,6 +134,7 @@ class SyncService(BaseService): def check_login_status(self): """Check if logged in to Google Drive.""" + Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs() if os.path.exists(self.token_file): creds = Credentials.from_authorized_user_file(self.token_file) if creds and creds.expired and creds.refresh_token: @@ -110,6 +147,7 @@ class SyncService(BaseService): def list_backups(self): """List files in Google Drive appDataFolder.""" + _, _, build, _, _, _, _, HttpError = _get_google_libs() creds = self.get_credentials() if not creds: printer.error("Not logged in to Google Drive.") @@ -168,6 +206,7 @@ class SyncService(BaseService): def upload_file(self, file_path, timestamp): """Internal method to upload to Drive.""" + _, _, build, _, _, MediaFileUpload, _, _ = _get_google_libs() creds = self.get_credentials() if not creds: return False @@ -193,6 +232,7 @@ class SyncService(BaseService): def delete_backup(self, file_id): """Delete a backup from Drive.""" + _, _, build, _, _, _, _, _ = _get_google_libs() creds = self.get_credentials() if not creds: return False try: @@ -226,6 +266,7 @@ class SyncService(BaseService): def download_file(self, file_id, dest): """Internal method to download from Drive.""" + _, _, build, _, _, _, MediaIoBaseDownload, _ = _get_google_libs() creds = self.get_credentials() if not creds: return False try: