sshmenuc 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sshmenuc/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ from .core import ConnectionManager, ConnectionNavigator, SSHLauncher
2
+ from .ui import Colors, MenuDisplay
3
+ from .utils import setup_logging, setup_argument_parser
4
+
5
+ __version__ = "1.1.0"
6
+ __all__ = [
7
+ 'ConnectionManager',
8
+ 'ConnectionNavigator',
9
+ 'SSHLauncher',
10
+ 'Colors',
11
+ 'MenuDisplay',
12
+ 'setup_logging',
13
+ 'setup_argument_parser'
14
+ ]
sshmenuc/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """For execute sshmenuc as a module"""
2
+
3
+ from .main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,9 @@
1
+ """
2
+ Core module per sshmenuc.
3
+ """
4
+ from .base import BaseSSHMenuC
5
+ from .config import ConnectionManager
6
+ from .navigation import ConnectionNavigator
7
+ from .launcher import SSHLauncher
8
+
9
+ __all__ = ['BaseSSHMenuC', 'ConnectionManager', 'ConnectionNavigator', 'SSHLauncher']
sshmenuc/core/base.py ADDED
@@ -0,0 +1,108 @@
1
+ """
2
+ Common base class for all classes in the sshmenuc project.
3
+ Provides shared functionality and common patterns.
4
+ """
5
+ import json
6
+ import os
7
+ import logging
8
+ from typing import Dict, Any, List, Union, Optional
9
+ from abc import ABC, abstractmethod
10
+
11
+
12
+ class BaseSSHMenuC(ABC):
13
+ """Abstract base class with common functionality for all sshmenuc classes."""
14
+
15
+ def __init__(self, config_file: Optional[str] = None):
16
+ self.config_file = config_file
17
+ self.config_data: Dict[str, Any] = {"targets": []}
18
+ self._setup_logging()
19
+
20
+ def _setup_logging(self):
21
+ """Setup basic logging configuration."""
22
+ if not logging.getLogger().handlers:
23
+ logging.basicConfig(level=logging.INFO)
24
+
25
+ def load_config(self):
26
+ """Load and normalize the configuration file.
27
+
28
+ Handles both new format (with 'targets' key) and legacy format.
29
+ If the file doesn't exist or is corrupted, creates an empty config.
30
+ """
31
+ try:
32
+ with open(self.config_file, "r") as f:
33
+ data = json.load(f)
34
+ if isinstance(data, dict) and "targets" not in data:
35
+ targets = []
36
+ for k, v in data.items():
37
+ targets.append({k: v})
38
+ self.config_data = {"targets": targets}
39
+ else:
40
+ self.config_data = data
41
+ except FileNotFoundError:
42
+ self._create_config_directory()
43
+ self.config_data = {"targets": []}
44
+ except json.JSONDecodeError:
45
+ logging.error(f"Error decoding JSON in '{self.config_file}'. Using empty configuration.")
46
+ self.config_data = {"targets": []}
47
+
48
+ def _create_config_directory(self):
49
+ """Create configuration directory if it doesn't exist."""
50
+ try:
51
+ os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
52
+ except Exception as e:
53
+ logging.warning(f"Could not create config directory: {e}")
54
+
55
+ def save_config(self):
56
+ """Save configuration to file.
57
+
58
+ Raises:
59
+ OSError: If file cannot be written (permission denied, disk full, etc.)
60
+ """
61
+ try:
62
+ with open(self.config_file, "w") as file:
63
+ json.dump(self.config_data, file, indent=4)
64
+ except Exception as e:
65
+ logging.error(f"Error saving config: {e}")
66
+
67
+ def get_config(self) -> Dict[str, Any]:
68
+ """Return the current configuration.
69
+
70
+ Returns:
71
+ Configuration dictionary with 'targets' key
72
+ """
73
+ return self.config_data
74
+
75
+ def set_config(self, config_data: Dict[str, Any]):
76
+ """Set a new configuration.
77
+
78
+ Args:
79
+ config_data: New configuration dictionary to set
80
+ """
81
+ self.config_data = config_data
82
+
83
+ def has_global_hosts(self) -> bool:
84
+ """Check if there are any hosts in the configuration.
85
+
86
+ Returns:
87
+ True if at least one host entry exists, False otherwise
88
+ """
89
+ targets = self.config_data.get("targets", [])
90
+ for t in targets:
91
+ if isinstance(t, dict):
92
+ for v in t.values():
93
+ if isinstance(v, list):
94
+ for item in v:
95
+ if isinstance(item, dict) and ("friendly" in item or "host" in item):
96
+ return True
97
+ return False
98
+
99
+ @abstractmethod
100
+ def validate_config(self) -> bool:
101
+ """Abstract method to validate the configuration.
102
+
103
+ Must be implemented by subclasses to provide specific validation logic.
104
+
105
+ Returns:
106
+ True if configuration is valid, False otherwise
107
+ """
108
+ pass
@@ -0,0 +1,146 @@
1
+ """
2
+ SSH configuration management.
3
+ """
4
+ from typing import List, Dict, Any, Optional
5
+ from .base import BaseSSHMenuC
6
+
7
+
8
+ class ConnectionManager(BaseSSHMenuC):
9
+ """Manages SSH connection configurations.
10
+
11
+ Provides CRUD operations for targets and connections within the configuration.
12
+ """
13
+
14
+ def __init__(self, config_file: Optional[str] = None):
15
+ super().__init__(config_file)
16
+ if config_file:
17
+ self.load_config()
18
+
19
+ def _get_target_key(self, target: Dict[str, Any]) -> str:
20
+ """Extract the first (and only) key from a target dictionary.
21
+
22
+ Args:
23
+ target: Target dictionary with single key
24
+
25
+ Returns:
26
+ The target key name
27
+ """
28
+ return next(iter(target.keys()))
29
+
30
+ def _find_target(self, target_name: str) -> Optional[Dict[str, Any]]:
31
+ """Find and return the target dictionary by name.
32
+
33
+ Args:
34
+ target_name: Name of the target to find
35
+
36
+ Returns:
37
+ Target dictionary if found, None otherwise
38
+ """
39
+ for target in self.config_data["targets"]:
40
+ if self._get_target_key(target) == target_name:
41
+ return target
42
+ return None
43
+
44
+ def validate_config(self) -> bool:
45
+ """Validate the configuration structure.
46
+
47
+ Returns:
48
+ True if config has valid structure with 'targets' key, False otherwise
49
+ """
50
+ if not isinstance(self.config_data, dict):
51
+ return False
52
+ if "targets" not in self.config_data:
53
+ return False
54
+ if not isinstance(self.config_data["targets"], list):
55
+ return False
56
+ return True
57
+
58
+ def create_target(self, target_name: str, connections: List[Dict[str, Any]]):
59
+ """Create a new connection target.
60
+
61
+ Args:
62
+ target_name: Name of the target to create
63
+ connections: List of connection configuration dictionaries
64
+ """
65
+ target = {target_name: connections}
66
+ self.config_data["targets"].append(target)
67
+
68
+ def modify_target(self, target_name: str, new_target_name: str = None,
69
+ connections: List[Dict[str, Any]] = None):
70
+ """Modify an existing target.
71
+
72
+ Args:
73
+ target_name: Current name of the target
74
+ new_target_name: New name for the target (optional, if renaming)
75
+ connections: New connection list (optional, if updating connections)
76
+ """
77
+ target = self._find_target(target_name)
78
+ if target:
79
+ if new_target_name:
80
+ target[new_target_name] = target.pop(target_name)
81
+ if connections:
82
+ key = self._get_target_key(target)
83
+ target[key] = connections
84
+
85
+ def delete_target(self, target_name: str):
86
+ """Delete a target.
87
+
88
+ Args:
89
+ target_name: Name of the target to delete
90
+ """
91
+ self.config_data["targets"] = [
92
+ target for target in self.config_data["targets"]
93
+ if self._get_target_key(target) != target_name
94
+ ]
95
+
96
+ def create_connection(self, target_name: str, friendly: str, host: str,
97
+ connection_type: str = "ssh", command: str = "ssh",
98
+ zone: str = "", project: str = ""):
99
+ """Create a new connection within a target.
100
+
101
+ Args:
102
+ target_name: Name of the target to add connection to
103
+ friendly: Friendly name for the connection
104
+ host: Host address to connect to
105
+ connection_type: Type of connection (ssh, gssh, docker)
106
+ command: Command to execute for connection
107
+ zone: Cloud zone (for gssh connections)
108
+ project: Cloud project (for gssh connections)
109
+ """
110
+ connection = {
111
+ "friendly": friendly,
112
+ "host": host,
113
+ "connection_type": connection_type,
114
+ "command": command,
115
+ "zone": zone,
116
+ "project": project,
117
+ }
118
+ target = self._find_target(target_name)
119
+ if target:
120
+ target[target_name].append(connection)
121
+
122
+ def modify_connection(self, target_name: str, connection_index: int, **kwargs):
123
+ """Modify an existing connection.
124
+
125
+ Args:
126
+ target_name: Name of the target containing the connection
127
+ connection_index: Index of the connection to modify
128
+ **kwargs: Connection fields to update (host, user, certkey, etc.)
129
+ """
130
+ target = self._find_target(target_name)
131
+ if target:
132
+ connection = target[target_name][connection_index]
133
+ for key, value in kwargs.items():
134
+ if value is not None:
135
+ connection[key] = value
136
+
137
+ def delete_connection(self, target_name: str, connection_index: int):
138
+ """Delete a connection.
139
+
140
+ Args:
141
+ target_name: Name of the target containing the connection
142
+ connection_index: Index of the connection to delete
143
+ """
144
+ target = self._find_target(target_name)
145
+ if target:
146
+ target[target_name].pop(connection_index)
@@ -0,0 +1,217 @@
1
+ """
2
+ Interactive configuration editor for managing targets and connections.
3
+ """
4
+ from typing import Dict, Any, Optional
5
+ from clint.textui import puts, colored
6
+ from .config import ConnectionManager
7
+
8
+
9
+ class ConfigEditor:
10
+ """Interactive editor for SSH configuration.
11
+
12
+ Provides forms and dialogs for creating, editing, and deleting
13
+ targets and connections within the configuration.
14
+ """
15
+
16
+ def __init__(self, config_manager: ConnectionManager):
17
+ """Initialize the config editor.
18
+
19
+ Args:
20
+ config_manager: ConnectionManager instance to modify
21
+ """
22
+ self.manager = config_manager
23
+
24
+ def prompt_input(self, prompt: str, default: str = "") -> str:
25
+ """Prompt user for input with optional default value.
26
+
27
+ Args:
28
+ prompt: Prompt message to display
29
+ default: Default value if user presses enter
30
+
31
+ Returns:
32
+ User input string
33
+ """
34
+ if default:
35
+ result = input(f"{prompt} [{default}]: ").strip()
36
+ return result if result else default
37
+ return input(f"{prompt}: ").strip()
38
+
39
+ def confirm(self, message: str) -> bool:
40
+ """Ask user for confirmation.
41
+
42
+ Args:
43
+ message: Confirmation message
44
+
45
+ Returns:
46
+ True if user confirms, False otherwise
47
+ """
48
+ response = input(f"{message} [y/N]: ").strip().lower()
49
+ return response == 'y'
50
+
51
+ def add_target(self) -> bool:
52
+ """Interactive form to add a new target.
53
+
54
+ Returns:
55
+ True if target was added, False if cancelled
56
+ """
57
+ puts(colored.cyan("\n=== Add New Target ==="))
58
+ target_name = self.prompt_input("Target name (e.g., Production, Development)")
59
+
60
+ if not target_name:
61
+ puts(colored.red("Target name cannot be empty"))
62
+ return False
63
+
64
+ # Check if target already exists
65
+ if self.manager._find_target(target_name):
66
+ puts(colored.red(f"Target '{target_name}' already exists"))
67
+ return False
68
+
69
+ self.manager.create_target(target_name, [])
70
+ self.manager.save_config()
71
+ puts(colored.green(f"✓ Target '{target_name}' created successfully"))
72
+ return True
73
+
74
+ def delete_target(self, target_name: str) -> bool:
75
+ """Delete a target with confirmation.
76
+
77
+ Args:
78
+ target_name: Name of target to delete
79
+
80
+ Returns:
81
+ True if deleted, False if cancelled
82
+ """
83
+ if not self.confirm(f"Delete target '{target_name}' and all its connections?"):
84
+ puts(colored.yellow("Cancelled"))
85
+ return False
86
+
87
+ self.manager.delete_target(target_name)
88
+ self.manager.save_config()
89
+ puts(colored.green(f"✓ Target '{target_name}' deleted"))
90
+ return True
91
+
92
+ def rename_target(self, target_name: str) -> bool:
93
+ """Rename a target.
94
+
95
+ Args:
96
+ target_name: Current name of target
97
+
98
+ Returns:
99
+ True if renamed, False if cancelled
100
+ """
101
+ puts(colored.cyan(f"\n=== Rename Target '{target_name}' ==="))
102
+ new_name = self.prompt_input("New name", target_name)
103
+
104
+ if not new_name or new_name == target_name:
105
+ puts(colored.yellow("Cancelled"))
106
+ return False
107
+
108
+ # Check if new name already exists
109
+ if self.manager._find_target(new_name):
110
+ puts(colored.red(f"Target '{new_name}' already exists"))
111
+ return False
112
+
113
+ self.manager.modify_target(target_name, new_target_name=new_name)
114
+ self.manager.save_config()
115
+ puts(colored.green(f"✓ Target renamed to '{new_name}'"))
116
+ return True
117
+
118
+ def add_connection(self, target_name: str) -> bool:
119
+ """Interactive form to add a connection to a target.
120
+
121
+ Args:
122
+ target_name: Target to add connection to
123
+
124
+ Returns:
125
+ True if connection added, False if cancelled
126
+ """
127
+ puts(colored.cyan(f"\n=== Add Connection to '{target_name}' ==="))
128
+
129
+ friendly = self.prompt_input("Friendly name (display name)")
130
+ if not friendly:
131
+ puts(colored.red("Friendly name cannot be empty"))
132
+ return False
133
+
134
+ host = self.prompt_input("Host (IP or hostname)")
135
+ if not host:
136
+ puts(colored.red("Host cannot be empty"))
137
+ return False
138
+
139
+ user = self.prompt_input("Username (optional, leave empty for current user)", "")
140
+ certkey = self.prompt_input("SSH key path (optional)", "")
141
+ connection_type = self.prompt_input("Connection type", "ssh")
142
+
143
+ # Build connection dict with only non-empty fields
144
+ connection = {
145
+ "friendly": friendly,
146
+ "host": host,
147
+ "connection_type": connection_type,
148
+ }
149
+
150
+ if user:
151
+ connection["user"] = user
152
+ if certkey:
153
+ connection["certkey"] = certkey
154
+
155
+ # Add connection using the existing method
156
+ target = self.manager._find_target(target_name)
157
+ if target:
158
+ target[target_name].append(connection)
159
+ self.manager.save_config()
160
+ puts(colored.green(f"✓ Connection '{friendly}' added to '{target_name}'"))
161
+ return True
162
+
163
+ puts(colored.red(f"Target '{target_name}' not found"))
164
+ return False
165
+
166
+ def edit_connection(self, target_name: str, connection_index: int,
167
+ connection: Dict[str, Any]) -> bool:
168
+ """Interactive form to edit a connection.
169
+
170
+ Args:
171
+ target_name: Target containing the connection
172
+ connection_index: Index of connection to edit
173
+ connection: Current connection data
174
+
175
+ Returns:
176
+ True if edited, False if cancelled
177
+ """
178
+ puts(colored.cyan(f"\n=== Edit Connection '{connection.get('friendly', 'Unknown')}' ==="))
179
+
180
+ friendly = self.prompt_input("Friendly name", connection.get("friendly", ""))
181
+ host = self.prompt_input("Host", connection.get("host", ""))
182
+ user = self.prompt_input("Username", connection.get("user", ""))
183
+ certkey = self.prompt_input("SSH key path", connection.get("certkey", ""))
184
+
185
+ if not friendly or not host:
186
+ puts(colored.red("Friendly name and host cannot be empty"))
187
+ return False
188
+
189
+ self.manager.modify_connection(
190
+ target_name, connection_index,
191
+ friendly=friendly, host=host, user=user or None, certkey=certkey or None
192
+ )
193
+ self.manager.save_config()
194
+ puts(colored.green(f"✓ Connection updated"))
195
+ return True
196
+
197
+ def delete_connection(self, target_name: str, connection_index: int,
198
+ connection: Dict[str, Any]) -> bool:
199
+ """Delete a connection with confirmation.
200
+
201
+ Args:
202
+ target_name: Target containing the connection
203
+ connection_index: Index of connection to delete
204
+ connection: Connection data (for display)
205
+
206
+ Returns:
207
+ True if deleted, False if cancelled
208
+ """
209
+ friendly = connection.get("friendly", "Unknown")
210
+ if not self.confirm(f"Delete connection '{friendly}'?"):
211
+ puts(colored.yellow("Cancelled"))
212
+ return False
213
+
214
+ self.manager.delete_connection(target_name, connection_index)
215
+ self.manager.save_config()
216
+ puts(colored.green(f"✓ Connection '{friendly}' deleted"))
217
+ return True