perf(core): implement lazy loading for heavy services, Google SDK and CLI forms

- Defer instantiation of heavy services (SyncService, UserService,
  ImportExportService, SystemService, ExecutionService) using cached
  properties in ServiceProvider and module __getattr__ in services.
- Defer Google Drive SDK imports in sync_service.py using module-level
  __getattr__ and _get_google_libs() helper while retaining mock patchability.
- Lazy load inquirer, Forms, Validators and Blessed theme adapters in
  connpy/cli/* to prevent loading CLI form dependencies at startup.
- Clean up unused top-level service imports in connapp.py.
This commit is contained in:
2026-08-11 17:06:40 -03:00
parent fe189885d4
commit bf5bddda5a
11 changed files with 225 additions and 86 deletions
+5 -1
View File
@@ -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:
+22 -21
View File
@@ -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:
+13 -3
View File
@@ -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"]:
+13 -3
View File
@@ -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"]:
+13 -3
View File
@@ -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"]:
+2 -1
View File
@@ -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"]:
+28 -25
View File
@@ -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
+1 -6
View File
@@ -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
+20 -3
View File
@@ -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',
+60 -13
View File
@@ -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
+48 -7
View File
@@ -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: