Skip to content

Commit 390dc02

Browse files
authored
Merge pull request #34 from Cyber-Syntax:refactor/improve-code-structure
refactor: code style and add cleanup support for decrypted files
2 parents 98b7702 + e61971a commit 390dc02

16 files changed

Lines changed: 435 additions & 305 deletions

AGENTS.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,20 @@ This file provides guidance to agents when working with code in this repository.
55
## General Guidelines
66

77
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.
8+
2. Type Safety: Full type annotations on all functions including return types. Use modern syntax (`dict[str, int]`, `str | None`).
9+
3. KISS Principle: Aim for simplicity and clarity. Avoid unnecessary abstractions or metaprogramming.
10+
4. DRY with Care: Reuse code appropriately but avoid over-engineering. Each command handler has single responsibility.
1311

14-
## Activate venv before any test execution
12+
## Test after any change
1513

16-
Unit test located in `tests/` directory
14+
1. Activate venv before any test execution:
1715

1816
```bash
1917
source .venv/bin/activate
18+
```
19+
20+
2. Run pytest with following command to ensure all tests pass:
21+
22+
```bash
2023
pytest -v -qa --strict-markers
2124
```

autotarcompress/commands.py

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,14 @@
1-
"""Command pattern implementations for backup operations.
1+
"""Re-export command classes for backward compatibility in backup operations."""
22

3-
This module re-exports command classes from the commands package
4-
for backward compatibility.
5-
"""
3+
from autotarcompress.commands.backup import BackupCommand
4+
from autotarcompress.commands.cleanup import CleanupCommand
5+
from autotarcompress.commands.command import Command
6+
from autotarcompress.commands.decrypt import DecryptCommand
7+
from autotarcompress.commands.encrypt import EncryptCommand
8+
from autotarcompress.commands.extract import ExtractCommand
9+
from autotarcompress.commands.info import InfoCommand
610

7-
# Re-export command classes for backward compatibility
8-
from autotarcompress.commands import (
9-
BackupCommand,
10-
CleanupCommand,
11-
Command,
12-
DecryptCommand,
13-
EncryptCommand,
14-
ExtractCommand,
15-
InfoCommand,
16-
)
17-
18-
__all__ = [
11+
__all__: list[str] = [
1912
"BackupCommand",
2013
"CleanupCommand",
2114
"Command",

autotarcompress/commands/backup.py

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,46 +21,78 @@
2121

2222

2323
class BackupCommand(Command):
24-
"""Concrete command to perform backup"""
24+
"""Command to create compressed backup archives using tar and xz."""
2525

26-
def __init__(self, config: BackupConfig):
27-
self.config = config
28-
self.logger = logging.getLogger(__name__)
26+
def __init__(self, config: BackupConfig) -> None:
27+
"""Initialize BackupCommand.
28+
29+
Args:
30+
config (BackupConfig): Backup configuration object.
31+
32+
"""
33+
self.config: BackupConfig = config
34+
self.logger: logging.Logger = logging.getLogger(__name__)
2935

3036
def execute(self) -> bool:
31-
"""Execute backup process."""
32-
if not self.config.dirs_to_backup:
33-
self.logger.error("No directories configured for backup")
34-
return False
37+
"""Execute backup process.
3538
36-
total_size = self._calculate_total_size()
37-
success = self._run_backup_process(total_size)
39+
Returns:
40+
bool: True if backup succeeded, False otherwise.
3841
39-
# Save backup info if backup was successful
42+
"""
43+
if not self.config.dirs_to_backup:
44+
msg = "No directories configured for backup. Skipping backup."
45+
print(msg)
46+
self.logger.error("No directories configured for backup. Skipping backup.")
47+
return False
48+
total_size: int = self._calculate_total_size()
49+
if total_size == 0:
50+
msg = "Total backup size is 0 bytes. Nothing to back up."
51+
print(msg)
52+
self.logger.warning("Total backup size is 0 bytes. Nothing to back up.")
53+
return False
54+
success: bool = self._run_backup_process(total_size)
4055
if success:
4156
self._save_backup_info(total_size)
42-
4357
return success
4458

4559
def _calculate_total_size(self) -> int:
60+
"""Calculate total size of all directories to back up.
61+
62+
Returns:
63+
int: Total size in bytes.
64+
65+
"""
4666
calculator = SizeCalculator(self.config.dirs_to_backup, self.config.ignore_list)
4767
return calculator.calculate_total_size()
4868

4969
def _run_backup_process(self, total_size: int) -> bool:
50-
"""Run the backup process and return success status."""
51-
# Check is there any file exist with same name
70+
"""Run the backup process and return success status.
71+
72+
Args:
73+
total_size (int): Total size of files to back up, in bytes.
74+
75+
Returns:
76+
bool: True if backup succeeded, False otherwise.
77+
78+
"""
5279
if os.path.exists(self.config.backup_path):
53-
print(f"File already exist: {self.config.backup_path}")
80+
print(f"File already exists: {self.config.backup_path}")
81+
self.logger.warning("Backup file already exists: %s", self.config.backup_path)
5482
if input("Do you want to remove it? (y/n): ").lower() == "y":
55-
os.remove(self.config.backup_path)
83+
try:
84+
os.remove(self.config.backup_path)
85+
self.logger.info("Removed existing backup file: %s", self.config.backup_path)
86+
except Exception as e:
87+
print(f"Failed to remove existing backup: {e}")
88+
self.logger.error("Failed to remove existing backup: %s", e)
89+
return False
5690
else:
91+
print("Backup aborted by user.")
92+
self.logger.info("Backup aborted by user due to existing file.")
5793
return False
5894

59-
exclude_options = " ".join([f"--exclude={path}" for path in self.config.ignore_list])
60-
61-
# TODO: need to fix this exclude option
62-
# TEST: without os.path.basename which it is not working
63-
# exclude_options += f" --exclude={self.config.backup_folder}"
95+
exclude_options = " ".join(f"--exclude={path}" for path in self.config.ignore_list)
6496

6597
dir_paths = [os.path.expanduser(path) for path in self.config.dirs_to_backup]
6698

@@ -73,13 +105,14 @@ def _run_backup_process(self, total_size: int) -> bool:
73105

74106
# HACK: h option is used to follow symlinks
75107
cmd = (
76-
f"tar -chf - --one-file-system {exclude_options} {' '.join(quoted_paths)} | "
108+
f"tar -chf - --one-file-system {exclude_options} "
109+
f"{' '.join(quoted_paths)} | "
77110
f"xz --threads={threads} > {self.config.backup_path}"
78111
)
79112
total_size_gb = total_size / 1024**3
80113

81114
self.logger.info(f"Starting backup to {self.config.backup_path}")
82-
self.logger.info(f"Total size: {total_size_gb} GB")
115+
self.logger.info(f"Total size: {total_size_gb:.2f} GB")
83116

84117
try:
85118
# FIX: later spinner not working for now
@@ -88,8 +121,10 @@ def _run_backup_process(self, total_size: int) -> bool:
88121
# self._show_spinner(subprocess.Popen(cmd, shell=True))
89122
subprocess.run(cmd, shell=True, check=True)
90123
self.logger.info("Backup completed successfully")
124+
print("Backup completed successfully.")
91125
return True
92126
except subprocess.CalledProcessError as e:
127+
print(f"Backup failed: {e}")
93128
self.logger.error(f"Backup failed: {e}")
94129
return False
95130

autotarcompress/commands/cleanup.py

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,37 +14,49 @@
1414

1515

1616
class CleanupCommand(Command):
17-
"""Concrete command to perform cleanup of old backups"""
17+
"""Command to clean up old backup, encrypted, and decrypted files."""
1818

19-
def __init__(self, config: BackupConfig):
20-
self.config = config
21-
self.logger = logging.getLogger(__name__)
19+
def __init__(self, config: BackupConfig) -> None:
20+
"""Initialize CleanupCommand.
21+
22+
Args:
23+
config (BackupConfig): Backup configuration with retention and folder settings.
24+
25+
"""
26+
self.config: BackupConfig = config
27+
self.logger: logging.Logger = logging.getLogger(__name__)
2228

2329
def execute(self) -> bool:
24-
"""Execute cleanup process for old backup files"""
30+
"""Delete old backup, encrypted, and decrypted files per retention policy.
31+
32+
Returns:
33+
bool: Always True (cleanup always completes, even if nothing to delete).
34+
35+
"""
2536
self._cleanup_files(".tar.xz", self.config.keep_backup)
2637
self._cleanup_files(".tar.xz.enc", self.config.keep_enc_backup)
38+
self._cleanup_files(".tar.xz-decrypted", self.config.keep_backup)
2739
return True
2840

2941
def _cleanup_files(self, ext: str, keep_count: int) -> None:
30-
"""Delete old backup files based on file extension and retention count.
42+
"""Delete old files by extension, keeping only the most recent as configured.
3143
3244
Args:
33-
ext: File extension to filter for cleanup
34-
keep_count: Number of recent files to keep
45+
ext (str): File extension to filter for cleanup.
46+
keep_count (int): Number of recent files to keep.
3547
3648
"""
37-
backup_folder = Path(self.config.backup_folder)
38-
39-
# Get all files with the specified extension
40-
files = sorted(
49+
backup_folder: Path = Path(self.config.backup_folder)
50+
files: list[str] = sorted(
4151
[f for f in os.listdir(backup_folder) if f.endswith(ext)],
4252
key=lambda x: datetime.datetime.strptime(x.split(".")[0], "%d-%m-%Y"),
4353
)
44-
45-
# Delete files exceeding the retention count
46-
files_to_delete = files if keep_count == 0 else files[:-keep_count]
47-
54+
files_to_delete: list[str] = files if keep_count == 0 else files[:-keep_count]
55+
if not files_to_delete:
56+
msg = f"No old '{ext}' files to remove."
57+
print(msg)
58+
self.logger.info("No old '%s' files to remove.", ext)
59+
return
4860
for old_file in files_to_delete:
4961
file_path = backup_folder / old_file
5062
try:

autotarcompress/commands/command.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@
77

88

99
class Command(ABC):
10-
"""Command interface for backup manager"""
10+
"""Abstract command interface for backup manager operations."""
1111

1212
@abstractmethod
1313
def execute(self) -> bool:
14-
"""Execute the command operation"""
14+
"""Execute the command operation.
15+
16+
Returns:
17+
bool: True if command succeeded, False otherwise.
18+
19+
"""

autotarcompress/commands/decrypt.py

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,45 @@
1616

1717

1818
class DecryptCommand(Command):
19-
"""Concrete command to perform decryption with secure parameters"""
20-
21-
PBKDF2_ITERATIONS = 600000 # Must match encryption iterations
22-
23-
def __init__(self, config: BackupConfig, file_path: str):
24-
self.config = config
25-
self.file_path = file_path
26-
self.logger = logging.getLogger(__name__)
19+
"""
20+
Command to securely decrypt backup archives using OpenSSL with PBKDF2.
21+
22+
This class ensures decryption parameters match those used for encryption and
23+
verifies file integrity post-decryption. Logging uses %s formatting for performance.
24+
"""
25+
26+
PBKDF2_ITERATIONS: int = 600000 # Must match encryption iterations
27+
28+
def __init__(self, config: BackupConfig, file_path: str) -> None:
29+
"""
30+
Initialize the DecryptCommand.
31+
32+
Args:
33+
config (BackupConfig): The backup configuration object.
34+
file_path (str): Path to the encrypted file to decrypt.
35+
"""
36+
self.config: BackupConfig = config
37+
self.file_path: str = file_path
38+
self.logger: logging.Logger = logging.getLogger(__name__)
2739
self._password_context = ContextManager()._password_context
2840
self._safe_cleanup = ContextManager()._safe_cleanup
2941

3042
def execute(self) -> bool:
31-
"""Secure decryption with matched PBKDF2 parameters"""
32-
output_path = os.path.splitext(self.file_path)[0]
33-
decrypted_path = f"{output_path}-decrypted"
43+
"""
44+
Perform secure decryption with matched PBKDF2 parameters.
45+
46+
Returns:
47+
bool: True if decryption and integrity check succeed, False otherwise.
48+
"""
49+
output_path: str = os.path.splitext(self.file_path)[0]
50+
decrypted_path: str = f"{output_path}-decrypted"
3451

52+
# Use password context manager to securely obtain password
3553
with self._password_context() as password:
3654
if not password:
3755
return False
3856

39-
cmd = [
57+
cmd: list[str] = [
4058
"openssl",
4159
"enc",
4260
"-d",
@@ -66,7 +84,10 @@ def execute(self) -> bool:
6684
self._verify_integrity(decrypted_path)
6785
return True
6886
except subprocess.CalledProcessError as e:
69-
self.logger.error(f"Decryption failed: {self._sanitize_logs(e.stderr)}")
87+
# Log sanitized error output to avoid leaking sensitive data
88+
self.logger.error(
89+
"Decryption failed: %s", self._sanitize_logs(e.stderr)
90+
)
7091
self._safe_cleanup(decrypted_path)
7192
return False
7293
except subprocess.TimeoutExpired:
@@ -75,13 +96,18 @@ def execute(self) -> bool:
7596
return False
7697

7798
def _verify_integrity(self, decrypted_path: str) -> None:
78-
"""Verify decrypted file matches original backup checksum"""
79-
original_path = os.path.splitext(self.file_path)[0]
99+
"""
100+
Verify decrypted file matches original backup checksum.
101+
102+
Args:
103+
decrypted_path (str): Path to the decrypted file.
104+
"""
105+
original_path: str = os.path.splitext(self.file_path)[0]
80106
if os.path.exists(original_path):
81-
decrypted_hash = self._calculate_sha256(decrypted_path)
82-
original_hash = self._calculate_sha256(original_path)
107+
decrypted_hash: str = self._calculate_sha256(decrypted_path)
108+
original_hash: str = self._calculate_sha256(original_path)
83109

84-
# Compare hashes
110+
# Print hashes for manual inspection (acceptable for CLI tools)
85111
print(f"Decrypted file hash: {decrypted_hash}")
86112
print(f"Original file hash: {original_hash}")
87113

@@ -91,7 +117,15 @@ def _verify_integrity(self, decrypted_path: str) -> None:
91117
self.logger.error("Integrity check failed")
92118

93119
def _calculate_sha256(self, file_path: str) -> str:
94-
"""Calculate SHA256 checksum for a file"""
120+
"""
121+
Calculate SHA256 checksum for a file.
122+
123+
Args:
124+
file_path (str): Path to the file.
125+
126+
Returns:
127+
str: SHA256 hex digest of the file contents.
128+
"""
95129
sha256 = hashlib.sha256()
96130
with open(file_path, "rb") as f:
97131
while True:
@@ -102,8 +136,16 @@ def _calculate_sha256(self, file_path: str) -> str:
102136
return sha256.hexdigest()
103137

104138
def _sanitize_logs(self, output: bytes) -> str:
105-
"""Safe log sanitization without modifying bytes"""
106-
# Replace password=<value> with password=[REDACTED]
139+
"""
140+
Sanitize log output to redact sensitive information.
141+
142+
Args:
143+
output (bytes): Raw stderr output from subprocess.
144+
145+
Returns:
146+
str: Sanitized string safe for logging.
147+
"""
148+
# Redact password and IP addresses from logs for security
107149
sanitized = re.sub(rb"password=[^\s]*", b"password=[REDACTED]", output)
108150
sanitized = re.sub(rb"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", b"[IP_REDACTED]", sanitized)
109151
return sanitized.decode("utf-8", errors="replace")

0 commit comments

Comments
 (0)