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.
Files changed (34) hide show
  1. {easyclone-0.3.0/src/easyclone.egg-info → easyclone-1.1.0}/PKG-INFO +8 -1
  2. {easyclone-0.3.0 → easyclone-1.1.0}/README.md +2 -0
  3. {easyclone-0.3.0 → easyclone-1.1.0}/pyproject.toml +11 -6
  4. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/config.py +13 -8
  5. easyclone-1.1.0/src/easyclone/ipc/__init__.py +2 -0
  6. easyclone-1.1.0/src/easyclone/main.py +178 -0
  7. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/rclone/backup.py +42 -13
  8. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/rclone/create_dirs.py +58 -19
  9. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/rclone/operations.py +16 -8
  10. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/shared/sync_status.py +22 -12
  11. easyclone-1.1.0/src/easyclone/utils/__init__.py +2 -0
  12. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/utils/essentials.py +15 -4
  13. easyclone-1.1.0/src/easyclone/utils/path.py +85 -0
  14. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/utypes/config.py +4 -1
  15. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/utypes/enums.py +11 -0
  16. {easyclone-0.3.0 → easyclone-1.1.0/src/easyclone.egg-info}/PKG-INFO +8 -1
  17. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone.egg-info/SOURCES.txt +1 -1
  18. easyclone-0.3.0/src/easyclone/ipc/__init__.py +0 -2
  19. easyclone-0.3.0/src/easyclone/main.py +0 -70
  20. easyclone-0.3.0/src/easyclone/utils/__init__.py +0 -2
  21. easyclone-0.3.0/src/easyclone/utils/path_manipulation.py +0 -45
  22. {easyclone-0.3.0 → easyclone-1.1.0}/LICENSE +0 -0
  23. {easyclone-0.3.0 → easyclone-1.1.0}/setup.cfg +0 -0
  24. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/__main__.py +0 -0
  25. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/ipc/client.py +0 -0
  26. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/ipc/server.py +0 -0
  27. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/rclone/__init__.py +0 -0
  28. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/shared/__init__.py +0 -0
  29. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/utypes/__init__.py +0 -0
  30. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone/utypes/models.py +0 -0
  31. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone.egg-info/dependency_links.txt +0 -0
  32. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone.egg-info/entry_points.txt +0 -0
  33. {easyclone-0.3.0 → easyclone-1.1.0}/src/easyclone.egg-info/requires.txt +0 -0
  34. {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: 0.3.0
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
+ [![Pepy Total Downloads](https://img.shields.io/pepy/dt/easyclone)](https://pypi.org/project/easyclone/) [![PyPI - Version](https://img.shields.io/pypi/v/easyclone)](https://pypi.org/project/easyclone/) [![PyPI - License](https://img.shields.io/pypi/l/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
+ [![Pepy Total Downloads](https://img.shields.io/pepy/dt/easyclone)](https://pypi.org/project/easyclone/) [![PyPI - Version](https://img.shields.io/pypi/v/easyclone)](https://pypi.org/project/easyclone/) [![PyPI - License](https://img.shields.io/pypi/l/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 = "0.3.0"
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() / '.config' / "easyclone" / "config.toml"
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) / 'easyclone'
40
- else:
41
- config_dir = Path.home() / '.config' / "easyclone"
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(f"Error while opening the config file {self._path}: {e}", LogLevel.ERROR)
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,2 @@
1
+ from .server import start_status_server, SOCKET_PATH
2
+ from .client import listen_ipc
@@ -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 BackupLog, BackupStatus, CommandType, LogLevel, RcloneOperationType
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.path_manipulation import collapseuser
13
+ from easyclone.utils.path import collapseuser
8
14
 
9
- async def backup_command(rclone_command: list[str], source: str, dest: str, path_type: str, command_type: CommandType, verbose: bool = False):
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(*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)
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(f"Back up operation failed with {process.returncode} exit code: {collapsed_source}", BackupLog.ERR)
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
- async def backup(paths: list[PathItem], command_type: CommandType, rclone_args: list[str], semaphore: asyncio.Semaphore, verbose: bool = False):
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 BackupLog, BackupStatus, LogLevel, PathType, RcloneOperationType
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("Root", {"source": "/", "dest": f"{cfg.backup.remote_name}:{cfg.backup.root_dir}", "path_type": "dir",})
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(*lsd_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
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(*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)
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(f"Operation failed with {process.returncode} exit code: {dest}", BackupLog.ERR)
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
- async def create_folders_on_remote(nodes: list[DirNode], semaphore: asyncio.Semaphore, verbose: bool):
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 = [mkdir_task(node.details.get("source"), node.details.get("dest")) for node in nodes]
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
- async def traverse_and_create_folders_by_depth(root: DirNode, verbose: bool, semaphore: asyncio.Semaphore):
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 create_dir_tree, create_dirs_array, traverse_and_create_folders_by_depth
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.path_manipulation import organize_paths
11
+ from easyclone.utils.path import organize_paths
8
12
  from easyclone.utypes.enums import CommandType, LogLevel
9
13
 
10
- def make_backup_operation(command_type: CommandType, paths_config: list[str], verbose: bool):
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(f"Below paths couldn't be found:\n{"\n".join(paths["empty_paths"])}\n", LogLevel.WARN)
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(self, source: str, dest: str, path_type: str, status: BackupStatus, operation_type: RcloneOperationType):
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
- "id": random_id,
20
- "source": source,
21
- "dest": dest,
22
- "status": status.value,
23
- "path_type": path_type,
24
- "operation_type": operation_type.value
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)
@@ -0,0 +1,2 @@
1
+ from .essentials import log, exit_if_no_rclone
2
+ from .path import organize_paths, collapseuser
@@ -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(isinstance(logtype, LogLevel)):
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("Rclone is not installed on your system, 'rclone' command should be in the $PATH.", LogLevel.ERROR)
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('/tmp/easyclone.sock'):
38
- log("Easyclone is already running. Delete /tmp/easyclone.sock if you think this is a mistake.", LogLevel.WARN)
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: 0.3.0
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
+ [![Pepy Total Downloads](https://img.shields.io/pepy/dt/easyclone)](https://pypi.org/project/easyclone/) [![PyPI - Version](https://img.shields.io/pypi/v/easyclone)](https://pypi.org/project/easyclone/) [![PyPI - License](https://img.shields.io/pypi/l/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/path_manipulation.py
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,2 +0,0 @@
1
- from .server import start_status_server
2
- from .client import listen_ipc
@@ -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,2 +0,0 @@
1
- from .essentials import log, exit_if_no_rclone
2
- from .path_manipulation import organize_paths, collapseuser
@@ -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