easyclone 0.1.0__tar.gz → 0.3.0__tar.gz
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-0.1.0/src/easyclone.egg-info → easyclone-0.3.0}/PKG-INFO +25 -12
- {easyclone-0.1.0 → easyclone-0.3.0}/README.md +24 -11
- {easyclone-0.1.0 → easyclone-0.3.0}/pyproject.toml +1 -1
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/config.py +7 -17
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/ipc/server.py +1 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/main.py +17 -6
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/rclone/__init__.py +1 -1
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/rclone/backup.py +0 -1
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/rclone/create_dirs.py +1 -1
- easyclone-0.3.0/src/easyclone/rclone/operations.py +35 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/shared/sync_status.py +10 -1
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/utils/__init__.py +1 -1
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/utils/essentials.py +15 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/utils/path_manipulation.py +13 -5
- easyclone-0.3.0/src/easyclone/utypes/config.py +26 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/utypes/models.py +5 -0
- {easyclone-0.1.0 → easyclone-0.3.0/src/easyclone.egg-info}/PKG-INFO +25 -12
- easyclone-0.1.0/src/easyclone/rclone/operations.py +0 -48
- easyclone-0.1.0/src/easyclone/utypes/config.py +0 -16
- {easyclone-0.1.0 → easyclone-0.3.0}/LICENSE +0 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/setup.cfg +0 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/__main__.py +0 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/ipc/__init__.py +0 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/ipc/client.py +0 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/shared/__init__.py +0 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/utypes/__init__.py +0 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone/utypes/enums.py +0 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone.egg-info/SOURCES.txt +0 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone.egg-info/dependency_links.txt +0 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone.egg-info/entry_points.txt +0 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone.egg-info/requires.txt +0 -0
- {easyclone-0.1.0 → easyclone-0.3.0}/src/easyclone.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: easyclone
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Very convenient Rclone bulk backup wrapper
|
|
5
5
|
Project-URL: Repository, https://github.com/dybdeskarphet/easyclone
|
|
6
6
|
Project-URL: Documentation, https://github.com/dybdeskarphet/easyclone
|
|
@@ -19,15 +19,28 @@ Dynamic: license-file
|
|
|
19
19
|
|
|
20
20
|
You define what to back up, where to back it up, and EasyClone handles the syncs and copies — clean, fast, and reliable.
|
|
21
21
|
|
|
22
|
-
##
|
|
22
|
+
## Features
|
|
23
23
|
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
24
|
+
* Sync & Copy support per-path
|
|
25
|
+
* Backup multiple paths at once
|
|
26
|
+
* Human-friendly TOML config
|
|
27
|
+
* IPC-ready architecture for future GUI or monitoring tools
|
|
28
|
+
* Optional verbose logging
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
Install it with `pip` or `pipx`:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install easyclone
|
|
36
|
+
pipx install easyclone
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
The config file is at `~/.config/easyclone/config.toml`
|
|
42
|
+
|
|
43
|
+
## Requirements
|
|
31
44
|
|
|
32
45
|
* Python **3.13+**
|
|
33
46
|
* [`rclone`](https://rclone.org/) installed and accessible in your `$PATH`
|
|
@@ -35,7 +48,7 @@ You define what to back up, where to back it up, and EasyClone handles the syncs
|
|
|
35
48
|
* `toml>=0.10.2`
|
|
36
49
|
* `typer>=0.16.0`
|
|
37
50
|
|
|
38
|
-
##
|
|
51
|
+
## Example Usage
|
|
39
52
|
|
|
40
53
|
```bash
|
|
41
54
|
easyclone start-backup
|
|
@@ -47,16 +60,16 @@ It will:
|
|
|
47
60
|
* Copy the paths in `copy_paths`
|
|
48
61
|
* Use the `remote_name` and `root_dir` to target your cloud storage
|
|
49
62
|
|
|
50
|
-
##
|
|
63
|
+
## Contributing
|
|
51
64
|
|
|
52
65
|
PRs welcome. Bug reports even more welcome.
|
|
53
66
|
|
|
54
|
-
##
|
|
67
|
+
## FAQ
|
|
55
68
|
|
|
56
69
|
Why does it create the folders first?
|
|
57
70
|
> Because services like Google Drive support multiple folders with the same name in the same directory. So when you try to concurrently backup paths from the same directory, it will create the parent directory more than once, and we don't want that.
|
|
58
71
|
|
|
59
|
-
##
|
|
72
|
+
## License
|
|
60
73
|
|
|
61
74
|
GPLv3 — do whatever you want, just don't blame me if you sync your `/` folder to the cloud :)
|
|
62
75
|
|
|
@@ -4,15 +4,28 @@
|
|
|
4
4
|
|
|
5
5
|
You define what to back up, where to back it up, and EasyClone handles the syncs and copies — clean, fast, and reliable.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Features
|
|
8
8
|
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Sync & Copy support per-path
|
|
10
|
+
* Backup multiple paths at once
|
|
11
|
+
* Human-friendly TOML config
|
|
12
|
+
* IPC-ready architecture for future GUI or monitoring tools
|
|
13
|
+
* Optional verbose logging
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Install it with `pip` or `pipx`:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install easyclone
|
|
21
|
+
pipx install easyclone
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
The config file is at `~/.config/easyclone/config.toml`
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
16
29
|
|
|
17
30
|
* Python **3.13+**
|
|
18
31
|
* [`rclone`](https://rclone.org/) installed and accessible in your `$PATH`
|
|
@@ -20,7 +33,7 @@ You define what to back up, where to back it up, and EasyClone handles the syncs
|
|
|
20
33
|
* `toml>=0.10.2`
|
|
21
34
|
* `typer>=0.16.0`
|
|
22
35
|
|
|
23
|
-
##
|
|
36
|
+
## Example Usage
|
|
24
37
|
|
|
25
38
|
```bash
|
|
26
39
|
easyclone start-backup
|
|
@@ -32,16 +45,16 @@ It will:
|
|
|
32
45
|
* Copy the paths in `copy_paths`
|
|
33
46
|
* Use the `remote_name` and `root_dir` to target your cloud storage
|
|
34
47
|
|
|
35
|
-
##
|
|
48
|
+
## Contributing
|
|
36
49
|
|
|
37
50
|
PRs welcome. Bug reports even more welcome.
|
|
38
51
|
|
|
39
|
-
##
|
|
52
|
+
## FAQ
|
|
40
53
|
|
|
41
54
|
Why does it create the folders first?
|
|
42
55
|
> Because services like Google Drive support multiple folders with the same name in the same directory. So when you try to concurrently backup paths from the same directory, it will create the parent directory more than once, and we don't want that.
|
|
43
56
|
|
|
44
|
-
##
|
|
57
|
+
## License
|
|
45
58
|
|
|
46
59
|
GPLv3 — do whatever you want, just don't blame me if you sync your `/` folder to the cloud :)
|
|
47
60
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
import json
|
|
2
3
|
from threading import Lock
|
|
3
4
|
from os import getenv
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from easyclone.utypes.enums import LogLevel
|
|
6
|
-
from easyclone.utypes.config import BackupConfigModel, ConfigModel
|
|
7
|
+
from easyclone.utypes.config import BackupConfigModel, ConfigModel
|
|
7
8
|
import toml
|
|
8
9
|
|
|
9
10
|
class Config:
|
|
@@ -31,21 +32,6 @@ class Config:
|
|
|
31
32
|
copy_paths=[],
|
|
32
33
|
remote_name="GoogleDrive",
|
|
33
34
|
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
35
|
)
|
|
50
36
|
)
|
|
51
37
|
|
|
@@ -66,11 +52,14 @@ class Config:
|
|
|
66
52
|
|
|
67
53
|
self._path = config_file
|
|
68
54
|
|
|
55
|
+
def _config_normalize(self, config: ConfigModel):
|
|
56
|
+
config.backup.root_dir = config.backup.root_dir.strip("/")
|
|
57
|
+
return config
|
|
58
|
+
|
|
69
59
|
def _load_config(self):
|
|
70
60
|
from easyclone.utils.essentials import log
|
|
71
61
|
self._get_config_path()
|
|
72
62
|
|
|
73
|
-
|
|
74
63
|
try:
|
|
75
64
|
with open(self._path) as f:
|
|
76
65
|
parsed_string = f.read()
|
|
@@ -84,6 +73,7 @@ class Config:
|
|
|
84
73
|
try:
|
|
85
74
|
parsed_toml = toml.loads(parsed_string)
|
|
86
75
|
validated_config = ConfigModel.model_validate(parsed_toml)
|
|
76
|
+
validated_config = self._config_normalize(validated_config)
|
|
87
77
|
return validated_config
|
|
88
78
|
except Exception as e:
|
|
89
79
|
log(f"Invalid config: {e}", LogLevel.ERROR)
|
|
@@ -13,6 +13,7 @@ async def handle_client(_reader: asyncio.StreamReader, writer: asyncio.StreamWri
|
|
|
13
13
|
message = json.dumps({
|
|
14
14
|
"total_path_count": await sync_status.get_total_path(),
|
|
15
15
|
"finished_path_count": await sync_status.get_currently_finished(),
|
|
16
|
+
"empty_paths": await sync_status.get_empty_paths(),
|
|
16
17
|
"operation_count": await sync_status.get_operation_count(),
|
|
17
18
|
"operations": await sync_status.get_operations()
|
|
18
19
|
}).encode() + b"\n"
|
|
@@ -3,11 +3,12 @@ from typing import Annotated, Any
|
|
|
3
3
|
from easyclone.config import cfg
|
|
4
4
|
from easyclone.ipc.client import listen_ipc
|
|
5
5
|
from easyclone.ipc.server import start_status_server
|
|
6
|
-
from easyclone.rclone.operations import
|
|
6
|
+
from easyclone.rclone.operations import make_backup_operation
|
|
7
7
|
import typer
|
|
8
8
|
import json
|
|
9
|
-
|
|
10
9
|
from easyclone.shared import sync_status
|
|
10
|
+
from easyclone.utils.essentials import exit_if_currently_running, exit_if_no_rclone
|
|
11
|
+
from easyclone.utypes.enums import CommandType
|
|
11
12
|
|
|
12
13
|
app = typer.Typer(
|
|
13
14
|
help="Very convenient Rclone bulk backup wrapper",
|
|
@@ -22,14 +23,16 @@ async def ipc():
|
|
|
22
23
|
@app.command(help="Starts the backup process using the details in the config file.")
|
|
23
24
|
def start_backup(verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enables the rclone logging (overrides config).")] = False):
|
|
24
25
|
async def start():
|
|
26
|
+
exit_if_no_rclone()
|
|
27
|
+
exit_if_currently_running()
|
|
25
28
|
await sync_status.set_total_path_count(len(cfg.backup.sync_paths) + len(cfg.backup.copy_paths))
|
|
26
29
|
|
|
27
30
|
_ipc_task = asyncio.create_task(ipc())
|
|
28
31
|
|
|
29
32
|
verbose_state = verbose or cfg.backup.verbose_log
|
|
30
33
|
|
|
31
|
-
await
|
|
32
|
-
await
|
|
34
|
+
await make_backup_operation(CommandType.COPY, cfg.backup.copy_paths, verbose_state)()
|
|
35
|
+
await make_backup_operation(CommandType.SYNC, cfg.backup.sync_paths, verbose_state)()
|
|
33
36
|
|
|
34
37
|
asyncio.run(start())
|
|
35
38
|
|
|
@@ -38,11 +41,13 @@ def get_status(
|
|
|
38
41
|
all: Annotated[bool, typer.Option("--all", "-a", help="Show all the backup status information.")] = False,
|
|
39
42
|
show_total: Annotated[bool, typer.Option("--show-total", "-t", help="Show the total amount of paths.")] = False,
|
|
40
43
|
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
|
|
44
|
+
show_operations: Annotated[bool, typer.Option("--show-operations", "-o", help="Show currently running operations.")] = False,
|
|
45
|
+
show_operation_count: Annotated[bool, typer.Option("--get-operation-count", "-O", help="Show the total amount of running operations.")] = False,
|
|
46
|
+
show_empty_paths: Annotated[bool, typer.Option("--show-empty-paths", "-e", help="Show all the empty paths.")] = False
|
|
42
47
|
):
|
|
43
48
|
data: Any = asyncio.run(listen_ipc())
|
|
44
49
|
|
|
45
|
-
if (not show_total and not show_current and not show_operations or all) or (show_total and show_current and show_operations):
|
|
50
|
+
if (not show_total and not show_current and not show_operations and not show_operation_count and not show_empty_paths or all) or (show_total and show_current and show_operations and show_operation_count and show_empty_paths):
|
|
46
51
|
print(json.dumps(data, indent=2))
|
|
47
52
|
return
|
|
48
53
|
|
|
@@ -55,5 +60,11 @@ def get_status(
|
|
|
55
60
|
if show_operations:
|
|
56
61
|
print(json.dumps(data["operations"], indent=2))
|
|
57
62
|
|
|
63
|
+
if show_operation_count:
|
|
64
|
+
print(json.dumps(data["operation_count"], indent=2))
|
|
65
|
+
|
|
66
|
+
if show_empty_paths:
|
|
67
|
+
print(json.dumps(data["empty_paths"], indent=2))
|
|
68
|
+
|
|
58
69
|
if __name__ == "__main__":
|
|
59
70
|
app()
|
|
@@ -39,7 +39,6 @@ async def backup_command(rclone_command: list[str], source: str, dest: str, path
|
|
|
39
39
|
async def backup(paths: list[PathItem], command_type: CommandType, rclone_args: list[str], semaphore: asyncio.Semaphore, verbose: bool = False):
|
|
40
40
|
cmd = ["rclone", command_type.value]
|
|
41
41
|
|
|
42
|
-
|
|
43
42
|
for arg in rclone_args:
|
|
44
43
|
parts = shlex.split(arg)
|
|
45
44
|
cmd += parts
|
|
@@ -56,7 +56,7 @@ def create_dirs_array(path_list: list[PathItem]):
|
|
|
56
56
|
return only_dirs
|
|
57
57
|
|
|
58
58
|
def create_dir_tree(path_list: list[PathItem]):
|
|
59
|
-
root = DirNode("Root", {"source": "/", "dest": f"{cfg.backup.remote_name}:{
|
|
59
|
+
root = DirNode("Root", {"source": "/", "dest": f"{cfg.backup.remote_name}:{cfg.backup.root_dir}", "path_type": "dir",})
|
|
60
60
|
|
|
61
61
|
for path_item in path_list:
|
|
62
62
|
source_str = path_item.get("source")
|
|
@@ -0,0 +1,35 @@
|
|
|
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.shared import sync_status
|
|
6
|
+
from easyclone.utils.essentials import log
|
|
7
|
+
from easyclone.utils.path_manipulation import organize_paths
|
|
8
|
+
from easyclone.utypes.enums import CommandType, LogLevel
|
|
9
|
+
|
|
10
|
+
def make_backup_operation(command_type: CommandType, paths_config: list[str], verbose: bool):
|
|
11
|
+
async def backup_operation():
|
|
12
|
+
paths = organize_paths(paths_config, cfg.backup.remote_name)
|
|
13
|
+
task_semaphore = asyncio.Semaphore(cfg.rclone.concurrent_limit)
|
|
14
|
+
dirs_task_semaphore = asyncio.Semaphore(cfg.rclone.concurrent_limit)
|
|
15
|
+
dirs_array = create_dirs_array(paths["valid_paths"])
|
|
16
|
+
dirs_root = create_dir_tree(dirs_array)
|
|
17
|
+
|
|
18
|
+
log(f"Below paths couldn't be found:\n{"\n".join(paths["empty_paths"])}\n", LogLevel.WARN)
|
|
19
|
+
for path in paths["empty_paths"]:
|
|
20
|
+
await sync_status.add_empty_path(path)
|
|
21
|
+
|
|
22
|
+
_copy_folders_create_operation = await traverse_and_create_folders_by_depth(
|
|
23
|
+
root=dirs_root,
|
|
24
|
+
verbose=verbose,
|
|
25
|
+
semaphore=dirs_task_semaphore
|
|
26
|
+
)
|
|
27
|
+
_copy_operation = await backup(
|
|
28
|
+
paths=paths["valid_paths"],
|
|
29
|
+
command_type=command_type,
|
|
30
|
+
rclone_args=cfg.rclone.args,
|
|
31
|
+
semaphore=task_semaphore,
|
|
32
|
+
verbose=verbose
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return backup_operation
|
|
@@ -7,8 +7,9 @@ class SyncStatus:
|
|
|
7
7
|
def __init__(self):
|
|
8
8
|
self.total_path_count: int = 0
|
|
9
9
|
self.operations: list[SyncStatusItem] = []
|
|
10
|
-
self.lock: asyncio.Lock = asyncio.Lock()
|
|
11
10
|
self.finished_path_count: int = 0
|
|
11
|
+
self.empty_paths: list[str] = []
|
|
12
|
+
self.lock: asyncio.Lock = asyncio.Lock()
|
|
12
13
|
|
|
13
14
|
async def add_operation(self, source: str, dest: str, path_type: str, status: BackupStatus, operation_type: RcloneOperationType):
|
|
14
15
|
random_id = str(uuid.uuid4())
|
|
@@ -59,3 +60,11 @@ class SyncStatus:
|
|
|
59
60
|
async def get_currently_finished(self):
|
|
60
61
|
async with self.lock:
|
|
61
62
|
return self.finished_path_count
|
|
63
|
+
|
|
64
|
+
async def add_empty_path(self, path: str):
|
|
65
|
+
async with self.lock:
|
|
66
|
+
self.empty_paths.append(path)
|
|
67
|
+
|
|
68
|
+
async def get_empty_paths(self):
|
|
69
|
+
async with self.lock:
|
|
70
|
+
return self.empty_paths
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
from .essentials import log
|
|
1
|
+
from .essentials import log, exit_if_no_rclone
|
|
2
2
|
from .path_manipulation import organize_paths, collapseuser
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import os
|
|
1
2
|
from easyclone.utypes.enums import LogLevel, BackupLog
|
|
2
3
|
|
|
3
4
|
def log(message: str, logtype: LogLevel | BackupLog) -> None:
|
|
@@ -22,3 +23,17 @@ def log(message: str, logtype: LogLevel | BackupLog) -> None:
|
|
|
22
23
|
full_msg = full_msg + ":"
|
|
23
24
|
|
|
24
25
|
print(f"{full_msg}\033[0m {message}")
|
|
26
|
+
|
|
27
|
+
def is_tool(name: str):
|
|
28
|
+
from shutil import which
|
|
29
|
+
return which(name) is not None
|
|
30
|
+
|
|
31
|
+
def exit_if_no_rclone():
|
|
32
|
+
if not is_tool("rclone"):
|
|
33
|
+
log("Rclone is not installed on your system, 'rclone' command should be in the $PATH.", LogLevel.ERROR)
|
|
34
|
+
exit(1)
|
|
35
|
+
|
|
36
|
+
def exit_if_currently_running():
|
|
37
|
+
if os.path.exists('/tmp/easyclone.sock'):
|
|
38
|
+
log("Easyclone is already running. Delete /tmp/easyclone.sock if you think this is a mistake.", LogLevel.WARN)
|
|
39
|
+
exit(1)
|
|
@@ -1,15 +1,20 @@
|
|
|
1
|
+
import json
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
import os
|
|
3
4
|
from easyclone.utypes.enums import PathType
|
|
4
|
-
from easyclone.utypes.models import PathItem
|
|
5
|
+
from easyclone.utypes.models import OrganizedPaths, PathItem
|
|
5
6
|
|
|
6
|
-
def organize_paths(paths: list[str], remote_name: str) ->
|
|
7
|
+
def organize_paths(paths: list[str], remote_name: str) -> OrganizedPaths:
|
|
7
8
|
from easyclone.config import cfg
|
|
8
9
|
source_dest_array: list[PathItem] = []
|
|
10
|
+
empty_paths: list[str] = []
|
|
9
11
|
root_dir = cfg.backup.root_dir
|
|
10
12
|
|
|
11
13
|
for path in paths:
|
|
12
|
-
p = Path(path
|
|
14
|
+
p = Path(os.path.expandvars(os.path.expanduser(path)))
|
|
15
|
+
|
|
16
|
+
if not os.path.exists(p):
|
|
17
|
+
empty_paths.append(path)
|
|
13
18
|
|
|
14
19
|
if p.is_dir():
|
|
15
20
|
source_dest_array.append({
|
|
@@ -24,8 +29,11 @@ def organize_paths(paths: list[str], remote_name: str) -> list[PathItem]:
|
|
|
24
29
|
"dest": f"{remote_name}:{root_dir}{dest_dir}",
|
|
25
30
|
"path_type": PathType.FILE.value
|
|
26
31
|
})
|
|
27
|
-
|
|
28
|
-
return
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
"valid_paths": source_dest_array,
|
|
35
|
+
"empty_paths": empty_paths
|
|
36
|
+
}
|
|
29
37
|
|
|
30
38
|
def collapseuser(path: str) -> str:
|
|
31
39
|
"""
|
|
@@ -0,0 +1,26 @@
|
|
|
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 = False
|
|
9
|
+
|
|
10
|
+
class RcloneConfigModel(BaseModel):
|
|
11
|
+
args: list[str] = [
|
|
12
|
+
"--update",
|
|
13
|
+
"--verbose",
|
|
14
|
+
"--transfers 30",
|
|
15
|
+
"--checkers 8",
|
|
16
|
+
"--contimeout 60s",
|
|
17
|
+
"--timeout 300s",
|
|
18
|
+
"--retries 3",
|
|
19
|
+
"--low-level-retries 10",
|
|
20
|
+
"--stats 1s"
|
|
21
|
+
]
|
|
22
|
+
concurrent_limit: int = 50
|
|
23
|
+
|
|
24
|
+
class ConfigModel(BaseModel):
|
|
25
|
+
backup: BackupConfigModel
|
|
26
|
+
rclone: RcloneConfigModel = RcloneConfigModel()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: easyclone
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Very convenient Rclone bulk backup wrapper
|
|
5
5
|
Project-URL: Repository, https://github.com/dybdeskarphet/easyclone
|
|
6
6
|
Project-URL: Documentation, https://github.com/dybdeskarphet/easyclone
|
|
@@ -19,15 +19,28 @@ Dynamic: license-file
|
|
|
19
19
|
|
|
20
20
|
You define what to back up, where to back it up, and EasyClone handles the syncs and copies — clean, fast, and reliable.
|
|
21
21
|
|
|
22
|
-
##
|
|
22
|
+
## Features
|
|
23
23
|
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
24
|
+
* Sync & Copy support per-path
|
|
25
|
+
* Backup multiple paths at once
|
|
26
|
+
* Human-friendly TOML config
|
|
27
|
+
* IPC-ready architecture for future GUI or monitoring tools
|
|
28
|
+
* Optional verbose logging
|
|
29
29
|
|
|
30
|
-
##
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
Install it with `pip` or `pipx`:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install easyclone
|
|
36
|
+
pipx install easyclone
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
The config file is at `~/.config/easyclone/config.toml`
|
|
42
|
+
|
|
43
|
+
## Requirements
|
|
31
44
|
|
|
32
45
|
* Python **3.13+**
|
|
33
46
|
* [`rclone`](https://rclone.org/) installed and accessible in your `$PATH`
|
|
@@ -35,7 +48,7 @@ You define what to back up, where to back it up, and EasyClone handles the syncs
|
|
|
35
48
|
* `toml>=0.10.2`
|
|
36
49
|
* `typer>=0.16.0`
|
|
37
50
|
|
|
38
|
-
##
|
|
51
|
+
## Example Usage
|
|
39
52
|
|
|
40
53
|
```bash
|
|
41
54
|
easyclone start-backup
|
|
@@ -47,16 +60,16 @@ It will:
|
|
|
47
60
|
* Copy the paths in `copy_paths`
|
|
48
61
|
* Use the `remote_name` and `root_dir` to target your cloud storage
|
|
49
62
|
|
|
50
|
-
##
|
|
63
|
+
## Contributing
|
|
51
64
|
|
|
52
65
|
PRs welcome. Bug reports even more welcome.
|
|
53
66
|
|
|
54
|
-
##
|
|
67
|
+
## FAQ
|
|
55
68
|
|
|
56
69
|
Why does it create the folders first?
|
|
57
70
|
> Because services like Google Drive support multiple folders with the same name in the same directory. So when you try to concurrently backup paths from the same directory, it will create the parent directory more than once, and we don't want that.
|
|
58
71
|
|
|
59
|
-
##
|
|
72
|
+
## License
|
|
60
73
|
|
|
61
74
|
GPLv3 — do whatever you want, just don't blame me if you sync your `/` folder to the cloud :)
|
|
62
75
|
|
|
@@ -1,48 +0,0 @@
|
|
|
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
|
-
)
|
|
@@ -1,16 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|