easyclone 0.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.
- easyclone/__main__.py +4 -0
- easyclone/config.py +97 -0
- easyclone/ipc/__init__.py +2 -0
- easyclone/ipc/client.py +26 -0
- easyclone/ipc/server.py +50 -0
- easyclone/main.py +59 -0
- easyclone/rclone/__init__.py +3 -0
- easyclone/rclone/backup.py +59 -0
- easyclone/rclone/create_dirs.py +159 -0
- easyclone/rclone/operations.py +48 -0
- easyclone/shared/__init__.py +3 -0
- easyclone/shared/sync_status.py +61 -0
- easyclone/utils/__init__.py +2 -0
- easyclone/utils/essentials.py +24 -0
- easyclone/utils/path_manipulation.py +37 -0
- easyclone/utypes/__init__.py +3 -0
- easyclone/utypes/config.py +16 -0
- easyclone/utypes/enums.py +29 -0
- easyclone/utypes/models.py +11 -0
- easyclone-0.1.0.dist-info/METADATA +62 -0
- easyclone-0.1.0.dist-info/RECORD +25 -0
- easyclone-0.1.0.dist-info/WHEEL +5 -0
- easyclone-0.1.0.dist-info/entry_points.txt +2 -0
- easyclone-0.1.0.dist-info/licenses/LICENSE +674 -0
- easyclone-0.1.0.dist-info/top_level.txt +1 -0
easyclone/__main__.py
ADDED
easyclone/config.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from threading import Lock
|
|
3
|
+
from os import getenv
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from easyclone.utypes.enums import LogLevel
|
|
6
|
+
from easyclone.utypes.config import BackupConfigModel, ConfigModel, RcloneConfigModel
|
|
7
|
+
import toml
|
|
8
|
+
|
|
9
|
+
class Config:
|
|
10
|
+
_instance: Config | None = None
|
|
11
|
+
_lock: Lock = Lock()
|
|
12
|
+
_path: Path = Path.home() / '.config' / "easyclone" / "config.toml"
|
|
13
|
+
_config: ConfigModel | None = None
|
|
14
|
+
|
|
15
|
+
def __new__(cls):
|
|
16
|
+
if cls._instance is None:
|
|
17
|
+
with cls._lock:
|
|
18
|
+
instance = super().__new__(cls)
|
|
19
|
+
instance._get_config_path()
|
|
20
|
+
instance._config = instance._load_config()
|
|
21
|
+
cls._instance = instance
|
|
22
|
+
|
|
23
|
+
return cls._instance
|
|
24
|
+
|
|
25
|
+
def _get_config_path(self):
|
|
26
|
+
xdg_config_home = getenv("XDG_CONFIG_HOME")
|
|
27
|
+
|
|
28
|
+
empty_config = ConfigModel(
|
|
29
|
+
backup=BackupConfigModel(
|
|
30
|
+
sync_paths=[],
|
|
31
|
+
copy_paths=[],
|
|
32
|
+
remote_name="GoogleDrive",
|
|
33
|
+
root_dir="Backups/PC",
|
|
34
|
+
verbose_log=False
|
|
35
|
+
),
|
|
36
|
+
rclone=RcloneConfigModel(
|
|
37
|
+
args=[
|
|
38
|
+
"--update",
|
|
39
|
+
"--verbose",
|
|
40
|
+
"--transfers 30",
|
|
41
|
+
"--checkers 8",
|
|
42
|
+
"--contimeout 60s",
|
|
43
|
+
"--timeout 300s",
|
|
44
|
+
"--retries 3",
|
|
45
|
+
"--low-level-retries 10",
|
|
46
|
+
"--stats 1s"
|
|
47
|
+
],
|
|
48
|
+
concurrent_limit=50
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if xdg_config_home:
|
|
53
|
+
config_dir = Path(xdg_config_home) / 'easyclone'
|
|
54
|
+
else:
|
|
55
|
+
config_dir = Path.home() / '.config' / "easyclone"
|
|
56
|
+
|
|
57
|
+
config_file = config_dir / "config.toml"
|
|
58
|
+
|
|
59
|
+
if not config_dir.exists():
|
|
60
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
if not config_file.exists():
|
|
63
|
+
config_file.touch()
|
|
64
|
+
with open(config_file, "w") as f:
|
|
65
|
+
_ = toml.dump(empty_config.model_dump(), f)
|
|
66
|
+
|
|
67
|
+
self._path = config_file
|
|
68
|
+
|
|
69
|
+
def _load_config(self):
|
|
70
|
+
from easyclone.utils.essentials import log
|
|
71
|
+
self._get_config_path()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
with open(self._path) as f:
|
|
76
|
+
parsed_string = f.read()
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
log(f"Config file not found at {self._path}.", LogLevel.ERROR)
|
|
79
|
+
exit(1)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
log(f"Error while opening the config file {self._path}: {e}", LogLevel.ERROR)
|
|
82
|
+
exit(1)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
parsed_toml = toml.loads(parsed_string)
|
|
86
|
+
validated_config = ConfigModel.model_validate(parsed_toml)
|
|
87
|
+
return validated_config
|
|
88
|
+
except Exception as e:
|
|
89
|
+
log(f"Invalid config: {e}", LogLevel.ERROR)
|
|
90
|
+
exit(1)
|
|
91
|
+
|
|
92
|
+
def config(self) -> ConfigModel:
|
|
93
|
+
if self._config is None:
|
|
94
|
+
raise RuntimeError("Config is not loaded yet")
|
|
95
|
+
return self._config
|
|
96
|
+
|
|
97
|
+
cfg = Config().config()
|
easyclone/ipc/client.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from easyclone.utils.essentials import log
|
|
5
|
+
from easyclone.utypes.enums import LogLevel
|
|
6
|
+
|
|
7
|
+
SOCKET_PATH = "/tmp/easyclone.sock"
|
|
8
|
+
|
|
9
|
+
async def listen_ipc():
|
|
10
|
+
try:
|
|
11
|
+
reader, writer = await asyncio.open_unix_connection(SOCKET_PATH)
|
|
12
|
+
except (FileNotFoundError, ConnectionRefusedError):
|
|
13
|
+
log("No tasks are running at the moment.", LogLevel.ERROR)
|
|
14
|
+
exit(1)
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
line = await reader.readline()
|
|
18
|
+
if not line:
|
|
19
|
+
log("No tasks are running at the moment", LogLevel.ERROR)
|
|
20
|
+
exit(1)
|
|
21
|
+
|
|
22
|
+
data = json.loads(line.decode())
|
|
23
|
+
return data
|
|
24
|
+
finally:
|
|
25
|
+
writer.close()
|
|
26
|
+
await writer.wait_closed()
|
easyclone/ipc/server.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from easyclone.utypes.enums import LogLevel
|
|
5
|
+
from easyclone.shared import sync_status
|
|
6
|
+
from easyclone.utils.essentials import log
|
|
7
|
+
|
|
8
|
+
SOCKET_PATH = "/tmp/easyclone.sock"
|
|
9
|
+
|
|
10
|
+
async def handle_client(_reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
|
11
|
+
try:
|
|
12
|
+
while True:
|
|
13
|
+
message = json.dumps({
|
|
14
|
+
"total_path_count": await sync_status.get_total_path(),
|
|
15
|
+
"finished_path_count": await sync_status.get_currently_finished(),
|
|
16
|
+
"operation_count": await sync_status.get_operation_count(),
|
|
17
|
+
"operations": await sync_status.get_operations()
|
|
18
|
+
}).encode() + b"\n"
|
|
19
|
+
|
|
20
|
+
writer.write(message)
|
|
21
|
+
|
|
22
|
+
await writer.drain()
|
|
23
|
+
await asyncio.sleep(0.5)
|
|
24
|
+
except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError):
|
|
25
|
+
pass
|
|
26
|
+
except Exception as e:
|
|
27
|
+
print(f"Unexpected error: {e}")
|
|
28
|
+
finally:
|
|
29
|
+
try:
|
|
30
|
+
writer.close()
|
|
31
|
+
await writer.wait_closed()
|
|
32
|
+
except BrokenPipeError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
async def start_status_server():
|
|
36
|
+
try:
|
|
37
|
+
Path(SOCKET_PATH).unlink()
|
|
38
|
+
except FileNotFoundError as e:
|
|
39
|
+
pass
|
|
40
|
+
except Exception as e:
|
|
41
|
+
log(f"Something happened while connecting to the socket for status server: {e}", LogLevel.ERROR)
|
|
42
|
+
raise
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
server = await asyncio.start_unix_server(handle_client, path=SOCKET_PATH)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
log(f"Couldn't create the UNIX server: {e}", LogLevel.ERROR)
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
return server
|
easyclone/main.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Annotated, Any
|
|
3
|
+
from easyclone.config import cfg
|
|
4
|
+
from easyclone.ipc.client import listen_ipc
|
|
5
|
+
from easyclone.ipc.server import start_status_server
|
|
6
|
+
from easyclone.rclone.operations import backup_copy_operation, backup_sync_operation
|
|
7
|
+
import typer
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
from easyclone.shared import sync_status
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
help="Very convenient Rclone bulk backup wrapper",
|
|
14
|
+
context_settings={"help_option_names": ["-h", "--help"]}
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
async def ipc():
|
|
18
|
+
server = await start_status_server()
|
|
19
|
+
async with server:
|
|
20
|
+
await server.serve_forever()
|
|
21
|
+
|
|
22
|
+
@app.command(help="Starts the backup process using the details in the config file.")
|
|
23
|
+
def start_backup(verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enables the rclone logging (overrides config).")] = False):
|
|
24
|
+
async def start():
|
|
25
|
+
await sync_status.set_total_path_count(len(cfg.backup.sync_paths) + len(cfg.backup.copy_paths))
|
|
26
|
+
|
|
27
|
+
_ipc_task = asyncio.create_task(ipc())
|
|
28
|
+
|
|
29
|
+
verbose_state = verbose or cfg.backup.verbose_log
|
|
30
|
+
|
|
31
|
+
await backup_copy_operation(verbose_state)
|
|
32
|
+
await backup_sync_operation(verbose_state)
|
|
33
|
+
|
|
34
|
+
asyncio.run(start())
|
|
35
|
+
|
|
36
|
+
@app.command(help="Gets status information about the backup process.")
|
|
37
|
+
def get_status(
|
|
38
|
+
all: Annotated[bool, typer.Option("--all", "-a", help="Show all the backup status information.")] = False,
|
|
39
|
+
show_total: Annotated[bool, typer.Option("--show-total", "-t", help="Show the total amount of paths.")] = False,
|
|
40
|
+
show_current: Annotated[bool, typer.Option("--show-current", "-c", help="Show the total amount of pending paths.")] = False,
|
|
41
|
+
show_operations: Annotated[bool, typer.Option("--show-operations", "-o", help="Show currently running operations.")] = False
|
|
42
|
+
):
|
|
43
|
+
data: Any = asyncio.run(listen_ipc())
|
|
44
|
+
|
|
45
|
+
if (not show_total and not show_current and not show_operations or all) or (show_total and show_current and show_operations):
|
|
46
|
+
print(json.dumps(data, indent=2))
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if show_total:
|
|
50
|
+
print(data["total_path_count"])
|
|
51
|
+
|
|
52
|
+
if show_current:
|
|
53
|
+
print(data["operation_count"])
|
|
54
|
+
|
|
55
|
+
if show_operations:
|
|
56
|
+
print(json.dumps(data["operations"], indent=2))
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
app()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import shlex
|
|
3
|
+
from easyclone.shared import sync_status
|
|
4
|
+
from easyclone.utypes.enums import BackupLog, BackupStatus, CommandType, LogLevel, RcloneOperationType
|
|
5
|
+
from easyclone.utypes.models import PathItem
|
|
6
|
+
from easyclone.utils.essentials import log
|
|
7
|
+
from easyclone.utils.path_manipulation import collapseuser
|
|
8
|
+
|
|
9
|
+
async def backup_command(rclone_command: list[str], source: str, dest: str, path_type: str, command_type: CommandType, verbose: bool = False):
|
|
10
|
+
cmd = rclone_command + [source, dest]
|
|
11
|
+
operation_name = command_type.value.capitalize() + "ing"
|
|
12
|
+
|
|
13
|
+
log(f"{operation_name} {collapseuser(source)}", BackupLog.WAIT)
|
|
14
|
+
process = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
|
15
|
+
process_id = await sync_status.add_operation(source=source, dest=dest, path_type=path_type, status=BackupStatus.IN_PROGRESS, operation_type=RcloneOperationType.BACKUP)
|
|
16
|
+
|
|
17
|
+
stdout, stderr = await process.communicate()
|
|
18
|
+
stdout = stdout.decode(errors="ignore").strip()
|
|
19
|
+
stderr = stderr.decode(errors="ignore").strip()
|
|
20
|
+
|
|
21
|
+
collapsed_source = collapseuser(source)
|
|
22
|
+
match process.returncode:
|
|
23
|
+
case 0:
|
|
24
|
+
log(f"Backed up successfully: {collapsed_source}", BackupLog.OK)
|
|
25
|
+
case 1:
|
|
26
|
+
log(f"Back up operation failed: {collapsed_source}", BackupLog.ERR)
|
|
27
|
+
case _:
|
|
28
|
+
log(f"Back up operation failed with {process.returncode} exit code: {collapsed_source}", BackupLog.ERR)
|
|
29
|
+
|
|
30
|
+
await sync_status.delete_operation(process_id)
|
|
31
|
+
await sync_status.add_currently_finished()
|
|
32
|
+
|
|
33
|
+
if verbose:
|
|
34
|
+
if stderr:
|
|
35
|
+
log(f"{stderr}", LogLevel.WARN)
|
|
36
|
+
if stdout:
|
|
37
|
+
log(f"{stdout}", LogLevel.LOG)
|
|
38
|
+
|
|
39
|
+
async def backup(paths: list[PathItem], command_type: CommandType, rclone_args: list[str], semaphore: asyncio.Semaphore, verbose: bool = False):
|
|
40
|
+
cmd = ["rclone", command_type.value]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
for arg in rclone_args:
|
|
44
|
+
parts = shlex.split(arg)
|
|
45
|
+
cmd += parts
|
|
46
|
+
|
|
47
|
+
# I have to do this because python doesn't have anon coroutines
|
|
48
|
+
async def backup_task(source: str, dest: str, path_type: str):
|
|
49
|
+
async with semaphore:
|
|
50
|
+
await backup_command(cmd, source, dest, path_type, command_type, verbose)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
tasks = [
|
|
54
|
+
backup_task(path["source"], path["dest"], path["path_type"])
|
|
55
|
+
for path in paths
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
return await asyncio.gather(*tasks)
|
|
59
|
+
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import asyncio
|
|
3
|
+
from easyclone.shared import sync_status
|
|
4
|
+
from easyclone.utils.essentials import log
|
|
5
|
+
from easyclone.utypes.models import PathItem
|
|
6
|
+
from easyclone.utypes.enums import BackupLog, BackupStatus, LogLevel, PathType, RcloneOperationType
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from collections import deque
|
|
10
|
+
from easyclone.config import cfg
|
|
11
|
+
|
|
12
|
+
class DirNode:
|
|
13
|
+
def __init__(self, name: str, details: PathItem):
|
|
14
|
+
self.name: str = name
|
|
15
|
+
self.details: PathItem = details
|
|
16
|
+
self.children: list[DirNode] = []
|
|
17
|
+
|
|
18
|
+
def find_child(self, name: str) -> "DirNode | None":
|
|
19
|
+
for child in self.children:
|
|
20
|
+
if child.name == name:
|
|
21
|
+
return child
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
def add_child(self, name: str, details: PathItem) -> "DirNode":
|
|
25
|
+
existing = self.find_child(name)
|
|
26
|
+
if existing:
|
|
27
|
+
return existing
|
|
28
|
+
new_child = DirNode(name, details)
|
|
29
|
+
self.children.append(new_child)
|
|
30
|
+
return new_child
|
|
31
|
+
|
|
32
|
+
def print_tree(self, level: int=0):
|
|
33
|
+
indent = " " * level
|
|
34
|
+
print(f"{indent}- {self.name} ({self.details.get('dest')})")
|
|
35
|
+
for child in sorted(self.children, key=lambda c: c.name):
|
|
36
|
+
child.print_tree(level + 1)
|
|
37
|
+
|
|
38
|
+
def create_dirs_array(path_list: list[PathItem]):
|
|
39
|
+
only_dirs: list[PathItem] = []
|
|
40
|
+
|
|
41
|
+
for path in path_list:
|
|
42
|
+
new_dir_path: PathItem
|
|
43
|
+
|
|
44
|
+
if path.get("type") == "dir":
|
|
45
|
+
new_dir_path = path
|
|
46
|
+
else:
|
|
47
|
+
new_dir_path = {
|
|
48
|
+
"source": os.path.dirname(path.get("source")),
|
|
49
|
+
"dest": path.get("dest"),
|
|
50
|
+
"path_type": PathType.DIR.value
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if new_dir_path not in only_dirs:
|
|
54
|
+
only_dirs.append(new_dir_path)
|
|
55
|
+
|
|
56
|
+
return only_dirs
|
|
57
|
+
|
|
58
|
+
def create_dir_tree(path_list: list[PathItem]):
|
|
59
|
+
root = DirNode("Root", {"source": "/", "dest": f"{cfg.backup.remote_name}:{str(cfg.backup.root_dir).rstrip("/")}", "path_type": "dir",})
|
|
60
|
+
|
|
61
|
+
for path_item in path_list:
|
|
62
|
+
source_str = path_item.get("source")
|
|
63
|
+
dest_str = path_item.get("dest")
|
|
64
|
+
|
|
65
|
+
source_parts = Path(source_str).parts
|
|
66
|
+
dest_main, _, dest_path = dest_str.partition(":")
|
|
67
|
+
|
|
68
|
+
if dest_path:
|
|
69
|
+
dest_parts = Path(dest_path).parts
|
|
70
|
+
else:
|
|
71
|
+
dest_parts = []
|
|
72
|
+
|
|
73
|
+
current = root
|
|
74
|
+
|
|
75
|
+
for i in range(1, len(source_parts)):
|
|
76
|
+
source_sub_path = str(Path(*source_parts[:i+1]))
|
|
77
|
+
|
|
78
|
+
if dest_path:
|
|
79
|
+
dest_sub_path = f"{dest_main}:{Path(*dest_parts[:i+2])}"
|
|
80
|
+
else:
|
|
81
|
+
dest_sub_path = ""
|
|
82
|
+
|
|
83
|
+
node_details: PathItem = {
|
|
84
|
+
"source": source_sub_path,
|
|
85
|
+
"dest": dest_sub_path,
|
|
86
|
+
"path_type": PathType.DIR.value
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
part_name = source_parts[i]
|
|
90
|
+
current = current.add_child(part_name, node_details)
|
|
91
|
+
|
|
92
|
+
return root
|
|
93
|
+
|
|
94
|
+
async def create_folder_command(source: str, dest: str, verbose: bool):
|
|
95
|
+
lsd_cmd = ["rclone", "lsd", dest]
|
|
96
|
+
log(f"Checking if directory exist at {dest}", BackupLog.WAIT)
|
|
97
|
+
|
|
98
|
+
process = await asyncio.create_subprocess_exec(*lsd_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
|
99
|
+
stdout, stderr = await process.communicate()
|
|
100
|
+
|
|
101
|
+
if process.returncode == 0:
|
|
102
|
+
log(f"Directory exist at {dest}", BackupLog.OK)
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
mkdir_cmd = ["rclone", "mkdir", dest]
|
|
106
|
+
|
|
107
|
+
log(f"Creating a directory at {dest}", BackupLog.WAIT)
|
|
108
|
+
process = await asyncio.create_subprocess_exec(*mkdir_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
|
109
|
+
process_id = await sync_status.add_operation(source=source, dest=dest, path_type=PathType.DIR.value, status=BackupStatus.IN_PROGRESS, operation_type=RcloneOperationType.MKDIR)
|
|
110
|
+
|
|
111
|
+
stdout, stderr = await process.communicate()
|
|
112
|
+
stdout = stdout.decode(errors="ignore").strip()
|
|
113
|
+
stderr = stderr.decode(errors="ignore").strip()
|
|
114
|
+
|
|
115
|
+
match process.returncode:
|
|
116
|
+
case 0:
|
|
117
|
+
log(f"Directory created successfully: {dest}", BackupLog.OK)
|
|
118
|
+
case 1:
|
|
119
|
+
log(f"Couldn't create the directory: {dest}", BackupLog.ERR)
|
|
120
|
+
case _:
|
|
121
|
+
log(f"Operation failed with {process.returncode} exit code: {dest}", BackupLog.ERR)
|
|
122
|
+
|
|
123
|
+
await sync_status.delete_operation(process_id)
|
|
124
|
+
|
|
125
|
+
if verbose:
|
|
126
|
+
if stderr:
|
|
127
|
+
log(f"{stderr}", LogLevel.WARN)
|
|
128
|
+
if stdout:
|
|
129
|
+
log(f"{stdout}", LogLevel.LOG)
|
|
130
|
+
|
|
131
|
+
async def create_folders_on_remote(nodes: list[DirNode], semaphore: asyncio.Semaphore, verbose: bool):
|
|
132
|
+
async def mkdir_task(source: str, dest: str):
|
|
133
|
+
async with semaphore:
|
|
134
|
+
await create_folder_command(source, dest, verbose)
|
|
135
|
+
|
|
136
|
+
tasks = [mkdir_task(node.details.get("source"), node.details.get("dest")) for node in nodes]
|
|
137
|
+
|
|
138
|
+
_ = await asyncio.gather(*tasks)
|
|
139
|
+
|
|
140
|
+
async def traverse_and_create_folders_by_depth(root: DirNode, verbose: bool, semaphore: asyncio.Semaphore):
|
|
141
|
+
queue = deque([(root, 0)])
|
|
142
|
+
current_depth = 0
|
|
143
|
+
current_level_nodes: list[DirNode] = []
|
|
144
|
+
|
|
145
|
+
while queue:
|
|
146
|
+
node, depth = queue.popleft()
|
|
147
|
+
|
|
148
|
+
if depth != current_depth:
|
|
149
|
+
await create_folders_on_remote(current_level_nodes, semaphore, verbose)
|
|
150
|
+
current_level_nodes = []
|
|
151
|
+
current_depth = depth
|
|
152
|
+
|
|
153
|
+
current_level_nodes.append(node)
|
|
154
|
+
|
|
155
|
+
for child in node.children:
|
|
156
|
+
queue.append((child, depth + 1))
|
|
157
|
+
|
|
158
|
+
if current_level_nodes:
|
|
159
|
+
await create_folders_on_remote(current_level_nodes, semaphore, verbose)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from easyclone.config import cfg
|
|
3
|
+
from easyclone.rclone.backup import backup
|
|
4
|
+
from easyclone.rclone.create_dirs import create_dir_tree, create_dirs_array, traverse_and_create_folders_by_depth
|
|
5
|
+
from easyclone.utils.path_manipulation import organize_paths
|
|
6
|
+
from easyclone.utypes.enums import CommandType
|
|
7
|
+
|
|
8
|
+
async def backup_copy_operation(verbose: bool):
|
|
9
|
+
copy_paths = organize_paths(cfg.backup.copy_paths, cfg.backup.remote_name)
|
|
10
|
+
copy_task_semaphore = asyncio.Semaphore(cfg.rclone.concurrent_limit)
|
|
11
|
+
copy_dirs_task_semaphore = asyncio.Semaphore(cfg.rclone.concurrent_limit)
|
|
12
|
+
copy_dirs_array = create_dirs_array(copy_paths)
|
|
13
|
+
copy_dirs_root = create_dir_tree(copy_dirs_array)
|
|
14
|
+
|
|
15
|
+
_copy_folders_create_operation = await traverse_and_create_folders_by_depth(
|
|
16
|
+
root=copy_dirs_root,
|
|
17
|
+
verbose=verbose,
|
|
18
|
+
semaphore=copy_dirs_task_semaphore
|
|
19
|
+
)
|
|
20
|
+
_copy_operation = await backup(
|
|
21
|
+
paths=copy_paths,
|
|
22
|
+
command_type=CommandType.COPY,
|
|
23
|
+
rclone_args=cfg.rclone.args,
|
|
24
|
+
semaphore=copy_task_semaphore,
|
|
25
|
+
verbose=verbose
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def backup_sync_operation(verbose: bool):
|
|
30
|
+
sync_paths = organize_paths(cfg.backup.sync_paths, cfg.backup.remote_name)
|
|
31
|
+
sync_task_semaphore = asyncio.Semaphore(cfg.rclone.concurrent_limit)
|
|
32
|
+
sync_dirs_task_semaphore = asyncio.Semaphore(cfg.rclone.concurrent_limit)
|
|
33
|
+
sync_dirs_array = create_dirs_array(sync_paths)
|
|
34
|
+
sync_dirs_root = create_dir_tree(sync_dirs_array)
|
|
35
|
+
|
|
36
|
+
_sync_folders_create_operation = await traverse_and_create_folders_by_depth(
|
|
37
|
+
root=sync_dirs_root,
|
|
38
|
+
verbose=verbose,
|
|
39
|
+
semaphore=sync_dirs_task_semaphore
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_sync_operation = await backup(
|
|
43
|
+
paths=sync_paths,
|
|
44
|
+
command_type=CommandType.COPY,
|
|
45
|
+
rclone_args=cfg.rclone.args,
|
|
46
|
+
semaphore=sync_task_semaphore,
|
|
47
|
+
verbose=verbose
|
|
48
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from easyclone.utypes.enums import BackupStatus, RcloneOperationType
|
|
3
|
+
from easyclone.utypes.models import SyncStatusItem
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
class SyncStatus:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.total_path_count: int = 0
|
|
9
|
+
self.operations: list[SyncStatusItem] = []
|
|
10
|
+
self.lock: asyncio.Lock = asyncio.Lock()
|
|
11
|
+
self.finished_path_count: int = 0
|
|
12
|
+
|
|
13
|
+
async def add_operation(self, source: str, dest: str, path_type: str, status: BackupStatus, operation_type: RcloneOperationType):
|
|
14
|
+
random_id = str(uuid.uuid4())
|
|
15
|
+
|
|
16
|
+
async with self.lock:
|
|
17
|
+
self.operations.append({
|
|
18
|
+
"id": random_id,
|
|
19
|
+
"source": source,
|
|
20
|
+
"dest": dest,
|
|
21
|
+
"status": status.value,
|
|
22
|
+
"path_type": path_type,
|
|
23
|
+
"operation_type": operation_type.value
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
return random_id
|
|
27
|
+
|
|
28
|
+
async def delete_operation(self, target_id: str):
|
|
29
|
+
async with self.lock:
|
|
30
|
+
for index, item in enumerate(self.operations):
|
|
31
|
+
if item.get("id") == target_id:
|
|
32
|
+
del self.operations[index]
|
|
33
|
+
break;
|
|
34
|
+
|
|
35
|
+
async def get_operations(self):
|
|
36
|
+
async with self.lock:
|
|
37
|
+
return self.operations
|
|
38
|
+
|
|
39
|
+
async def get_operation_count(self):
|
|
40
|
+
async with self.lock:
|
|
41
|
+
return len(self.operations)
|
|
42
|
+
|
|
43
|
+
async def set_total_path_count(self, count: int):
|
|
44
|
+
async with self.lock:
|
|
45
|
+
self.total_path_count = count
|
|
46
|
+
|
|
47
|
+
async def reset_total_paths(self):
|
|
48
|
+
async with self.lock:
|
|
49
|
+
self.total_path_count = 0
|
|
50
|
+
|
|
51
|
+
async def get_total_path(self):
|
|
52
|
+
async with self.lock:
|
|
53
|
+
return self.total_path_count
|
|
54
|
+
|
|
55
|
+
async def add_currently_finished(self):
|
|
56
|
+
async with self.lock:
|
|
57
|
+
self.finished_path_count += 1
|
|
58
|
+
|
|
59
|
+
async def get_currently_finished(self):
|
|
60
|
+
async with self.lock:
|
|
61
|
+
return self.finished_path_count
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from easyclone.utypes.enums import LogLevel, BackupLog
|
|
2
|
+
|
|
3
|
+
def log(message: str, logtype: LogLevel | BackupLog) -> None:
|
|
4
|
+
color = "\033[32;1m"
|
|
5
|
+
|
|
6
|
+
match logtype:
|
|
7
|
+
case LogLevel.ERROR | BackupLog.ERR:
|
|
8
|
+
# RED
|
|
9
|
+
color = "\033[31;1m"
|
|
10
|
+
case LogLevel.LOG:
|
|
11
|
+
# BLUE
|
|
12
|
+
color = "\033[34;1m"
|
|
13
|
+
case LogLevel.INFO | BackupLog.OK:
|
|
14
|
+
# GREEN
|
|
15
|
+
color = "\033[32;1m"
|
|
16
|
+
case LogLevel.WARN | BackupLog.WAIT:
|
|
17
|
+
# YELLOW
|
|
18
|
+
color = "\033[33;1m"
|
|
19
|
+
|
|
20
|
+
full_msg = f"{color}{logtype.value}"
|
|
21
|
+
if(isinstance(logtype, LogLevel)):
|
|
22
|
+
full_msg = full_msg + ":"
|
|
23
|
+
|
|
24
|
+
print(f"{full_msg}\033[0m {message}")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
from easyclone.utypes.enums import PathType
|
|
4
|
+
from easyclone.utypes.models import PathItem
|
|
5
|
+
|
|
6
|
+
def organize_paths(paths: list[str], remote_name: str) -> list[PathItem]:
|
|
7
|
+
from easyclone.config import cfg
|
|
8
|
+
source_dest_array: list[PathItem] = []
|
|
9
|
+
root_dir = cfg.backup.root_dir
|
|
10
|
+
|
|
11
|
+
for path in paths:
|
|
12
|
+
p = Path(path).expanduser()
|
|
13
|
+
|
|
14
|
+
if p.is_dir():
|
|
15
|
+
source_dest_array.append({
|
|
16
|
+
"source": f"{p}",
|
|
17
|
+
"dest": f"{remote_name}:{root_dir}{p}",
|
|
18
|
+
"path_type": PathType.DIR.value
|
|
19
|
+
})
|
|
20
|
+
elif p.is_file():
|
|
21
|
+
dest_dir = p.parent
|
|
22
|
+
source_dest_array.append({
|
|
23
|
+
"source": f"{p}",
|
|
24
|
+
"dest": f"{remote_name}:{root_dir}{dest_dir}",
|
|
25
|
+
"path_type": PathType.FILE.value
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
return source_dest_array
|
|
29
|
+
|
|
30
|
+
def collapseuser(path: str) -> str:
|
|
31
|
+
"""
|
|
32
|
+
Opposite of path.expanduser()
|
|
33
|
+
"""
|
|
34
|
+
home = os.path.expanduser("~")
|
|
35
|
+
if path.startswith(home):
|
|
36
|
+
return path.replace(home, "~", 1)
|
|
37
|
+
return path
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
class BackupConfigModel(BaseModel):
|
|
4
|
+
sync_paths: list[str]
|
|
5
|
+
copy_paths: list[str]
|
|
6
|
+
remote_name: str
|
|
7
|
+
root_dir: str
|
|
8
|
+
verbose_log: bool
|
|
9
|
+
|
|
10
|
+
class RcloneConfigModel(BaseModel):
|
|
11
|
+
args: list[str]
|
|
12
|
+
concurrent_limit: int
|
|
13
|
+
|
|
14
|
+
class ConfigModel(BaseModel):
|
|
15
|
+
backup: BackupConfigModel
|
|
16
|
+
rclone: RcloneConfigModel
|