Skip to content

Commit 377ba08

Browse files
authored
Merge pull request #32 from Cyber-Syntax:Cyber-Syntax/issue30
Save last backup date and improve code structure
2 parents a215e4b + fd8194a commit 377ba08

39 files changed

Lines changed: 3818 additions & 239 deletions

.github/copilot-instructions.md

Lines changed: 0 additions & 128 deletions
This file was deleted.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# Custom
2+
examples
3+
14
# Byte-compiled / optimized / DLL files
25
__pycache__/
36
*.py[cod]

AGENTS.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# AGENTS.md
2+
3+
This file provides guidance to agents when working with code in this repository.
4+
5+
## General Guidelines
6+
7+
1. Modern Python First: Use Python 3.12+ features extensively - built-in generics, pattern matching, and dataclasses.
8+
2. Async-First Architecture: All I/O operations must be async. Use modern async patterns like `asyncio.TaskGroup` for concurrency.
9+
3. Type Safety: Full type annotations on all functions including return types. Use modern syntax (`dict[str, int]`, `str | None`).
10+
4. KISS Principle: Aim for simplicity and clarity. Avoid unnecessary abstractions or metaprogramming.
11+
5. DRY with Care: Reuse code appropriately but avoid over-engineering. Each command handler has single responsibility.
12+
6. Performance-Conscious: Use `@dataclass(slots=True)` when object count justifies it, orjson for JSON, and async-safe patterns over explicit locks.
13+
14+
## Activate venv before any test execution
15+
16+
Unit test located in `tests/` directory
17+
18+
```bash
19+
source .venv/bin/activate
20+
pytest -v -qa --strict-markers
21+
```

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
# Changelog
22
All notable changes to this project will be documented in this file. Commits automatically generated by github actions.
33

4-
## v0.4.0-beta
4+
## v0.5.0-beta
5+
### Changes
6+
This release adds the `info` command to display details about the latest backup, including directories backed up and backup file status.
57

8+
## v0.4.0-beta
69
## v0.3.1-beta
710
### Changes
811
# BREAKING CHANGES
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
"""
66

77
# Re-export command classes for backward compatibility
8-
from src.commands import (
8+
from autotarcompress.commands import (
99
BackupCommand,
1010
CleanupCommand,
1111
Command,
1212
DecryptCommand,
1313
EncryptCommand,
1414
ExtractCommand,
15+
InfoCommand,
1516
)
1617

1718
__all__ = [
@@ -21,4 +22,5 @@
2122
"DecryptCommand",
2223
"EncryptCommand",
2324
"ExtractCommand",
25+
"InfoCommand",
2426
]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Command pattern implementations for backup operations.
2+
3+
This module aggregates all command classes for easy importing.
4+
"""
5+
6+
from autotarcompress.commands.command import Command
7+
from autotarcompress.commands.backup import BackupCommand
8+
from autotarcompress.commands.cleanup import CleanupCommand
9+
from autotarcompress.commands.decrypt import DecryptCommand
10+
from autotarcompress.commands.encrypt import EncryptCommand
11+
from autotarcompress.commands.extract import ExtractCommand
12+
from autotarcompress.commands.info import InfoCommand
13+
14+
__all__ = [
15+
"Command",
16+
"BackupCommand",
17+
"CleanupCommand",
18+
"DecryptCommand",
19+
"EncryptCommand",
20+
"ExtractCommand",
21+
"InfoCommand",
22+
]
Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@
44
using tar and xz compression.
55
"""
66

7+
import datetime
78
import itertools
9+
import json
810
import logging
911
import os
1012
import shlex
1113
import subprocess
1214
import sys
1315
import time
16+
from pathlib import Path
1417

15-
from src.commands.command import Command
16-
from src.config import BackupConfig
17-
from src.utils import SizeCalculator
18+
from autotarcompress.commands.command import Command
19+
from autotarcompress.config import BackupConfig
20+
from autotarcompress.utils import SizeCalculator
1821

1922

2023
class BackupCommand(Command):
@@ -25,29 +28,33 @@ def __init__(self, config: BackupConfig):
2528
self.logger = logging.getLogger(__name__)
2629

2730
def execute(self) -> bool:
28-
"""Execute backup process"""
31+
"""Execute backup process."""
2932
if not self.config.dirs_to_backup:
3033
self.logger.error("No directories configured for backup")
3134
return False
3235

3336
total_size = self._calculate_total_size()
34-
self._run_backup_process(total_size)
35-
return True
37+
success = self._run_backup_process(total_size)
38+
39+
# Save backup info if backup was successful
40+
if success:
41+
self._save_backup_info(total_size)
42+
43+
return success
3644

3745
def _calculate_total_size(self) -> int:
3846
calculator = SizeCalculator(self.config.dirs_to_backup, self.config.ignore_list)
3947
return calculator.calculate_total_size()
4048

41-
# HACK: use loading spinner as a workaround loading which tqdm won't work
42-
43-
def _run_backup_process(self, total_size: int) -> None:
49+
def _run_backup_process(self, total_size: int) -> bool:
50+
"""Run the backup process and return success status."""
4451
# Check is there any file exist with same name
4552
if os.path.exists(self.config.backup_path):
4653
print(f"File already exist: {self.config.backup_path}")
4754
if input("Do you want to remove it? (y/n): ").lower() == "y":
4855
os.remove(self.config.backup_path)
4956
else:
50-
return
57+
return False
5158

5259
exclude_options = " ".join([f"--exclude={path}" for path in self.config.ignore_list])
5360

@@ -56,14 +63,18 @@ def _run_backup_process(self, total_size: int) -> None:
5663
# exclude_options += f" --exclude={self.config.backup_folder}"
5764

5865
dir_paths = [os.path.expanduser(path) for path in self.config.dirs_to_backup]
59-
66+
6067
# Properly quote directory paths to handle spaces and special characters
6168
quoted_paths = [shlex.quote(path) for path in dir_paths]
62-
69+
70+
# Get CPU count safely
71+
cpu_count = os.cpu_count() or 1
72+
threads = max(1, cpu_count - 1)
73+
6374
# HACK: h option is used to follow symlinks
6475
cmd = (
6576
f"tar -chf - --one-file-system {exclude_options} {' '.join(quoted_paths)} | "
66-
f"xz --threads={os.cpu_count() - 1} > {self.config.backup_path}"
77+
f"xz --threads={threads} > {self.config.backup_path}"
6778
)
6879
total_size_gb = total_size / 1024**3
6980

@@ -72,12 +83,49 @@ def _run_backup_process(self, total_size: int) -> None:
7283

7384
try:
7485
# FIX: later spinner not working for now
75-
# FAILED: not work as expected because of "| tar: Removing leading `/' from member names" outputs
86+
# FAILED: not work as expected because of
87+
# "| tar: Removing leading `/' from member names" outputs
7688
# self._show_spinner(subprocess.Popen(cmd, shell=True))
7789
subprocess.run(cmd, shell=True, check=True)
7890
self.logger.info("Backup completed successfully")
91+
return True
7992
except subprocess.CalledProcessError as e:
8093
self.logger.error(f"Backup failed: {e}")
94+
return False
95+
96+
def _save_backup_info(self, total_size: int) -> None:
97+
"""Save backup information to last-backup-info.json."""
98+
try:
99+
backup_info = {
100+
"backup_file": Path(self.config.backup_path).name,
101+
"backup_path": str(self.config.backup_path),
102+
"backup_date": datetime.datetime.now().isoformat(),
103+
"backup_size_bytes": total_size,
104+
"backup_size_human": self._format_size(total_size),
105+
"directories_backed_up": self.config.dirs_to_backup,
106+
}
107+
108+
# Save the info file in the backup folder
109+
info_file_path = Path(self.config.backup_folder) / "last-backup-info.json"
110+
111+
with open(info_file_path, "w", encoding="utf-8") as f:
112+
json.dump(backup_info, f, indent=2)
113+
114+
self.logger.info(f"Backup info saved to {info_file_path}")
115+
116+
except Exception as e:
117+
self.logger.error(f"Failed to save backup info: {e}")
118+
119+
def _format_size(self, size_bytes: int) -> str:
120+
"""Format size in bytes to human readable format."""
121+
BYTES_IN_KB = 1024.0
122+
size = float(size_bytes)
123+
124+
for unit in ["B", "KB", "MB", "GB", "TB"]:
125+
if size < BYTES_IN_KB:
126+
return f"{size:.2f} {unit}"
127+
size /= BYTES_IN_KB
128+
return f"{size:.2f} PB"
81129

82130
def _show_spinner(self, process) -> None:
83131
spinner = itertools.cycle(["/", "-", "\\", "|"])
Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
import os
1010
from pathlib import Path
1111

12-
from src.commands.command import Command
13-
from src.config import BackupConfig
12+
from autotarcompress.commands.command import Command
13+
from autotarcompress.config import BackupConfig
1414

1515

1616
class CleanupCommand(Command):
@@ -43,10 +43,14 @@ def _cleanup_files(self, ext: str, keep_count: int) -> None:
4343
)
4444

4545
# Delete files exceeding the retention count
46-
for old_file in files[:-keep_count]:
46+
files_to_delete = files if keep_count == 0 else files[:-keep_count]
47+
48+
for old_file in files_to_delete:
4749
file_path = backup_folder / old_file
4850
try:
4951
file_path.unlink()
50-
self.logger.info(f"Deleted old backup: {old_file}")
52+
self.logger.info("Deleted old backup: %s", old_file)
53+
print(f"Deleted old backup: {old_file}")
5154
except Exception as e:
52-
self.logger.error(f"Failed to delete {old_file}: {e}")
55+
self.logger.error("Failed to delete %s: %s", old_file, e)
56+
print(f"Failed to delete {old_file}: {e}")
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ class Command(ABC):
1010
"""Command interface for backup manager"""
1111

1212
@abstractmethod
13-
def execute(self):
13+
def execute(self) -> bool:
1414
"""Execute the command operation"""

0 commit comments

Comments
 (0)