sftp-pilot 2.0.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.
sftp_pilot/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """
2
+ SFTP Pilot - SFTP file transfer and scheduling application
3
+
4
+ A Python application for SFTP file operations with a task scheduler and two
5
+ interfaces (GUI and console). It uses paramiko.
6
+ Formerly WinSCP Manager (winscp_tools).
7
+
8
+ Author: Pandiyaraj Karuppasamy
9
+ Date: Oct-19-2025
10
+ Modified: Oct-18-2026
11
+ Repository: https://github.com/Pandiyarajk/sftp-pilot
12
+ """
13
+
14
+ __version__ = "2.0.0"
15
+ __author__ = "Pandiyaraj Karuppasamy"
16
+ __license__ = "MIT"
17
+ __url__ = "https://github.com/Pandiyarajk/sftp-pilot"
sftp_pilot/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """
2
+ Allows `python -m sftp_pilot`.
3
+
4
+ Author: Pandiyaraj Karuppasamy
5
+ Date: Oct-17-2026
6
+ Modified: Oct-18-2026
7
+ """
8
+
9
+ import sys
10
+
11
+ from .cli import main
12
+
13
+ sys.exit(main())
sftp_pilot/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """
2
+ SFTP Pilot command-line entry point (installed as `sftp-pilot`).
3
+
4
+ Author: Pandiyaraj Karuppasamy
5
+ Date: Oct-19-2025
6
+ Modified: Oct-18-2026
7
+ """
8
+
9
+ import argparse
10
+ import logging
11
+ import os
12
+ import sys
13
+ from typing import List, Optional
14
+
15
+ from . import __version__
16
+
17
+ WARNING = """\
18
+ WARNING: this tool uploads, overwrites and deletes files on remote servers,
19
+ including unattended through the scheduler. It is provided AS IS, without
20
+ warranty of any kind, and you use it entirely at your own risk. Check every
21
+ target path, keep tested backups, and only act on servers you are authorised
22
+ to change. See DISCLAIMER.md; LICENSE is the governing text.
23
+ """
24
+
25
+
26
+ def setup_logging(config_path: str) -> None:
27
+ """
28
+ Configure root logging from the [LOGGING] section, falling back to INFO
29
+ on the console when the configuration cannot be read.
30
+
31
+ Args:
32
+ config_path: Path to the configuration file
33
+ """
34
+ level_name, log_file = "INFO", ""
35
+ if os.path.exists(config_path):
36
+ try:
37
+ from .config_manager import ConfigManager
38
+ cfg = ConfigManager(config_path).get_logging_config()
39
+ level_name, log_file = cfg['log_level'], cfg['log_file']
40
+ except Exception as e:
41
+ print(f"Warning: could not read logging settings: {e}", file=sys.stderr)
42
+
43
+ handlers: List[logging.Handler] = [logging.StreamHandler()]
44
+ if log_file:
45
+ handlers.append(logging.FileHandler(log_file, encoding='utf-8'))
46
+ logging.basicConfig(
47
+ level=getattr(logging, level_name, logging.INFO),
48
+ format='%(asctime)s - %(levelname)s - %(name)s - %(message)s',
49
+ handlers=handlers,
50
+ force=True,
51
+ )
52
+ # paramiko is chatty at INFO; keep its noise out unless DEBUG was asked for.
53
+ if level_name != "DEBUG":
54
+ logging.getLogger("paramiko").setLevel(logging.WARNING)
55
+
56
+
57
+ def main(argv: Optional[List[str]] = None) -> int:
58
+ """
59
+ Parse arguments and start the chosen interface
60
+
61
+ Args:
62
+ argv: Argument list (defaults to sys.argv[1:])
63
+
64
+ Returns:
65
+ Process exit code
66
+ """
67
+ parser = argparse.ArgumentParser(
68
+ prog='sftp-pilot',
69
+ description='SFTP Pilot - SFTP file transfer and scheduler',
70
+ formatter_class=argparse.RawDescriptionHelpFormatter,
71
+ epilog=f"""
72
+ Examples:
73
+ sftp-pilot # GUI (default)
74
+ sftp-pilot --console # console menu
75
+ sftp-pilot -c D:\\cfg\\prod.ini # use another config file
76
+
77
+ {WARNING}"""
78
+ )
79
+ group = parser.add_mutually_exclusive_group()
80
+ group.add_argument('--gui', action='store_true', help='Run GUI interface (default)')
81
+ group.add_argument('--console', action='store_true', help='Run console interface')
82
+ parser.add_argument('-c', '--config', default='config.ini',
83
+ help='Configuration file (default: config.ini in the current directory)')
84
+ parser.add_argument('--tasks-file', default='scheduled_tasks.json',
85
+ help='Where scheduled tasks are stored (default: scheduled_tasks.json)')
86
+ parser.add_argument('--version', action='version', version=f'SFTP Pilot {__version__}')
87
+
88
+ args = parser.parse_args(argv)
89
+ setup_logging(args.config)
90
+
91
+ if args.console:
92
+ from .console import run_console
93
+ run_console(args.config, args.tasks_file)
94
+ return 0
95
+
96
+ try:
97
+ from .gui import run_gui
98
+ except ImportError as e:
99
+ print(f"Error: Failed to load GUI. {e}", file=sys.stderr)
100
+ print("Try running with --console for console interface", file=sys.stderr)
101
+ return 1
102
+ run_gui(args.config, args.tasks_file)
103
+ return 0
104
+
105
+
106
+ if __name__ == '__main__':
107
+ sys.exit(main())
@@ -0,0 +1,120 @@
1
+ """
2
+ Configuration Manager for SFTP Pilot credentials and settings.
3
+
4
+ Secrets can be kept out of the file: the environment variables
5
+ SFTP_PILOT_PASSWORD and SFTP_PILOT_KEY_PASSPHRASE override the
6
+ `password` and `private_key_passphrase` keys when set.
7
+
8
+ Author: Pandiyaraj Karuppasamy
9
+ Date: Oct-19-2025
10
+ Modified: Oct-18-2026
11
+ """
12
+
13
+ import configparser
14
+ import os
15
+ from typing import Any, Dict
16
+
17
+ PASSWORD_ENV = "SFTP_PILOT_PASSWORD"
18
+ PASSPHRASE_ENV = "SFTP_PILOT_KEY_PASSPHRASE"
19
+
20
+ HOST_KEY_POLICIES = ("accept_new", "reject", "auto_add")
21
+
22
+
23
+ class ConfigManager:
24
+ """Manages configuration file reading and validation"""
25
+
26
+ def __init__(self, config_path: str = "config.ini"):
27
+ """
28
+ Initialize the configuration manager
29
+
30
+ Args:
31
+ config_path: Path to the configuration file
32
+ """
33
+ self.config_path = config_path
34
+ self.config = configparser.ConfigParser()
35
+ self.load_config()
36
+
37
+ def load_config(self) -> None:
38
+ """Load configuration from file"""
39
+ if not os.path.exists(self.config_path):
40
+ raise FileNotFoundError(f"Configuration file not found: {self.config_path}")
41
+
42
+ self.config.read(self.config_path, encoding="utf-8")
43
+
44
+ def get_connection_details(self) -> Dict[str, Any]:
45
+ """
46
+ Get connection details from config, with secrets taken from the
47
+ environment when the matching variable is set.
48
+
49
+ Returns:
50
+ Dictionary containing connection parameters
51
+
52
+ Raises:
53
+ ValueError: If a setting has an unsupported value
54
+ """
55
+ protocol = self.config.get('DEFAULT', 'protocol', fallback='sftp').strip().lower()
56
+ if protocol != 'sftp':
57
+ raise ValueError(f"Unsupported protocol '{protocol}': only 'sftp' is supported")
58
+
59
+ policy = self.config.get('DEFAULT', 'host_key_policy', fallback='accept_new').strip().lower()
60
+ if policy not in HOST_KEY_POLICIES:
61
+ raise ValueError(
62
+ f"Unsupported host_key_policy '{policy}': use one of {', '.join(HOST_KEY_POLICIES)}"
63
+ )
64
+
65
+ return {
66
+ 'protocol': protocol,
67
+ 'host': self.config.get('DEFAULT', 'host'),
68
+ 'port': self.config.getint('DEFAULT', 'port', fallback=22),
69
+ 'username': self.config.get('DEFAULT', 'username'),
70
+ 'password': os.environ.get(PASSWORD_ENV)
71
+ or self.config.get('DEFAULT', 'password', fallback=''),
72
+ 'private_key_path': os.path.expanduser(
73
+ self.config.get('DEFAULT', 'private_key_path', fallback='')
74
+ ),
75
+ 'private_key_passphrase': os.environ.get(PASSPHRASE_ENV)
76
+ or self.config.get('DEFAULT', 'private_key_passphrase', fallback=''),
77
+ 'host_key_policy': policy,
78
+ 'known_hosts_file': os.path.expanduser(
79
+ self.config.get('DEFAULT', 'known_hosts_file', fallback='known_hosts')
80
+ ),
81
+ 'timeout': self.config.getfloat('DEFAULT', 'timeout', fallback=30.0),
82
+ }
83
+
84
+ def get_paths(self) -> Dict[str, str]:
85
+ """Get path configurations"""
86
+ return {
87
+ 'remote_upload_dir': self.config.get('PATHS', 'remote_upload_dir', fallback='/'),
88
+ 'local_download_dir': self.config.get('PATHS', 'local_download_dir', fallback='./downloads'),
89
+ }
90
+
91
+ def get_scheduling_config(self) -> Dict[str, Any]:
92
+ """Get scheduling configuration"""
93
+ return {
94
+ 'enabled': self.config.getboolean('SCHEDULING', 'enabled', fallback=True),
95
+ 'check_interval': max(1, self.config.getint('SCHEDULING', 'check_interval', fallback=60)),
96
+ }
97
+
98
+ def get_logging_config(self) -> Dict[str, str]:
99
+ """Get logging configuration"""
100
+ return {
101
+ 'log_file': self.config.get('LOGGING', 'log_file', fallback='sftp_pilot.log'),
102
+ 'log_level': self.config.get('LOGGING', 'log_level', fallback='INFO').upper(),
103
+ }
104
+
105
+ def update_config(self, section: str, key: str, value: str) -> None:
106
+ """
107
+ Update configuration value
108
+
109
+ Args:
110
+ section: Configuration section
111
+ key: Configuration key
112
+ value: New value
113
+ """
114
+ if section != 'DEFAULT' and section not in self.config:
115
+ self.config.add_section(section)
116
+
117
+ self.config.set(section, key, value)
118
+
119
+ with open(self.config_path, 'w', encoding="utf-8") as configfile:
120
+ self.config.write(configfile)
sftp_pilot/console.py ADDED
@@ -0,0 +1,391 @@
1
+ """
2
+ Console Interface for SFTP Pilot
3
+
4
+ Author: Pandiyaraj Karuppasamy
5
+ Date: Oct-19-2025
6
+ Modified: Oct-18-2026
7
+ """
8
+
9
+ import logging
10
+ import uuid
11
+ from datetime import datetime, timedelta
12
+ from typing import Optional
13
+
14
+ from .config_manager import ConfigManager
15
+ from .runner import make_task_executor
16
+ from .scheduler import ScheduledTask, TaskScheduler, TaskType
17
+ from .sftp_handler import SFTPHandler
18
+
19
+
20
+ def _progress_printer(verb: str):
21
+ def callback(done: int, total: int) -> None:
22
+ percent = (done / total * 100) if total else 100.0
23
+ print(f"\r{verb}: {percent:5.1f}% ({done}/{total} bytes)", end='', flush=True)
24
+ return callback
25
+
26
+
27
+ def _ask_int(prompt: str, default: int, minimum: int = 0) -> Optional[int]:
28
+ raw = input(f"{prompt} [{default}]: ").strip()
29
+ if not raw:
30
+ return default
31
+ try:
32
+ value = int(raw)
33
+ except ValueError:
34
+ print(f"FAILED: '{raw}' is not a whole number")
35
+ return None
36
+ if value < minimum:
37
+ print(f"FAILED: must be at least {minimum}")
38
+ return None
39
+ return value
40
+
41
+
42
+ class ConsoleInterface:
43
+ """Console-based interface for SFTP Pilot"""
44
+
45
+ def __init__(self, config_path: str = "config.ini", tasks_file: str = "scheduled_tasks.json"):
46
+ """
47
+ Initialize console interface
48
+
49
+ Args:
50
+ config_path: Configuration file to read
51
+ tasks_file: Where scheduled tasks are stored
52
+ """
53
+ self.config_path = config_path
54
+ self.tasks_file = tasks_file
55
+ self.config_manager: Optional[ConfigManager] = None
56
+ self.sftp_handler: Optional[SFTPHandler] = None
57
+ self.scheduler = TaskScheduler(tasks_file)
58
+ self.running = True
59
+ self.logger = logging.getLogger(__name__)
60
+
61
+ @property
62
+ def connected(self) -> bool:
63
+ """True while the interactive connection is alive"""
64
+ return bool(self.sftp_handler and self.sftp_handler.is_connected())
65
+
66
+ def run(self):
67
+ """Run the console interface"""
68
+ print("=" * 60)
69
+ print("SFTP Pilot - Console Interface")
70
+ print("=" * 60)
71
+
72
+ try:
73
+ self.config_manager = ConfigManager(self.config_path)
74
+ self.logger.info("Configuration loaded successfully")
75
+ except Exception as e:
76
+ self.logger.error(f"Failed to load configuration: {e}")
77
+ print(f"Warning: Could not load configuration file {self.config_path}")
78
+
79
+ if self.config_manager:
80
+ self.scheduler.check_interval = self.config_manager.get_scheduling_config()['check_interval']
81
+ self.scheduler.set_task_executor(
82
+ make_task_executor(self.config_manager.get_connection_details)
83
+ )
84
+
85
+ try:
86
+ while self.running:
87
+ self.show_menu()
88
+ choice = input("\nEnter your choice: ").strip()
89
+ self.handle_choice(choice)
90
+ except (KeyboardInterrupt, EOFError):
91
+ print()
92
+ self.exit_app()
93
+
94
+ def show_menu(self):
95
+ """Display main menu"""
96
+ state = "connected" if self.connected else "not connected"
97
+ sched = "running" if self.scheduler.running else "stopped"
98
+ print("\n" + "=" * 60)
99
+ print(f"Main Menu (server: {state}, scheduler: {sched})")
100
+ print("=" * 60)
101
+ print("1. Connect to server")
102
+ print("2. Disconnect from server")
103
+ print("3. Upload file")
104
+ print("4. Download file")
105
+ print("5. Delete file")
106
+ print("6. List remote files")
107
+ print("7. Schedule task")
108
+ print("8. View scheduled tasks")
109
+ print("9. Remove scheduled task")
110
+ print("10. Start scheduler")
111
+ print("11. Stop scheduler")
112
+ print("12. Exit")
113
+ print("=" * 60)
114
+
115
+ def handle_choice(self, choice: str):
116
+ """Handle menu choice"""
117
+ actions = {
118
+ '1': self.connect,
119
+ '2': self.disconnect,
120
+ '3': self.upload_file,
121
+ '4': self.download_file,
122
+ '5': self.delete_file,
123
+ '6': self.list_files,
124
+ '7': self.schedule_task,
125
+ '8': self.view_tasks,
126
+ '9': self.remove_task,
127
+ '10': self.start_scheduler,
128
+ '11': self.stop_scheduler,
129
+ '12': self.exit_app
130
+ }
131
+
132
+ action = actions.get(choice)
133
+ if not action:
134
+ print("\nInvalid choice. Please try again.")
135
+ return
136
+ try:
137
+ action()
138
+ except (KeyboardInterrupt, EOFError):
139
+ raise
140
+ except Exception as e:
141
+ self.logger.error(f"Error: {e}")
142
+ print(f"\nError: {e}")
143
+
144
+ def _require_connection(self) -> Optional[SFTPHandler]:
145
+ if self.sftp_handler and self.sftp_handler.is_connected():
146
+ return self.sftp_handler
147
+ if self.sftp_handler:
148
+ print("\nFAILED: the connection was lost. Please connect again.")
149
+ else:
150
+ print("\nFAILED: not connected to server. Please connect first.")
151
+ return None
152
+
153
+ def connect(self):
154
+ """Connect to server"""
155
+ if self.connected:
156
+ print("\nAlready connected to server")
157
+ return
158
+
159
+ if not self.config_manager:
160
+ print("\nConfiguration not loaded. Cannot connect.")
161
+ return
162
+
163
+ conn_details = self.config_manager.get_connection_details()
164
+ print(f"\nConnecting to {conn_details['host']}:{conn_details['port']}...")
165
+
166
+ self.sftp_handler = SFTPHandler.from_config(conn_details)
167
+ if self.sftp_handler.connect():
168
+ print("OK: connected")
169
+ else:
170
+ print(f"FAILED: {self.sftp_handler.last_error}")
171
+
172
+ def disconnect(self):
173
+ """Disconnect from server"""
174
+ if not self.sftp_handler:
175
+ print("\nNot connected to server")
176
+ return
177
+
178
+ self.sftp_handler.disconnect()
179
+ self.sftp_handler = None
180
+ print("\nOK: disconnected")
181
+
182
+ def upload_file(self):
183
+ """Upload file to server"""
184
+ handler = self._require_connection()
185
+ if not handler:
186
+ return
187
+
188
+ local_path = input("\nEnter local file path: ").strip().strip('"')
189
+ default_dir = self.config_manager.get_paths()['remote_upload_dir'] if self.config_manager else ''
190
+ remote_path = input(f"Enter remote destination path (end with / for a folder) [{default_dir}]: ").strip()
191
+ remote_path = remote_path or default_dir
192
+
193
+ if not local_path or not remote_path:
194
+ print("FAILED: both paths are required")
195
+ return
196
+ if not remote_path.endswith('/') and remote_path == default_dir:
197
+ remote_path += '/'
198
+
199
+ print(f"\nUploading {local_path} to {remote_path}...")
200
+ if handler.upload_file(local_path, remote_path, _progress_printer("Uploading")):
201
+ print("\nOK: upload completed")
202
+ else:
203
+ print("\nFAILED: upload failed (see log)")
204
+
205
+ def download_file(self):
206
+ """Download file from server"""
207
+ handler = self._require_connection()
208
+ if not handler:
209
+ return
210
+
211
+ remote_path = input("\nEnter remote file path: ").strip()
212
+ default_dir = self.config_manager.get_paths()['local_download_dir'] if self.config_manager else ''
213
+ local_path = input(f"Enter local destination (file or folder) [{default_dir}]: ").strip().strip('"')
214
+ local_path = local_path or default_dir
215
+
216
+ if not remote_path or not local_path:
217
+ print("FAILED: both paths are required")
218
+ return
219
+
220
+ print(f"\nDownloading {remote_path} to {local_path}...")
221
+ if handler.download_file(remote_path, local_path, _progress_printer("Downloading")):
222
+ print("\nOK: download completed")
223
+ else:
224
+ print("\nFAILED: download failed (see log)")
225
+
226
+ def delete_file(self):
227
+ """Delete file from server"""
228
+ handler = self._require_connection()
229
+ if not handler:
230
+ return
231
+
232
+ remote_path = input("\nEnter remote file path to delete: ").strip()
233
+
234
+ if not remote_path:
235
+ print("FAILED: path is required")
236
+ return
237
+
238
+ confirm = input(f"Permanently delete {remote_path} on the server? Type 'yes' to confirm: ").strip().lower()
239
+ if confirm != 'yes':
240
+ print("Cancelled")
241
+ return
242
+
243
+ if handler.delete_file(remote_path):
244
+ print("OK: file deleted")
245
+ else:
246
+ print("FAILED: delete failed (see log)")
247
+
248
+ def list_files(self):
249
+ """List files in remote directory"""
250
+ handler = self._require_connection()
251
+ if not handler:
252
+ return
253
+
254
+ remote_path = input("\nEnter remote directory path [/]: ").strip() or "/"
255
+ files = handler.list_files(remote_path)
256
+
257
+ if files:
258
+ print(f"\nFiles in {remote_path}:")
259
+ print("-" * 60)
260
+ for name in files:
261
+ print(f" {name}")
262
+ print("-" * 60)
263
+ else:
264
+ print("\nNo files found or error occurred")
265
+
266
+ def schedule_task(self):
267
+ """Schedule a new task"""
268
+ print("\nSchedule New Task")
269
+ print("-" * 60)
270
+ print("Task Types: 1) Upload 2) Download 3) Delete")
271
+ task_choice = input("Select task type (1-3): ").strip()
272
+
273
+ task_type = {'1': TaskType.UPLOAD, '2': TaskType.DOWNLOAD, '3': TaskType.DELETE}.get(task_choice)
274
+ if not task_type:
275
+ print("FAILED: invalid task type")
276
+ return
277
+
278
+ label = "remote file to delete" if task_type == TaskType.DELETE else "source path"
279
+ source_path = input(f"Enter {label}: ").strip().strip('"')
280
+ dest_path = ""
281
+ if task_type in (TaskType.UPLOAD, TaskType.DOWNLOAD):
282
+ dest_path = input("Enter destination path: ").strip().strip('"')
283
+
284
+ delay_minutes = _ask_int("Run in how many minutes?", 5)
285
+ if delay_minutes is None:
286
+ return
287
+
288
+ recurring = input("Recurring task? (yes/no) [no]: ").strip().lower() == 'yes'
289
+ interval_minutes = 0
290
+ if recurring:
291
+ interval_minutes = _ask_int("Interval in minutes", 60, minimum=1)
292
+ if interval_minutes is None:
293
+ return
294
+
295
+ task_id = str(uuid.uuid4())
296
+ scheduled_time = datetime.now() + timedelta(minutes=delay_minutes)
297
+ try:
298
+ task = ScheduledTask(task_id, task_type, source_path, dest_path,
299
+ scheduled_time, recurring, interval_minutes)
300
+ except ValueError as e:
301
+ print(f"FAILED: {e}")
302
+ return
303
+
304
+ self.scheduler.add_task(task)
305
+ print(f"\nOK: task scheduled (ID: {task_id[:8]})")
306
+ print(f" Will run at: {scheduled_time:%Y-%m-%d %H:%M:%S}")
307
+ if not self.scheduler.running:
308
+ print(" The scheduler is stopped: start it (option 10) for the task to run.")
309
+
310
+ def view_tasks(self):
311
+ """View all scheduled tasks"""
312
+ tasks = self.scheduler.get_all_tasks()
313
+
314
+ if not tasks:
315
+ print("\nNo scheduled tasks")
316
+ return
317
+
318
+ print("\nScheduled Tasks")
319
+ print("=" * 80)
320
+
321
+ for task in tasks:
322
+ print(f"\nTask ID: {task.task_id}")
323
+ print(f"Type: {task.task_type.value}")
324
+ print(f"Source: {task.source_path}")
325
+ print(f"Destination: {task.dest_path or 'N/A'}")
326
+ print(f"Next Run: {task.next_run:%Y-%m-%d %H:%M:%S}" if task.next_run else "Next Run: N/A")
327
+ print(f"Status: {task.describe_status()}")
328
+ if task.error_message:
329
+ print(f"Last error: {task.error_message}")
330
+ print(f"Recurring: {'every %d minutes' % task.interval_minutes if task.recurring else 'No'}")
331
+ print("-" * 80)
332
+
333
+ def remove_task(self):
334
+ """Remove a scheduled task"""
335
+ task_id = input("\nEnter task ID to remove (at least the first 4 characters): ").strip()
336
+ task = self.scheduler.find_task(task_id)
337
+ if not task:
338
+ print("FAILED: no single task matches that ID")
339
+ return
340
+ if self.scheduler.remove_task(task.task_id):
341
+ print(f"OK: task {task.task_id[:8]} removed")
342
+ else:
343
+ print("FAILED: could not remove task")
344
+
345
+ def start_scheduler(self):
346
+ """Start the scheduler"""
347
+ if self.scheduler.running:
348
+ print("\nScheduler is already running")
349
+ return
350
+ if not self.config_manager:
351
+ print("\nFAILED: the scheduler needs a loaded configuration to connect")
352
+ return
353
+ if not self.config_manager.get_scheduling_config()['enabled']:
354
+ print("\nFAILED: scheduling is disabled ([SCHEDULING] enabled = false)")
355
+ return
356
+
357
+ self.scheduler.start()
358
+ print("\nOK: scheduler started")
359
+
360
+ def stop_scheduler(self):
361
+ """Stop the scheduler"""
362
+ if not self.scheduler.running:
363
+ print("\nScheduler is not running")
364
+ return
365
+
366
+ self.scheduler.stop()
367
+ print("\nOK: scheduler stopped")
368
+
369
+ def exit_app(self):
370
+ """Exit the application"""
371
+ print("\nExiting...")
372
+
373
+ if self.scheduler.running:
374
+ self.scheduler.stop()
375
+
376
+ if self.sftp_handler:
377
+ self.sftp_handler.disconnect()
378
+
379
+ self.running = False
380
+ print("Goodbye!")
381
+
382
+
383
+ def run_console(config_path: str = "config.ini", tasks_file: str = "scheduled_tasks.json"):
384
+ """
385
+ Run the console interface
386
+
387
+ Args:
388
+ config_path: Configuration file to read
389
+ tasks_file: Where scheduled tasks are stored
390
+ """
391
+ ConsoleInterface(config_path, tasks_file).run()