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 +17 -0
- sftp_pilot/__main__.py +13 -0
- sftp_pilot/cli.py +107 -0
- sftp_pilot/config_manager.py +120 -0
- sftp_pilot/console.py +391 -0
- sftp_pilot/gui.py +598 -0
- sftp_pilot/runner.py +66 -0
- sftp_pilot/scheduler.py +359 -0
- sftp_pilot/sftp_handler.py +360 -0
- sftp_pilot-2.0.0.dist-info/METADATA +231 -0
- sftp_pilot-2.0.0.dist-info/RECORD +15 -0
- sftp_pilot-2.0.0.dist-info/WHEEL +5 -0
- sftp_pilot-2.0.0.dist-info/entry_points.txt +2 -0
- sftp_pilot-2.0.0.dist-info/licenses/LICENSE +21 -0
- sftp_pilot-2.0.0.dist-info/top_level.txt +1 -0
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
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()
|