Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 0 additions & 128 deletions .github/copilot-instructions.md

This file was deleted.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Custom
examples

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# AGENTS.md

This file provides guidance to agents when working with code in this repository.

## General Guidelines

1. Modern Python First: Use Python 3.12+ features extensively - built-in generics, pattern matching, and dataclasses.
2. Async-First Architecture: All I/O operations must be async. Use modern async patterns like `asyncio.TaskGroup` for concurrency.
3. Type Safety: Full type annotations on all functions including return types. Use modern syntax (`dict[str, int]`, `str | None`).
4. KISS Principle: Aim for simplicity and clarity. Avoid unnecessary abstractions or metaprogramming.
5. DRY with Care: Reuse code appropriately but avoid over-engineering. Each command handler has single responsibility.
6. Performance-Conscious: Use `@dataclass(slots=True)` when object count justifies it, orjson for JSON, and async-safe patterns over explicit locks.

## Activate venv before any test execution

Unit test located in `tests/` directory

```bash
source .venv/bin/activate
pytest -v -qa --strict-markers
```
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# Changelog
All notable changes to this project will be documented in this file. Commits automatically generated by github actions.

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

## v0.4.0-beta
## v0.3.1-beta
### Changes
# BREAKING CHANGES
Expand Down
File renamed without changes.
4 changes: 3 additions & 1 deletion src/commands.py → autotarcompress/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
"""

# Re-export command classes for backward compatibility
from src.commands import (
from autotarcompress.commands import (
BackupCommand,
CleanupCommand,
Command,
DecryptCommand,
EncryptCommand,
ExtractCommand,
InfoCommand,
)

__all__ = [
Expand All @@ -21,4 +22,5 @@
"DecryptCommand",
"EncryptCommand",
"ExtractCommand",
"InfoCommand",
]
22 changes: 22 additions & 0 deletions autotarcompress/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Command pattern implementations for backup operations.

This module aggregates all command classes for easy importing.
"""

from autotarcompress.commands.command import Command
from autotarcompress.commands.backup import BackupCommand
from autotarcompress.commands.cleanup import CleanupCommand
from autotarcompress.commands.decrypt import DecryptCommand
from autotarcompress.commands.encrypt import EncryptCommand
from autotarcompress.commands.extract import ExtractCommand
from autotarcompress.commands.info import InfoCommand

__all__ = [
"Command",
"BackupCommand",
"CleanupCommand",
"DecryptCommand",
"EncryptCommand",
"ExtractCommand",
"InfoCommand",
]
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@
using tar and xz compression.
"""

import datetime
import itertools
import json
import logging
import os
import shlex
import subprocess
import sys
import time
from pathlib import Path

from src.commands.command import Command
from src.config import BackupConfig
from src.utils import SizeCalculator
from autotarcompress.commands.command import Command
from autotarcompress.config import BackupConfig
from autotarcompress.utils import SizeCalculator


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

def execute(self) -> bool:
"""Execute backup process"""
"""Execute backup process."""
if not self.config.dirs_to_backup:
self.logger.error("No directories configured for backup")
return False

total_size = self._calculate_total_size()
self._run_backup_process(total_size)
return True
success = self._run_backup_process(total_size)

# Save backup info if backup was successful
if success:
self._save_backup_info(total_size)

return success

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

# HACK: use loading spinner as a workaround loading which tqdm won't work

def _run_backup_process(self, total_size: int) -> None:
def _run_backup_process(self, total_size: int) -> bool:
"""Run the backup process and return success status."""
# Check is there any file exist with same name
if os.path.exists(self.config.backup_path):
print(f"File already exist: {self.config.backup_path}")
if input("Do you want to remove it? (y/n): ").lower() == "y":
os.remove(self.config.backup_path)
else:
return
return False

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

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

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

# Properly quote directory paths to handle spaces and special characters
quoted_paths = [shlex.quote(path) for path in dir_paths]


# Get CPU count safely
cpu_count = os.cpu_count() or 1
threads = max(1, cpu_count - 1)

# HACK: h option is used to follow symlinks
cmd = (
f"tar -chf - --one-file-system {exclude_options} {' '.join(quoted_paths)} | "
f"xz --threads={os.cpu_count() - 1} > {self.config.backup_path}"
f"xz --threads={threads} > {self.config.backup_path}"
)
total_size_gb = total_size / 1024**3

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

try:
# FIX: later spinner not working for now
# FAILED: not work as expected because of "| tar: Removing leading `/' from member names" outputs
# FAILED: not work as expected because of
# "| tar: Removing leading `/' from member names" outputs
# self._show_spinner(subprocess.Popen(cmd, shell=True))
subprocess.run(cmd, shell=True, check=True)
self.logger.info("Backup completed successfully")
return True
except subprocess.CalledProcessError as e:
self.logger.error(f"Backup failed: {e}")
return False

def _save_backup_info(self, total_size: int) -> None:
"""Save backup information to last-backup-info.json."""
try:
backup_info = {
"backup_file": Path(self.config.backup_path).name,
"backup_path": str(self.config.backup_path),
"backup_date": datetime.datetime.now().isoformat(),
"backup_size_bytes": total_size,
"backup_size_human": self._format_size(total_size),
"directories_backed_up": self.config.dirs_to_backup,
}

# Save the info file in the backup folder
info_file_path = Path(self.config.backup_folder) / "last-backup-info.json"

with open(info_file_path, "w", encoding="utf-8") as f:
json.dump(backup_info, f, indent=2)

self.logger.info(f"Backup info saved to {info_file_path}")

except Exception as e:
self.logger.error(f"Failed to save backup info: {e}")

def _format_size(self, size_bytes: int) -> str:
"""Format size in bytes to human readable format."""
BYTES_IN_KB = 1024.0
size = float(size_bytes)

for unit in ["B", "KB", "MB", "GB", "TB"]:
if size < BYTES_IN_KB:
return f"{size:.2f} {unit}"
size /= BYTES_IN_KB
return f"{size:.2f} PB"

def _show_spinner(self, process) -> None:
spinner = itertools.cycle(["/", "-", "\\", "|"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
import os
from pathlib import Path

from src.commands.command import Command
from src.config import BackupConfig
from autotarcompress.commands.command import Command
from autotarcompress.config import BackupConfig


class CleanupCommand(Command):
Expand Down Expand Up @@ -43,10 +43,14 @@ def _cleanup_files(self, ext: str, keep_count: int) -> None:
)

# Delete files exceeding the retention count
for old_file in files[:-keep_count]:
files_to_delete = files if keep_count == 0 else files[:-keep_count]

for old_file in files_to_delete:
file_path = backup_folder / old_file
try:
file_path.unlink()
self.logger.info(f"Deleted old backup: {old_file}")
self.logger.info("Deleted old backup: %s", old_file)
print(f"Deleted old backup: {old_file}")
except Exception as e:
self.logger.error(f"Failed to delete {old_file}: {e}")
self.logger.error("Failed to delete %s: %s", old_file, e)
print(f"Failed to delete {old_file}: {e}")
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ class Command(ABC):
"""Command interface for backup manager"""

@abstractmethod
def execute(self):
def execute(self) -> bool:
"""Execute the command operation"""
Loading