easyclone 0.3.0__tar.gz → 1.1.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.3.0/src/easyclone.egg-info → easyclone-1.1.0}/PKG-INFO +8 -1
- {easyclone-0.3.0 → easyclone-1.1.0}/README.md +2 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/pyproject.toml +11 -6
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/config.py +13 -8
- easyclone-1.1.0/src/easyclone/ipc/__init__.py +2 -0
- easyclone-1.1.0/src/easyclone/main.py +178 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/rclone/backup.py +42 -13
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/rclone/create_dirs.py +58 -19
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/rclone/operations.py +16 -8
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/shared/sync_status.py +22 -12
- easyclone-1.1.0/src/easyclone/utils/__init__.py +2 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/utils/essentials.py +15 -4
- easyclone-1.1.0/src/easyclone/utils/path.py +85 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/utypes/config.py +4 -1
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/utypes/enums.py +11 -0
- {easyclone-0.3.0 → easyclone-1.1.0/src/easyclone.egg-info}/PKG-INFO +8 -1
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone.egg-info/SOURCES.txt +1 -1
- easyclone-0.3.0/src/easyclone/ipc/__init__.py +0 -2
- easyclone-0.3.0/src/easyclone/main.py +0 -70
- easyclone-0.3.0/src/easyclone/utils/__init__.py +0 -2
- easyclone-0.3.0/src/easyclone/utils/path_manipulation.py +0 -45
- {easyclone-0.3.0 → easyclone-1.1.0}/LICENSE +0 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/setup.cfg +0 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/__main__.py +0 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/ipc/client.py +0 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/ipc/server.py +0 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/rclone/__init__.py +0 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/shared/__init__.py +0 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/utypes/__init__.py +0 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/utypes/models.py +0 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone.egg-info/dependency_links.txt +0 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone.egg-info/entry_points.txt +0 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone.egg-info/requires.txt +0 -0
- {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone.egg-info/top_level.txt +0 -0
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: easyclone
|
|
3
|
-
Version:
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Very convenient Rclone bulk backup wrapper
|
|
5
|
+
Author-email: Ahmet Arda Kavakcı <ahmetardakavakci@gmail.com>
|
|
6
|
+
Maintainer-email: Ahmet Arda Kavakcı <ahmetardakavakci@gmail.com>
|
|
7
|
+
License-Expression: GPL-3.0-or-later
|
|
5
8
|
Project-URL: Repository, https://github.com/dybdeskarphet/easyclone
|
|
6
9
|
Project-URL: Documentation, https://github.com/dybdeskarphet/easyclone
|
|
7
10
|
Project-URL: Issues, https://github.com/dybdeskarphet/easyclone/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/dybdeskarphet/easyclone/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: backup,sync,google,drive,easyclone
|
|
8
13
|
Requires-Python: >=3.13
|
|
9
14
|
Description-Content-Type: text/markdown
|
|
10
15
|
License-File: LICENSE
|
|
@@ -15,6 +20,8 @@ Dynamic: license-file
|
|
|
15
20
|
|
|
16
21
|
# ☁️ easyclone
|
|
17
22
|
|
|
23
|
+
[](https://pypi.org/project/easyclone/) [](https://pypi.org/project/easyclone/) [](https://pypi.org/project/easyclone/)
|
|
24
|
+
|
|
18
25
|
**easyclone** is a lightweight, configurable CLI tool that wraps `rclone` to behave more like Google's *Backup and Sync* app.
|
|
19
26
|
|
|
20
27
|
You define what to back up, where to back it up, and EasyClone handles the syncs and copies — clean, fast, and reliable.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# ☁️ easyclone
|
|
2
2
|
|
|
3
|
+
[](https://pypi.org/project/easyclone/) [](https://pypi.org/project/easyclone/) [](https://pypi.org/project/easyclone/)
|
|
4
|
+
|
|
3
5
|
**easyclone** is a lightweight, configurable CLI tool that wraps `rclone` to behave more like Google's *Backup and Sync* app.
|
|
4
6
|
|
|
5
7
|
You define what to back up, where to back it up, and EasyClone handles the syncs and copies — clean, fast, and reliable.
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "easyclone"
|
|
3
|
-
version = "
|
|
3
|
+
version = "1.1.0"
|
|
4
4
|
description = "Very convenient Rclone bulk backup wrapper"
|
|
5
|
+
keywords = ["backup", "sync", "google", "drive", "easyclone"]
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Ahmet Arda Kavakcı", email = "ahmetardakavakci@gmail.com" },
|
|
8
|
+
]
|
|
9
|
+
maintainers = [
|
|
10
|
+
{ name = "Ahmet Arda Kavakcı", email = "ahmetardakavakci@gmail.com" },
|
|
11
|
+
]
|
|
5
12
|
readme = "README.md"
|
|
13
|
+
license = "GPL-3.0-or-later"
|
|
6
14
|
requires-python = ">=3.13"
|
|
7
|
-
dependencies = [
|
|
8
|
-
"pydantic>=2.11.5",
|
|
9
|
-
"toml>=0.10.2",
|
|
10
|
-
"typer>=0.16.0",
|
|
11
|
-
]
|
|
15
|
+
dependencies = ["pydantic>=2.11.5", "toml>=0.10.2", "typer>=0.16.0"]
|
|
12
16
|
|
|
13
17
|
[project.scripts]
|
|
14
18
|
easyclone = "easyclone.main:app"
|
|
@@ -17,6 +21,7 @@ easyclone = "easyclone.main:app"
|
|
|
17
21
|
Repository = "https://github.com/dybdeskarphet/easyclone"
|
|
18
22
|
Documentation = "https://github.com/dybdeskarphet/easyclone"
|
|
19
23
|
Issues = "https://github.com/dybdeskarphet/easyclone/issues"
|
|
24
|
+
Changelog = "https://github.com/dybdeskarphet/easyclone/blob/main/CHANGELOG.md"
|
|
20
25
|
|
|
21
26
|
[tool.basedpyright]
|
|
22
27
|
reportAny = false
|
|
@@ -7,10 +7,11 @@ from easyclone.utypes.enums import LogLevel
|
|
|
7
7
|
from easyclone.utypes.config import BackupConfigModel, ConfigModel
|
|
8
8
|
import toml
|
|
9
9
|
|
|
10
|
+
|
|
10
11
|
class Config:
|
|
11
12
|
_instance: Config | None = None
|
|
12
13
|
_lock: Lock = Lock()
|
|
13
|
-
_path: Path = Path.home() /
|
|
14
|
+
_path: Path = Path.home() / ".config" / "easyclone" / "config.toml"
|
|
14
15
|
_config: ConfigModel | None = None
|
|
15
16
|
|
|
16
17
|
def __new__(cls):
|
|
@@ -34,12 +35,12 @@ class Config:
|
|
|
34
35
|
root_dir="Backups/PC",
|
|
35
36
|
)
|
|
36
37
|
)
|
|
37
|
-
|
|
38
|
+
|
|
38
39
|
if xdg_config_home:
|
|
39
|
-
config_dir = Path(xdg_config_home) /
|
|
40
|
-
else:
|
|
41
|
-
config_dir = Path.home() /
|
|
42
|
-
|
|
40
|
+
config_dir = Path(xdg_config_home) / "easyclone"
|
|
41
|
+
else:
|
|
42
|
+
config_dir = Path.home() / ".config" / "easyclone"
|
|
43
|
+
|
|
43
44
|
config_file = config_dir / "config.toml"
|
|
44
45
|
|
|
45
46
|
if not config_dir.exists():
|
|
@@ -49,7 +50,7 @@ class Config:
|
|
|
49
50
|
config_file.touch()
|
|
50
51
|
with open(config_file, "w") as f:
|
|
51
52
|
_ = toml.dump(empty_config.model_dump(), f)
|
|
52
|
-
|
|
53
|
+
|
|
53
54
|
self._path = config_file
|
|
54
55
|
|
|
55
56
|
def _config_normalize(self, config: ConfigModel):
|
|
@@ -58,6 +59,7 @@ class Config:
|
|
|
58
59
|
|
|
59
60
|
def _load_config(self):
|
|
60
61
|
from easyclone.utils.essentials import log
|
|
62
|
+
|
|
61
63
|
self._get_config_path()
|
|
62
64
|
|
|
63
65
|
try:
|
|
@@ -67,7 +69,9 @@ class Config:
|
|
|
67
69
|
log(f"Config file not found at {self._path}.", LogLevel.ERROR)
|
|
68
70
|
exit(1)
|
|
69
71
|
except Exception as e:
|
|
70
|
-
log(
|
|
72
|
+
log(
|
|
73
|
+
f"Error while opening the config file {self._path}: {e}", LogLevel.ERROR
|
|
74
|
+
)
|
|
71
75
|
exit(1)
|
|
72
76
|
|
|
73
77
|
try:
|
|
@@ -84,4 +88,5 @@ class Config:
|
|
|
84
88
|
raise RuntimeError("Config is not loaded yet")
|
|
85
89
|
return self._config
|
|
86
90
|
|
|
91
|
+
|
|
87
92
|
cfg = Config().config()
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
from easyclone.config import cfg
|
|
5
|
+
from easyclone.ipc.client import listen_ipc
|
|
6
|
+
from easyclone.ipc.server import start_status_server
|
|
7
|
+
from easyclone.rclone.operations import make_backup_operation
|
|
8
|
+
from easyclone.utils.path import collapseuser, find_missing
|
|
9
|
+
import typer
|
|
10
|
+
import json
|
|
11
|
+
from easyclone.shared import sync_status
|
|
12
|
+
from easyclone.utils.essentials import exit_if_currently_running, exit_if_no_rclone
|
|
13
|
+
from easyclone.utypes.enums import CommandType, FindMissingOptions
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
help="Very convenient Rclone bulk backup wrapper",
|
|
17
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def ipc():
|
|
22
|
+
server = await start_status_server()
|
|
23
|
+
async with server:
|
|
24
|
+
await server.serve_forever()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command(help="Starts the backup process using the details in the config file.")
|
|
28
|
+
def start_backup(
|
|
29
|
+
verbose: Annotated[
|
|
30
|
+
bool,
|
|
31
|
+
typer.Option(
|
|
32
|
+
"--verbose", "-v", help="Enables the rclone logging (overrides config)."
|
|
33
|
+
),
|
|
34
|
+
] = False,
|
|
35
|
+
):
|
|
36
|
+
|
|
37
|
+
import atexit
|
|
38
|
+
from os import path, remove
|
|
39
|
+
import signal
|
|
40
|
+
from types import FrameType
|
|
41
|
+
from easyclone.ipc.client import SOCKET_PATH
|
|
42
|
+
from easyclone.utils.essentials import log
|
|
43
|
+
from easyclone.utypes.enums import LogLevel
|
|
44
|
+
|
|
45
|
+
def cleanup():
|
|
46
|
+
if path.exists(SOCKET_PATH):
|
|
47
|
+
log("Cleaning up socket file...", LogLevel.WARN)
|
|
48
|
+
remove(SOCKET_PATH)
|
|
49
|
+
|
|
50
|
+
_ = atexit.register(cleanup)
|
|
51
|
+
|
|
52
|
+
def handle_signal(_signum: int, _frame: FrameType | None):
|
|
53
|
+
cleanup()
|
|
54
|
+
sys.exit(0)
|
|
55
|
+
|
|
56
|
+
_ = signal.signal(signal.SIGINT, handle_signal)
|
|
57
|
+
_ = signal.signal(signal.SIGTERM, handle_signal)
|
|
58
|
+
|
|
59
|
+
async def start():
|
|
60
|
+
|
|
61
|
+
exit_if_no_rclone()
|
|
62
|
+
exit_if_currently_running()
|
|
63
|
+
await sync_status.set_total_path_count(
|
|
64
|
+
len(cfg.backup.sync_paths) + len(cfg.backup.copy_paths)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
_ipc_task = asyncio.create_task(ipc())
|
|
68
|
+
|
|
69
|
+
verbose_state = verbose or cfg.backup.verbose_log
|
|
70
|
+
|
|
71
|
+
await make_backup_operation(
|
|
72
|
+
CommandType.COPY, cfg.backup.copy_paths, verbose_state
|
|
73
|
+
)()
|
|
74
|
+
await make_backup_operation(
|
|
75
|
+
CommandType.SYNC, cfg.backup.sync_paths, verbose_state
|
|
76
|
+
)()
|
|
77
|
+
|
|
78
|
+
asyncio.run(start())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command(
|
|
82
|
+
help="Lists the directories and files that are not getting synced or backed up"
|
|
83
|
+
)
|
|
84
|
+
def get_missing(
|
|
85
|
+
recursive: Annotated[
|
|
86
|
+
bool, typer.Option("--recursive", "-r", help="Recurse into directories")
|
|
87
|
+
] = False,
|
|
88
|
+
list: Annotated[
|
|
89
|
+
FindMissingOptions,
|
|
90
|
+
typer.Option("--list", "-l", help="Select the list you want to filter from"),
|
|
91
|
+
] = FindMissingOptions.all,
|
|
92
|
+
want_json: Annotated[
|
|
93
|
+
bool, typer.Option("--json", "-j", help="Show the output in JSON format")
|
|
94
|
+
] = False,
|
|
95
|
+
):
|
|
96
|
+
missing = find_missing(recursive=recursive, list_type=list)
|
|
97
|
+
|
|
98
|
+
if want_json:
|
|
99
|
+
print(json.dumps(missing, indent=2))
|
|
100
|
+
else:
|
|
101
|
+
[print(collapseuser(p)) for p in missing]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command(help="Gets status information about the backup process.")
|
|
105
|
+
def get_status(
|
|
106
|
+
all_args: Annotated[
|
|
107
|
+
bool,
|
|
108
|
+
typer.Option("--all", "-a", help="Show all the backup status information."),
|
|
109
|
+
] = False,
|
|
110
|
+
show_total: Annotated[
|
|
111
|
+
bool, typer.Option("--show-total", "-t", help="Show the total amount of paths.")
|
|
112
|
+
] = False,
|
|
113
|
+
show_current: Annotated[
|
|
114
|
+
bool,
|
|
115
|
+
typer.Option(
|
|
116
|
+
"--show-current", "-c", help="Show the total amount of pending paths."
|
|
117
|
+
),
|
|
118
|
+
] = False,
|
|
119
|
+
show_operations: Annotated[
|
|
120
|
+
bool,
|
|
121
|
+
typer.Option(
|
|
122
|
+
"--show-operations", "-o", help="Show currently running operations."
|
|
123
|
+
),
|
|
124
|
+
] = False,
|
|
125
|
+
show_finished_path_count: Annotated[
|
|
126
|
+
bool,
|
|
127
|
+
typer.Option(
|
|
128
|
+
"--show-finished", "-f", help="Show the total amount of finished paths."
|
|
129
|
+
),
|
|
130
|
+
] = False,
|
|
131
|
+
show_operation_count: Annotated[
|
|
132
|
+
bool,
|
|
133
|
+
typer.Option(
|
|
134
|
+
"--get-operation-count",
|
|
135
|
+
"-O",
|
|
136
|
+
help="Show the total amount of running operations.",
|
|
137
|
+
),
|
|
138
|
+
] = False,
|
|
139
|
+
show_empty_paths: Annotated[
|
|
140
|
+
bool, typer.Option("--show-empty-paths", "-e", help="Show all the empty paths.")
|
|
141
|
+
] = False,
|
|
142
|
+
):
|
|
143
|
+
data: Any = asyncio.run(listen_ipc())
|
|
144
|
+
|
|
145
|
+
args = [
|
|
146
|
+
show_total,
|
|
147
|
+
show_current,
|
|
148
|
+
show_operations,
|
|
149
|
+
show_operation_count,
|
|
150
|
+
show_finished_path_count,
|
|
151
|
+
show_empty_paths,
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
if not any(args) or all_args:
|
|
155
|
+
print(json.dumps(data, indent=2))
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if show_total:
|
|
159
|
+
print(data["total_path_count"])
|
|
160
|
+
|
|
161
|
+
if show_current:
|
|
162
|
+
print(data["operation_count"])
|
|
163
|
+
|
|
164
|
+
if show_operations:
|
|
165
|
+
print(json.dumps(data["operations"], indent=2))
|
|
166
|
+
|
|
167
|
+
if show_operation_count:
|
|
168
|
+
print(data["operation_count"])
|
|
169
|
+
|
|
170
|
+
if show_finished_path_count:
|
|
171
|
+
print(data["finished_path_count"])
|
|
172
|
+
|
|
173
|
+
if show_empty_paths:
|
|
174
|
+
print(data["empty_paths"])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
app()
|
|
@@ -1,18 +1,40 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import shlex
|
|
3
3
|
from easyclone.shared import sync_status
|
|
4
|
-
from easyclone.utypes.enums import
|
|
4
|
+
from easyclone.utypes.enums import (
|
|
5
|
+
BackupLog,
|
|
6
|
+
BackupStatus,
|
|
7
|
+
CommandType,
|
|
8
|
+
LogLevel,
|
|
9
|
+
RcloneOperationType,
|
|
10
|
+
)
|
|
5
11
|
from easyclone.utypes.models import PathItem
|
|
6
12
|
from easyclone.utils.essentials import log
|
|
7
|
-
from easyclone.utils.
|
|
13
|
+
from easyclone.utils.path import collapseuser
|
|
8
14
|
|
|
9
|
-
|
|
15
|
+
|
|
16
|
+
async def backup_command(
|
|
17
|
+
rclone_command: list[str],
|
|
18
|
+
source: str,
|
|
19
|
+
dest: str,
|
|
20
|
+
path_type: str,
|
|
21
|
+
command_type: CommandType,
|
|
22
|
+
verbose: bool = False,
|
|
23
|
+
):
|
|
10
24
|
cmd = rclone_command + [source, dest]
|
|
11
25
|
operation_name = command_type.value.capitalize() + "ing"
|
|
12
26
|
|
|
13
27
|
log(f"{operation_name} {collapseuser(source)}", BackupLog.WAIT)
|
|
14
|
-
process = await asyncio.create_subprocess_exec(
|
|
15
|
-
|
|
28
|
+
process = await asyncio.create_subprocess_exec(
|
|
29
|
+
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
30
|
+
)
|
|
31
|
+
process_id = await sync_status.add_operation(
|
|
32
|
+
source=source,
|
|
33
|
+
dest=dest,
|
|
34
|
+
path_type=path_type,
|
|
35
|
+
status=BackupStatus.IN_PROGRESS,
|
|
36
|
+
operation_type=RcloneOperationType.BACKUP,
|
|
37
|
+
)
|
|
16
38
|
|
|
17
39
|
stdout, stderr = await process.communicate()
|
|
18
40
|
stdout = stdout.decode(errors="ignore").strip()
|
|
@@ -25,18 +47,28 @@ async def backup_command(rclone_command: list[str], source: str, dest: str, path
|
|
|
25
47
|
case 1:
|
|
26
48
|
log(f"Back up operation failed: {collapsed_source}", BackupLog.ERR)
|
|
27
49
|
case _:
|
|
28
|
-
log(
|
|
50
|
+
log(
|
|
51
|
+
f"Back up operation failed with {process.returncode} exit code: {collapsed_source}",
|
|
52
|
+
BackupLog.ERR,
|
|
53
|
+
)
|
|
29
54
|
|
|
30
55
|
await sync_status.delete_operation(process_id)
|
|
31
56
|
await sync_status.add_currently_finished()
|
|
32
57
|
|
|
33
58
|
if verbose:
|
|
34
|
-
if stderr:
|
|
59
|
+
if stderr:
|
|
35
60
|
log(f"{stderr}", LogLevel.WARN)
|
|
36
61
|
if stdout:
|
|
37
62
|
log(f"{stdout}", LogLevel.LOG)
|
|
38
63
|
|
|
39
|
-
|
|
64
|
+
|
|
65
|
+
async def backup(
|
|
66
|
+
paths: list[PathItem],
|
|
67
|
+
command_type: CommandType,
|
|
68
|
+
rclone_args: list[str],
|
|
69
|
+
semaphore: asyncio.Semaphore,
|
|
70
|
+
verbose: bool = False,
|
|
71
|
+
):
|
|
40
72
|
cmd = ["rclone", command_type.value]
|
|
41
73
|
|
|
42
74
|
for arg in rclone_args:
|
|
@@ -48,11 +80,8 @@ async def backup(paths: list[PathItem], command_type: CommandType, rclone_args:
|
|
|
48
80
|
async with semaphore:
|
|
49
81
|
await backup_command(cmd, source, dest, path_type, command_type, verbose)
|
|
50
82
|
|
|
51
|
-
|
|
52
83
|
tasks = [
|
|
53
|
-
backup_task(path["source"], path["dest"], path["path_type"])
|
|
54
|
-
for path in paths
|
|
84
|
+
backup_task(path["source"], path["dest"], path["path_type"]) for path in paths
|
|
55
85
|
]
|
|
56
|
-
|
|
57
|
-
return await asyncio.gather(*tasks)
|
|
58
86
|
|
|
87
|
+
return await asyncio.gather(*tasks)
|
|
@@ -3,12 +3,19 @@ import asyncio
|
|
|
3
3
|
from easyclone.shared import sync_status
|
|
4
4
|
from easyclone.utils.essentials import log
|
|
5
5
|
from easyclone.utypes.models import PathItem
|
|
6
|
-
from easyclone.utypes.enums import
|
|
6
|
+
from easyclone.utypes.enums import (
|
|
7
|
+
BackupLog,
|
|
8
|
+
BackupStatus,
|
|
9
|
+
LogLevel,
|
|
10
|
+
PathType,
|
|
11
|
+
RcloneOperationType,
|
|
12
|
+
)
|
|
7
13
|
import os
|
|
8
14
|
from pathlib import Path
|
|
9
15
|
from collections import deque
|
|
10
16
|
from easyclone.config import cfg
|
|
11
17
|
|
|
18
|
+
|
|
12
19
|
class DirNode:
|
|
13
20
|
def __init__(self, name: str, details: PathItem):
|
|
14
21
|
self.name: str = name
|
|
@@ -29,12 +36,13 @@ class DirNode:
|
|
|
29
36
|
self.children.append(new_child)
|
|
30
37
|
return new_child
|
|
31
38
|
|
|
32
|
-
def print_tree(self, level: int=0):
|
|
39
|
+
def print_tree(self, level: int = 0):
|
|
33
40
|
indent = " " * level
|
|
34
41
|
print(f"{indent}- {self.name} ({self.details.get('dest')})")
|
|
35
42
|
for child in sorted(self.children, key=lambda c: c.name):
|
|
36
43
|
child.print_tree(level + 1)
|
|
37
44
|
|
|
45
|
+
|
|
38
46
|
def create_dirs_array(path_list: list[PathItem]):
|
|
39
47
|
only_dirs: list[PathItem] = []
|
|
40
48
|
|
|
@@ -47,7 +55,7 @@ def create_dirs_array(path_list: list[PathItem]):
|
|
|
47
55
|
new_dir_path = {
|
|
48
56
|
"source": os.path.dirname(path.get("source")),
|
|
49
57
|
"dest": path.get("dest"),
|
|
50
|
-
"path_type": PathType.DIR.value
|
|
58
|
+
"path_type": PathType.DIR.value,
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
if new_dir_path not in only_dirs:
|
|
@@ -55,13 +63,21 @@ def create_dirs_array(path_list: list[PathItem]):
|
|
|
55
63
|
|
|
56
64
|
return only_dirs
|
|
57
65
|
|
|
66
|
+
|
|
58
67
|
def create_dir_tree(path_list: list[PathItem]):
|
|
59
|
-
root = DirNode(
|
|
68
|
+
root = DirNode(
|
|
69
|
+
"Root",
|
|
70
|
+
{
|
|
71
|
+
"source": "/",
|
|
72
|
+
"dest": f"{cfg.backup.remote_name}:{cfg.backup.root_dir}",
|
|
73
|
+
"path_type": "dir",
|
|
74
|
+
},
|
|
75
|
+
)
|
|
60
76
|
|
|
61
77
|
for path_item in path_list:
|
|
62
78
|
source_str = path_item.get("source")
|
|
63
79
|
dest_str = path_item.get("dest")
|
|
64
|
-
|
|
80
|
+
|
|
65
81
|
source_parts = Path(source_str).parts
|
|
66
82
|
dest_main, _, dest_path = dest_str.partition(":")
|
|
67
83
|
|
|
@@ -69,33 +85,36 @@ def create_dir_tree(path_list: list[PathItem]):
|
|
|
69
85
|
dest_parts = Path(dest_path).parts
|
|
70
86
|
else:
|
|
71
87
|
dest_parts = []
|
|
72
|
-
|
|
88
|
+
|
|
73
89
|
current = root
|
|
74
|
-
|
|
90
|
+
|
|
75
91
|
for i in range(1, len(source_parts)):
|
|
76
|
-
source_sub_path = str(Path(*source_parts[:i+1]))
|
|
92
|
+
source_sub_path = str(Path(*source_parts[: i + 1]))
|
|
77
93
|
|
|
78
94
|
if dest_path:
|
|
79
95
|
dest_sub_path = f"{dest_main}:{Path(*dest_parts[:i+2])}"
|
|
80
96
|
else:
|
|
81
97
|
dest_sub_path = ""
|
|
82
|
-
|
|
98
|
+
|
|
83
99
|
node_details: PathItem = {
|
|
84
100
|
"source": source_sub_path,
|
|
85
101
|
"dest": dest_sub_path,
|
|
86
|
-
"path_type": PathType.DIR.value
|
|
102
|
+
"path_type": PathType.DIR.value,
|
|
87
103
|
}
|
|
88
|
-
|
|
104
|
+
|
|
89
105
|
part_name = source_parts[i]
|
|
90
106
|
current = current.add_child(part_name, node_details)
|
|
91
107
|
|
|
92
108
|
return root
|
|
93
109
|
|
|
110
|
+
|
|
94
111
|
async def create_folder_command(source: str, dest: str, verbose: bool):
|
|
95
112
|
lsd_cmd = ["rclone", "lsd", dest]
|
|
96
113
|
log(f"Checking if directory exist at {dest}", BackupLog.WAIT)
|
|
97
114
|
|
|
98
|
-
process = await asyncio.create_subprocess_exec(
|
|
115
|
+
process = await asyncio.create_subprocess_exec(
|
|
116
|
+
*lsd_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
117
|
+
)
|
|
99
118
|
stdout, stderr = await process.communicate()
|
|
100
119
|
|
|
101
120
|
if process.returncode == 0:
|
|
@@ -105,8 +124,16 @@ async def create_folder_command(source: str, dest: str, verbose: bool):
|
|
|
105
124
|
mkdir_cmd = ["rclone", "mkdir", dest]
|
|
106
125
|
|
|
107
126
|
log(f"Creating a directory at {dest}", BackupLog.WAIT)
|
|
108
|
-
process = await asyncio.create_subprocess_exec(
|
|
109
|
-
|
|
127
|
+
process = await asyncio.create_subprocess_exec(
|
|
128
|
+
*mkdir_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
|
129
|
+
)
|
|
130
|
+
process_id = await sync_status.add_operation(
|
|
131
|
+
source=source,
|
|
132
|
+
dest=dest,
|
|
133
|
+
path_type=PathType.DIR.value,
|
|
134
|
+
status=BackupStatus.IN_PROGRESS,
|
|
135
|
+
operation_type=RcloneOperationType.MKDIR,
|
|
136
|
+
)
|
|
110
137
|
|
|
111
138
|
stdout, stderr = await process.communicate()
|
|
112
139
|
stdout = stdout.decode(errors="ignore").strip()
|
|
@@ -118,26 +145,38 @@ async def create_folder_command(source: str, dest: str, verbose: bool):
|
|
|
118
145
|
case 1:
|
|
119
146
|
log(f"Couldn't create the directory: {dest}", BackupLog.ERR)
|
|
120
147
|
case _:
|
|
121
|
-
log(
|
|
148
|
+
log(
|
|
149
|
+
f"Operation failed with {process.returncode} exit code: {dest}",
|
|
150
|
+
BackupLog.ERR,
|
|
151
|
+
)
|
|
122
152
|
|
|
123
153
|
await sync_status.delete_operation(process_id)
|
|
124
154
|
|
|
125
155
|
if verbose:
|
|
126
|
-
if stderr:
|
|
156
|
+
if stderr:
|
|
127
157
|
log(f"{stderr}", LogLevel.WARN)
|
|
128
158
|
if stdout:
|
|
129
159
|
log(f"{stdout}", LogLevel.LOG)
|
|
130
160
|
|
|
131
|
-
|
|
161
|
+
|
|
162
|
+
async def create_folders_on_remote(
|
|
163
|
+
nodes: list[DirNode], semaphore: asyncio.Semaphore, verbose: bool
|
|
164
|
+
):
|
|
132
165
|
async def mkdir_task(source: str, dest: str):
|
|
133
166
|
async with semaphore:
|
|
134
167
|
await create_folder_command(source, dest, verbose)
|
|
135
168
|
|
|
136
|
-
tasks = [
|
|
169
|
+
tasks = [
|
|
170
|
+
mkdir_task(node.details.get("source"), node.details.get("dest"))
|
|
171
|
+
for node in nodes
|
|
172
|
+
]
|
|
137
173
|
|
|
138
174
|
_ = await asyncio.gather(*tasks)
|
|
139
175
|
|
|
140
|
-
|
|
176
|
+
|
|
177
|
+
async def traverse_and_create_folders_by_depth(
|
|
178
|
+
root: DirNode, verbose: bool, semaphore: asyncio.Semaphore
|
|
179
|
+
):
|
|
141
180
|
queue = deque([(root, 0)])
|
|
142
181
|
current_depth = 0
|
|
143
182
|
current_level_nodes: list[DirNode] = []
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
from easyclone.config import cfg
|
|
3
3
|
from easyclone.rclone.backup import backup
|
|
4
|
-
from easyclone.rclone.create_dirs import
|
|
4
|
+
from easyclone.rclone.create_dirs import (
|
|
5
|
+
create_dir_tree,
|
|
6
|
+
create_dirs_array,
|
|
7
|
+
traverse_and_create_folders_by_depth,
|
|
8
|
+
)
|
|
5
9
|
from easyclone.shared import sync_status
|
|
6
10
|
from easyclone.utils.essentials import log
|
|
7
|
-
from easyclone.utils.
|
|
11
|
+
from easyclone.utils.path import organize_paths
|
|
8
12
|
from easyclone.utypes.enums import CommandType, LogLevel
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
|
|
15
|
+
def make_backup_operation(
|
|
16
|
+
command_type: CommandType, paths_config: list[str], verbose: bool
|
|
17
|
+
):
|
|
11
18
|
async def backup_operation():
|
|
12
19
|
paths = organize_paths(paths_config, cfg.backup.remote_name)
|
|
13
20
|
task_semaphore = asyncio.Semaphore(cfg.rclone.concurrent_limit)
|
|
@@ -15,21 +22,22 @@ def make_backup_operation(command_type: CommandType, paths_config: list[str], ve
|
|
|
15
22
|
dirs_array = create_dirs_array(paths["valid_paths"])
|
|
16
23
|
dirs_root = create_dir_tree(dirs_array)
|
|
17
24
|
|
|
18
|
-
log(
|
|
25
|
+
log(
|
|
26
|
+
f"Below paths couldn't be found:\n{"\n".join(paths["empty_paths"])}\n",
|
|
27
|
+
LogLevel.WARN,
|
|
28
|
+
)
|
|
19
29
|
for path in paths["empty_paths"]:
|
|
20
30
|
await sync_status.add_empty_path(path)
|
|
21
31
|
|
|
22
32
|
_copy_folders_create_operation = await traverse_and_create_folders_by_depth(
|
|
23
|
-
root=dirs_root,
|
|
24
|
-
verbose=verbose,
|
|
25
|
-
semaphore=dirs_task_semaphore
|
|
33
|
+
root=dirs_root, verbose=verbose, semaphore=dirs_task_semaphore
|
|
26
34
|
)
|
|
27
35
|
_copy_operation = await backup(
|
|
28
36
|
paths=paths["valid_paths"],
|
|
29
37
|
command_type=command_type,
|
|
30
38
|
rclone_args=cfg.rclone.args,
|
|
31
39
|
semaphore=task_semaphore,
|
|
32
|
-
verbose=verbose
|
|
40
|
+
verbose=verbose,
|
|
33
41
|
)
|
|
34
42
|
|
|
35
43
|
return backup_operation
|
|
@@ -3,26 +3,36 @@ from easyclone.utypes.enums import BackupStatus, RcloneOperationType
|
|
|
3
3
|
from easyclone.utypes.models import SyncStatusItem
|
|
4
4
|
import uuid
|
|
5
5
|
|
|
6
|
+
|
|
6
7
|
class SyncStatus:
|
|
7
8
|
def __init__(self):
|
|
8
9
|
self.total_path_count: int = 0
|
|
9
|
-
self.operations: list[SyncStatusItem]
|
|
10
|
+
self.operations: list[SyncStatusItem] = []
|
|
10
11
|
self.finished_path_count: int = 0
|
|
11
12
|
self.empty_paths: list[str] = []
|
|
12
13
|
self.lock: asyncio.Lock = asyncio.Lock()
|
|
13
14
|
|
|
14
|
-
async def add_operation(
|
|
15
|
+
async def add_operation(
|
|
16
|
+
self,
|
|
17
|
+
source: str,
|
|
18
|
+
dest: str,
|
|
19
|
+
path_type: str,
|
|
20
|
+
status: BackupStatus,
|
|
21
|
+
operation_type: RcloneOperationType,
|
|
22
|
+
):
|
|
15
23
|
random_id = str(uuid.uuid4())
|
|
16
24
|
|
|
17
25
|
async with self.lock:
|
|
18
|
-
self.operations.append(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
self.operations.append(
|
|
27
|
+
{
|
|
28
|
+
"id": random_id,
|
|
29
|
+
"source": source,
|
|
30
|
+
"dest": dest,
|
|
31
|
+
"status": status.value,
|
|
32
|
+
"path_type": path_type,
|
|
33
|
+
"operation_type": operation_type.value,
|
|
34
|
+
}
|
|
35
|
+
)
|
|
26
36
|
|
|
27
37
|
return random_id
|
|
28
38
|
|
|
@@ -31,12 +41,12 @@ class SyncStatus:
|
|
|
31
41
|
for index, item in enumerate(self.operations):
|
|
32
42
|
if item.get("id") == target_id:
|
|
33
43
|
del self.operations[index]
|
|
34
|
-
break
|
|
44
|
+
break
|
|
35
45
|
|
|
36
46
|
async def get_operations(self):
|
|
37
47
|
async with self.lock:
|
|
38
48
|
return self.operations
|
|
39
|
-
|
|
49
|
+
|
|
40
50
|
async def get_operation_count(self):
|
|
41
51
|
async with self.lock:
|
|
42
52
|
return len(self.operations)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
from easyclone.utypes.enums import LogLevel, BackupLog
|
|
3
3
|
|
|
4
|
+
|
|
4
5
|
def log(message: str, logtype: LogLevel | BackupLog) -> None:
|
|
5
6
|
color = "\033[32;1m"
|
|
6
7
|
|
|
@@ -19,21 +20,31 @@ def log(message: str, logtype: LogLevel | BackupLog) -> None:
|
|
|
19
20
|
color = "\033[33;1m"
|
|
20
21
|
|
|
21
22
|
full_msg = f"{color}{logtype.value}"
|
|
22
|
-
if
|
|
23
|
+
if isinstance(logtype, LogLevel):
|
|
23
24
|
full_msg = full_msg + ":"
|
|
24
25
|
|
|
25
26
|
print(f"{full_msg}\033[0m {message}")
|
|
26
27
|
|
|
28
|
+
|
|
27
29
|
def is_tool(name: str):
|
|
28
30
|
from shutil import which
|
|
31
|
+
|
|
29
32
|
return which(name) is not None
|
|
30
33
|
|
|
34
|
+
|
|
31
35
|
def exit_if_no_rclone():
|
|
32
36
|
if not is_tool("rclone"):
|
|
33
|
-
log(
|
|
37
|
+
log(
|
|
38
|
+
"Rclone is not installed on your system, 'rclone' command should be in the $PATH.",
|
|
39
|
+
LogLevel.ERROR,
|
|
40
|
+
)
|
|
34
41
|
exit(1)
|
|
35
42
|
|
|
43
|
+
|
|
36
44
|
def exit_if_currently_running():
|
|
37
|
-
if os.path.exists(
|
|
38
|
-
log(
|
|
45
|
+
if os.path.exists("/tmp/easyclone.sock"):
|
|
46
|
+
log(
|
|
47
|
+
"Easyclone is already running. Delete /tmp/easyclone.sock if you think this is a mistake.",
|
|
48
|
+
LogLevel.WARN,
|
|
49
|
+
)
|
|
39
50
|
exit(1)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from ntpath import expanduser
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import os
|
|
5
|
+
from easyclone.utypes.enums import FindMissingOptions, PathType
|
|
6
|
+
from easyclone.utypes.models import OrganizedPaths, PathItem
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def organize_paths(paths: list[str], remote_name: str) -> OrganizedPaths:
|
|
10
|
+
from easyclone.config import cfg
|
|
11
|
+
|
|
12
|
+
source_dest_array: list[PathItem] = []
|
|
13
|
+
empty_paths: list[str] = []
|
|
14
|
+
root_dir = cfg.backup.root_dir
|
|
15
|
+
|
|
16
|
+
for path in paths:
|
|
17
|
+
p = Path(os.path.expandvars(os.path.expanduser(path)))
|
|
18
|
+
|
|
19
|
+
if not os.path.exists(p):
|
|
20
|
+
empty_paths.append(path)
|
|
21
|
+
|
|
22
|
+
if p.is_dir():
|
|
23
|
+
source_dest_array.append(
|
|
24
|
+
{
|
|
25
|
+
"source": f"{p}",
|
|
26
|
+
"dest": f"{remote_name}:{root_dir}{p}",
|
|
27
|
+
"path_type": PathType.DIR.value,
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
elif p.is_file():
|
|
31
|
+
dest_dir = p.parent
|
|
32
|
+
source_dest_array.append(
|
|
33
|
+
{
|
|
34
|
+
"source": f"{p}",
|
|
35
|
+
"dest": f"{remote_name}:{root_dir}{dest_dir}",
|
|
36
|
+
"path_type": PathType.FILE.value,
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return {"valid_paths": source_dest_array, "empty_paths": empty_paths}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def collapseuser(path: str) -> str:
|
|
44
|
+
"""
|
|
45
|
+
Opposite of path.expanduser()
|
|
46
|
+
"""
|
|
47
|
+
home = os.path.expanduser("~")
|
|
48
|
+
if path.startswith(home):
|
|
49
|
+
return path.replace(home, "~", 1)
|
|
50
|
+
return path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def find_missing(
|
|
54
|
+
recursive=False, list_type: FindMissingOptions = FindMissingOptions.all
|
|
55
|
+
):
|
|
56
|
+
from easyclone.config import cfg
|
|
57
|
+
|
|
58
|
+
filter_list: list[str] = []
|
|
59
|
+
|
|
60
|
+
if list_type is FindMissingOptions.all:
|
|
61
|
+
filter_list += cfg.backup.copy_paths + cfg.backup.sync_paths
|
|
62
|
+
elif list_type is FindMissingOptions.copy:
|
|
63
|
+
filter_list += cfg.backup.copy_paths
|
|
64
|
+
elif list_type is FindMissingOptions.sync:
|
|
65
|
+
filter_list += cfg.backup.sync_paths
|
|
66
|
+
|
|
67
|
+
filter_paths = [
|
|
68
|
+
Path(os.path.expandvars(os.path.expanduser(p))).absolute() for p in filter_list
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
cwd_path = Path.cwd()
|
|
72
|
+
cwd_func = cwd_path.rglob("*") if recursive else cwd_path.iterdir()
|
|
73
|
+
|
|
74
|
+
if recursive:
|
|
75
|
+
missing = [
|
|
76
|
+
str(item)
|
|
77
|
+
for item in cwd_func
|
|
78
|
+
if not any(item.is_relative_to(fp) for fp in filter_paths)
|
|
79
|
+
]
|
|
80
|
+
else:
|
|
81
|
+
cwd_content = [str(item) for item in cwd_func]
|
|
82
|
+
filter_content = [str(fp) for fp in filter_paths]
|
|
83
|
+
missing = list(set(cwd_content) - set(filter_content))
|
|
84
|
+
|
|
85
|
+
return missing
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from pydantic import BaseModel
|
|
2
2
|
|
|
3
|
+
|
|
3
4
|
class BackupConfigModel(BaseModel):
|
|
4
5
|
sync_paths: list[str]
|
|
5
6
|
copy_paths: list[str]
|
|
@@ -7,6 +8,7 @@ class BackupConfigModel(BaseModel):
|
|
|
7
8
|
root_dir: str
|
|
8
9
|
verbose_log: bool = False
|
|
9
10
|
|
|
11
|
+
|
|
10
12
|
class RcloneConfigModel(BaseModel):
|
|
11
13
|
args: list[str] = [
|
|
12
14
|
"--update",
|
|
@@ -17,10 +19,11 @@ class RcloneConfigModel(BaseModel):
|
|
|
17
19
|
"--timeout 300s",
|
|
18
20
|
"--retries 3",
|
|
19
21
|
"--low-level-retries 10",
|
|
20
|
-
"--stats 1s"
|
|
22
|
+
"--stats 1s",
|
|
21
23
|
]
|
|
22
24
|
concurrent_limit: int = 50
|
|
23
25
|
|
|
26
|
+
|
|
24
27
|
class ConfigModel(BaseModel):
|
|
25
28
|
backup: BackupConfigModel
|
|
26
29
|
rclone: RcloneConfigModel = RcloneConfigModel()
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
from enum import Enum
|
|
2
2
|
|
|
3
|
+
|
|
3
4
|
class LogLevel(Enum):
|
|
4
5
|
ERROR = "error"
|
|
5
6
|
LOG = "log"
|
|
6
7
|
INFO = "info"
|
|
7
8
|
WARN = "warn"
|
|
8
9
|
|
|
10
|
+
|
|
9
11
|
class BackupLog(Enum):
|
|
10
12
|
OK = "✓"
|
|
11
13
|
ERR = "⛌"
|
|
@@ -16,14 +18,23 @@ class CommandType(Enum):
|
|
|
16
18
|
COPY = "copy"
|
|
17
19
|
SYNC = "sync"
|
|
18
20
|
|
|
21
|
+
|
|
19
22
|
class PathType(Enum):
|
|
20
23
|
FILE = "file"
|
|
21
24
|
DIR = "dir"
|
|
22
25
|
|
|
26
|
+
|
|
23
27
|
class BackupStatus(Enum):
|
|
24
28
|
IN_PROGRESS = "in_progress"
|
|
25
29
|
FINISHED = "finished"
|
|
26
30
|
|
|
31
|
+
|
|
27
32
|
class RcloneOperationType(Enum):
|
|
28
33
|
BACKUP = "backup"
|
|
29
34
|
MKDIR = "mkdir"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FindMissingOptions(Enum):
|
|
38
|
+
copy = "copy"
|
|
39
|
+
sync = "sync"
|
|
40
|
+
all = "all"
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: easyclone
|
|
3
|
-
Version:
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Very convenient Rclone bulk backup wrapper
|
|
5
|
+
Author-email: Ahmet Arda Kavakcı <ahmetardakavakci@gmail.com>
|
|
6
|
+
Maintainer-email: Ahmet Arda Kavakcı <ahmetardakavakci@gmail.com>
|
|
7
|
+
License-Expression: GPL-3.0-or-later
|
|
5
8
|
Project-URL: Repository, https://github.com/dybdeskarphet/easyclone
|
|
6
9
|
Project-URL: Documentation, https://github.com/dybdeskarphet/easyclone
|
|
7
10
|
Project-URL: Issues, https://github.com/dybdeskarphet/easyclone/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/dybdeskarphet/easyclone/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: backup,sync,google,drive,easyclone
|
|
8
13
|
Requires-Python: >=3.13
|
|
9
14
|
Description-Content-Type: text/markdown
|
|
10
15
|
License-File: LICENSE
|
|
@@ -15,6 +20,8 @@ Dynamic: license-file
|
|
|
15
20
|
|
|
16
21
|
# ☁️ easyclone
|
|
17
22
|
|
|
23
|
+
[](https://pypi.org/project/easyclone/) [](https://pypi.org/project/easyclone/) [](https://pypi.org/project/easyclone/)
|
|
24
|
+
|
|
18
25
|
**easyclone** is a lightweight, configurable CLI tool that wraps `rclone` to behave more like Google's *Backup and Sync* app.
|
|
19
26
|
|
|
20
27
|
You define what to back up, where to back it up, and EasyClone handles the syncs and copies — clean, fast, and reliable.
|
|
@@ -21,7 +21,7 @@ src/easyclone/shared/__init__.py
|
|
|
21
21
|
src/easyclone/shared/sync_status.py
|
|
22
22
|
src/easyclone/utils/__init__.py
|
|
23
23
|
src/easyclone/utils/essentials.py
|
|
24
|
-
src/easyclone/utils/
|
|
24
|
+
src/easyclone/utils/path.py
|
|
25
25
|
src/easyclone/utypes/__init__.py
|
|
26
26
|
src/easyclone/utypes/config.py
|
|
27
27
|
src/easyclone/utypes/enums.py
|
|
@@ -1,70 +0,0 @@
|
|
|
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 make_backup_operation
|
|
7
|
-
import typer
|
|
8
|
-
import json
|
|
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
|
|
12
|
-
|
|
13
|
-
app = typer.Typer(
|
|
14
|
-
help="Very convenient Rclone bulk backup wrapper",
|
|
15
|
-
context_settings={"help_option_names": ["-h", "--help"]}
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
async def ipc():
|
|
19
|
-
server = await start_status_server()
|
|
20
|
-
async with server:
|
|
21
|
-
await server.serve_forever()
|
|
22
|
-
|
|
23
|
-
@app.command(help="Starts the backup process using the details in the config file.")
|
|
24
|
-
def start_backup(verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enables the rclone logging (overrides config).")] = False):
|
|
25
|
-
async def start():
|
|
26
|
-
exit_if_no_rclone()
|
|
27
|
-
exit_if_currently_running()
|
|
28
|
-
await sync_status.set_total_path_count(len(cfg.backup.sync_paths) + len(cfg.backup.copy_paths))
|
|
29
|
-
|
|
30
|
-
_ipc_task = asyncio.create_task(ipc())
|
|
31
|
-
|
|
32
|
-
verbose_state = verbose or cfg.backup.verbose_log
|
|
33
|
-
|
|
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)()
|
|
36
|
-
|
|
37
|
-
asyncio.run(start())
|
|
38
|
-
|
|
39
|
-
@app.command(help="Gets status information about the backup process.")
|
|
40
|
-
def get_status(
|
|
41
|
-
all: Annotated[bool, typer.Option("--all", "-a", help="Show all the backup status information.")] = False,
|
|
42
|
-
show_total: Annotated[bool, typer.Option("--show-total", "-t", help="Show the total amount of paths.")] = False,
|
|
43
|
-
show_current: Annotated[bool, typer.Option("--show-current", "-c", help="Show the total amount of pending paths.")] = 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
|
|
47
|
-
):
|
|
48
|
-
data: Any = asyncio.run(listen_ipc())
|
|
49
|
-
|
|
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):
|
|
51
|
-
print(json.dumps(data, indent=2))
|
|
52
|
-
return
|
|
53
|
-
|
|
54
|
-
if show_total:
|
|
55
|
-
print(data["total_path_count"])
|
|
56
|
-
|
|
57
|
-
if show_current:
|
|
58
|
-
print(data["operation_count"])
|
|
59
|
-
|
|
60
|
-
if show_operations:
|
|
61
|
-
print(json.dumps(data["operations"], indent=2))
|
|
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
|
-
|
|
69
|
-
if __name__ == "__main__":
|
|
70
|
-
app()
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
import os
|
|
4
|
-
from easyclone.utypes.enums import PathType
|
|
5
|
-
from easyclone.utypes.models import OrganizedPaths, PathItem
|
|
6
|
-
|
|
7
|
-
def organize_paths(paths: list[str], remote_name: str) -> OrganizedPaths:
|
|
8
|
-
from easyclone.config import cfg
|
|
9
|
-
source_dest_array: list[PathItem] = []
|
|
10
|
-
empty_paths: list[str] = []
|
|
11
|
-
root_dir = cfg.backup.root_dir
|
|
12
|
-
|
|
13
|
-
for path in paths:
|
|
14
|
-
p = Path(os.path.expandvars(os.path.expanduser(path)))
|
|
15
|
-
|
|
16
|
-
if not os.path.exists(p):
|
|
17
|
-
empty_paths.append(path)
|
|
18
|
-
|
|
19
|
-
if p.is_dir():
|
|
20
|
-
source_dest_array.append({
|
|
21
|
-
"source": f"{p}",
|
|
22
|
-
"dest": f"{remote_name}:{root_dir}{p}",
|
|
23
|
-
"path_type": PathType.DIR.value
|
|
24
|
-
})
|
|
25
|
-
elif p.is_file():
|
|
26
|
-
dest_dir = p.parent
|
|
27
|
-
source_dest_array.append({
|
|
28
|
-
"source": f"{p}",
|
|
29
|
-
"dest": f"{remote_name}:{root_dir}{dest_dir}",
|
|
30
|
-
"path_type": PathType.FILE.value
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
return {
|
|
34
|
-
"valid_paths": source_dest_array,
|
|
35
|
-
"empty_paths": empty_paths
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
def collapseuser(path: str) -> str:
|
|
39
|
-
"""
|
|
40
|
-
Opposite of path.expanduser()
|
|
41
|
-
"""
|
|
42
|
-
home = os.path.expanduser("~")
|
|
43
|
-
if path.startswith(home):
|
|
44
|
-
return path.replace(home, "~", 1)
|
|
45
|
-
return path
|
|
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
|