trs-file-backup 2.0.1__py3-none-any.whl
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.
- trs_file_backup/__init__.py +1 -0
- trs_file_backup/backup.py +131 -0
- trs_file_backup/logger.py +163 -0
- trs_file_backup/main.py +583 -0
- trs_file_backup/py.typed +0 -0
- trs_file_backup/state.py +122 -0
- trs_file_backup-2.0.1.dist-info/METADATA +98 -0
- trs_file_backup-2.0.1.dist-info/RECORD +11 -0
- trs_file_backup-2.0.1.dist-info/WHEEL +4 -0
- trs_file_backup-2.0.1.dist-info/entry_points.txt +3 -0
- trs_file_backup-2.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.0.1"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""File backup operations."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from trs_file_backup.state import StateManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BackupManager:
|
|
12
|
+
"""Manages file backup operations."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
source_path: Path,
|
|
17
|
+
destination_path: Path,
|
|
18
|
+
excluded_patterns: List[str],
|
|
19
|
+
state: Optional[StateManager],
|
|
20
|
+
):
|
|
21
|
+
"""Initialize backup manager.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
source_path: Source directory path
|
|
25
|
+
destination_path: Destination directory path
|
|
26
|
+
excluded_patterns: List of exclusion patterns
|
|
27
|
+
state: StateManager instance or None
|
|
28
|
+
"""
|
|
29
|
+
self.source_path = source_path
|
|
30
|
+
self.destination_path = destination_path
|
|
31
|
+
self.excluded_patterns = excluded_patterns
|
|
32
|
+
self.state = state
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def generate_backup_filename(filename: str, timestamp: datetime) -> str:
|
|
36
|
+
"""Generate timestamped backup filename.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
filename: Original filename (can include path)
|
|
40
|
+
timestamp: Timestamp for the backup
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Formatted filename: filename_YYYY_MM_DD__HH_MM_SS.ext
|
|
44
|
+
"""
|
|
45
|
+
# Extract just the filename if path is included
|
|
46
|
+
path = Path(filename)
|
|
47
|
+
name_without_ext = path.stem
|
|
48
|
+
extension = path.suffix
|
|
49
|
+
timestamp_str = timestamp.strftime("%Y_%m_%d__%H_%M_%S")
|
|
50
|
+
return f"{name_without_ext}_{timestamp_str}{extension}"
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def matches_exclusion_pattern(filename: str, patterns: List[str]) -> bool:
|
|
54
|
+
"""Check if filename matches any exclusion pattern.
|
|
55
|
+
|
|
56
|
+
Hidden files (starting with .) are always excluded.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
filename: Filename to check
|
|
60
|
+
patterns: List of exclusion patterns (supports wildcards)
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
True if filename should be excluded, False otherwise
|
|
64
|
+
"""
|
|
65
|
+
# Always exclude hidden files
|
|
66
|
+
if filename.startswith("."):
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
# Check against patterns
|
|
70
|
+
path = Path(filename)
|
|
71
|
+
for pattern in patterns:
|
|
72
|
+
if path.match(pattern):
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
def get_files_to_backup(self) -> List[Path]:
|
|
78
|
+
"""Get list of files that need to be backed up.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
List of file paths that should be backed up
|
|
82
|
+
"""
|
|
83
|
+
files_to_backup = []
|
|
84
|
+
|
|
85
|
+
# Iterate only top-level files (no subdirectories)
|
|
86
|
+
for item in self.source_path.iterdir():
|
|
87
|
+
# Skip directories
|
|
88
|
+
if item.is_dir():
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# Skip excluded files
|
|
92
|
+
if self.matches_exclusion_pattern(item.name, self.excluded_patterns):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
# If no state exists, backup all files
|
|
96
|
+
if self.state is None:
|
|
97
|
+
files_to_backup.append(item)
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
# Check if file is modified
|
|
101
|
+
current_mtime = item.stat().st_mtime
|
|
102
|
+
if self.state.is_modified(item.name, current_mtime):
|
|
103
|
+
files_to_backup.append(item)
|
|
104
|
+
|
|
105
|
+
return files_to_backup
|
|
106
|
+
|
|
107
|
+
def backup_file(self, source_file: Path, timestamp: datetime) -> Path:
|
|
108
|
+
"""Backup a single file with timestamp.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
source_file: Path to source file
|
|
112
|
+
timestamp: Timestamp for backup filename
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Path to the created backup file
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
PermissionError: If file cannot be accessed
|
|
119
|
+
OSError: If file is locked or other IO error occurs
|
|
120
|
+
"""
|
|
121
|
+
# Ensure destination directory exists
|
|
122
|
+
self.destination_path.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
# Generate backup filename
|
|
125
|
+
backup_filename = self.generate_backup_filename(source_file.name, timestamp)
|
|
126
|
+
destination_file = self.destination_path / backup_filename
|
|
127
|
+
|
|
128
|
+
# Copy file (preserves metadata)
|
|
129
|
+
shutil.copy2(source_file, destination_file)
|
|
130
|
+
|
|
131
|
+
return destination_file
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Logging functionality for backup operations."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BackupLogger:
|
|
9
|
+
"""Handles logging of backup operations to file."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, destination_path: Path):
|
|
12
|
+
"""Initialize logger.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
destination_path: Destination directory where log file will be created
|
|
16
|
+
"""
|
|
17
|
+
self.destination_path = destination_path
|
|
18
|
+
self.log_file_path = destination_path / "backup.log"
|
|
19
|
+
|
|
20
|
+
# Ensure destination directory exists
|
|
21
|
+
destination_path.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
# Configure logger
|
|
24
|
+
self.logger = logging.getLogger("trs_file_backup")
|
|
25
|
+
self.logger.setLevel(logging.INFO)
|
|
26
|
+
|
|
27
|
+
# Remove existing handlers to avoid duplicates
|
|
28
|
+
# Close handlers properly to avoid ResourceWarning
|
|
29
|
+
for handler in self.logger.handlers[:]:
|
|
30
|
+
handler.close()
|
|
31
|
+
self.logger.removeHandler(handler)
|
|
32
|
+
|
|
33
|
+
# Create file handler with UTF-8 encoding and append mode
|
|
34
|
+
handler = logging.FileHandler(
|
|
35
|
+
self.log_file_path,
|
|
36
|
+
mode="a",
|
|
37
|
+
encoding="utf-8",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Set format
|
|
41
|
+
formatter = logging.Formatter(
|
|
42
|
+
"[%(asctime)s] [%(levelname)s] %(message)s",
|
|
43
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
44
|
+
)
|
|
45
|
+
handler.setFormatter(formatter)
|
|
46
|
+
|
|
47
|
+
self.logger.addHandler(handler)
|
|
48
|
+
|
|
49
|
+
def log_session_start(
|
|
50
|
+
self,
|
|
51
|
+
command: str,
|
|
52
|
+
source_path: Path,
|
|
53
|
+
destination_path: Path,
|
|
54
|
+
excluded_patterns: List[str],
|
|
55
|
+
debounce_seconds: int,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Log session start with configuration.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
command: Command being executed (init, run, watch)
|
|
61
|
+
source_path: Source directory path
|
|
62
|
+
destination_path: Destination directory path
|
|
63
|
+
excluded_patterns: List of exclusion patterns
|
|
64
|
+
debounce_seconds: Debounce delay in seconds
|
|
65
|
+
"""
|
|
66
|
+
self.logger.info("=" * 10 + " Session Started " + "=" * 10)
|
|
67
|
+
self.logger.info(f"Command: {command}")
|
|
68
|
+
self.logger.info(f"Source: {source_path}")
|
|
69
|
+
self.logger.info(f"Destination: {destination_path}")
|
|
70
|
+
self.logger.info(f"Exclusion patterns: {', '.join(excluded_patterns)}")
|
|
71
|
+
self.logger.info(f"Debounce delay: {debounce_seconds} seconds")
|
|
72
|
+
|
|
73
|
+
def log_session_end(self, files_backed_up: int = 0, files_total: int = 0) -> None:
|
|
74
|
+
"""Log session end with summary.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
files_backed_up: Number of files successfully backed up
|
|
78
|
+
files_total: Total number of files processed
|
|
79
|
+
"""
|
|
80
|
+
if files_total > 0:
|
|
81
|
+
self.logger.info(f"Session summary: {files_backed_up} of {files_total} files backed up")
|
|
82
|
+
self.logger.info("=" * 10 + " Session Ended " + "=" * 10)
|
|
83
|
+
# Add three blank lines for better session separation
|
|
84
|
+
self.logger.info("")
|
|
85
|
+
self.logger.info("")
|
|
86
|
+
self.logger.info("")
|
|
87
|
+
|
|
88
|
+
def log_init_success(self) -> None:
|
|
89
|
+
"""Log successful initialization."""
|
|
90
|
+
self.logger.info("Backup tracking initialized successfully")
|
|
91
|
+
|
|
92
|
+
def log_found_files(self, count: int) -> None:
|
|
93
|
+
"""Log number of files found.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
count: Number of modified files found
|
|
97
|
+
"""
|
|
98
|
+
self.logger.info(f"Found {count} modified files")
|
|
99
|
+
|
|
100
|
+
def log_file_backed_up(self, source_name: str, backup_name: str) -> None:
|
|
101
|
+
"""Log successful file backup.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
source_name: Original filename
|
|
105
|
+
backup_name: Backup filename with timestamp
|
|
106
|
+
"""
|
|
107
|
+
self.logger.info(f"Backed up: {source_name} → {backup_name}")
|
|
108
|
+
|
|
109
|
+
def log_file_excluded(self, filename: str, pattern: str) -> None:
|
|
110
|
+
"""Log excluded file.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
filename: Name of excluded file
|
|
114
|
+
pattern: Pattern that matched
|
|
115
|
+
"""
|
|
116
|
+
self.logger.warning(f"File excluded: {filename} (matches: {pattern})")
|
|
117
|
+
|
|
118
|
+
def log_file_locked(self, filename: str) -> None:
|
|
119
|
+
"""Log locked file warning.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
filename: Name of locked file
|
|
123
|
+
"""
|
|
124
|
+
self.logger.warning(f"File locked, skipping: {filename}")
|
|
125
|
+
|
|
126
|
+
def log_error(self, message: str) -> None:
|
|
127
|
+
"""Log error message.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
message: Error message
|
|
131
|
+
"""
|
|
132
|
+
self.logger.error(message)
|
|
133
|
+
|
|
134
|
+
def log_file_modified(self, filename: str) -> None:
|
|
135
|
+
"""Log file modification event (watch mode).
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
filename: Name of modified file
|
|
139
|
+
"""
|
|
140
|
+
self.logger.info(f"File modified: {filename}")
|
|
141
|
+
|
|
142
|
+
def log_watch_started(self) -> None:
|
|
143
|
+
"""Log watch mode started."""
|
|
144
|
+
self.logger.info("Watch mode started, monitoring for changes...")
|
|
145
|
+
|
|
146
|
+
def log_watch_stopped(self) -> None:
|
|
147
|
+
"""Log watch mode stopped."""
|
|
148
|
+
self.logger.info("Watch mode stopped by user (Ctrl+C)")
|
|
149
|
+
|
|
150
|
+
def log_backup_completed(self, backed_up: int, total: int) -> None:
|
|
151
|
+
"""Log backup operation completion.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
backed_up: Number of files backed up successfully
|
|
155
|
+
total: Total number of files attempted
|
|
156
|
+
"""
|
|
157
|
+
self.logger.info(f"Backup operation completed: {backed_up} of {total} files backed up")
|
|
158
|
+
|
|
159
|
+
def close(self) -> None:
|
|
160
|
+
"""Close all handlers and clean up resources."""
|
|
161
|
+
for handler in self.logger.handlers[:]:
|
|
162
|
+
handler.close()
|
|
163
|
+
self.logger.removeHandler(handler)
|
trs_file_backup/main.py
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"""Main CLI application for trs-file-backup."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, List
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from watchdog.events import FileSystemEventHandler
|
|
11
|
+
from watchdog.observers import Observer
|
|
12
|
+
|
|
13
|
+
from trs_file_backup import __version__
|
|
14
|
+
from trs_file_backup.backup import BackupManager
|
|
15
|
+
from trs_file_backup.logger import BackupLogger
|
|
16
|
+
from trs_file_backup.state import StateManager
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="trs-file-backup",
|
|
20
|
+
help="trs-file-backup - Monitor and backup modified files with timestamps",
|
|
21
|
+
add_completion=False,
|
|
22
|
+
rich_markup_mode="rich",
|
|
23
|
+
epilog="""\b
|
|
24
|
+
Examples:
|
|
25
|
+
trs-file-backup run --source ./myproject --destination ./backup
|
|
26
|
+
trs-file-backup watch --source ./myproject --destination ./backup
|
|
27
|
+
|
|
28
|
+
Use 'trs-file-backup COMMAND --help' for detailed information on each command.
|
|
29
|
+
""",
|
|
30
|
+
)
|
|
31
|
+
console = Console()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def version_callback(value: bool) -> None:
|
|
35
|
+
"""Show version and exit."""
|
|
36
|
+
if value:
|
|
37
|
+
print(f"trs-file-backup version {__version__}")
|
|
38
|
+
raise typer.Exit(0)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.callback(invoke_without_command=True)
|
|
42
|
+
def main(
|
|
43
|
+
ctx: typer.Context,
|
|
44
|
+
version: bool = typer.Option(
|
|
45
|
+
False,
|
|
46
|
+
"--version",
|
|
47
|
+
"-v",
|
|
48
|
+
help="Show version and exit",
|
|
49
|
+
is_eager=True,
|
|
50
|
+
),
|
|
51
|
+
) -> None:
|
|
52
|
+
"""
|
|
53
|
+
trs-file-backup - Monitor and backup modified files with timestamps
|
|
54
|
+
|
|
55
|
+
This tool helps you create timestamped backups of modified files from a
|
|
56
|
+
source directory to a destination directory. It supports one-time backups,
|
|
57
|
+
initialization, and continuous monitoring.
|
|
58
|
+
|
|
59
|
+
Options:
|
|
60
|
+
-v, --version Show version and exit
|
|
61
|
+
|
|
62
|
+
\b
|
|
63
|
+
Examples:
|
|
64
|
+
trs-file-backup run --source ./myproject --destination ./backup
|
|
65
|
+
trs-file-backup watch --source ./myproject --destination ./backup
|
|
66
|
+
|
|
67
|
+
Use 'trs-file-backup COMMAND --help' for more information on a specific command.
|
|
68
|
+
"""
|
|
69
|
+
if version:
|
|
70
|
+
version_callback(True)
|
|
71
|
+
|
|
72
|
+
if ctx.invoked_subcommand is None:
|
|
73
|
+
console.print(ctx.get_help())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def init(
|
|
78
|
+
source: Path = typer.Option( # noqa: B008
|
|
79
|
+
...,
|
|
80
|
+
"--source",
|
|
81
|
+
help="Source directory to monitor",
|
|
82
|
+
exists=True,
|
|
83
|
+
file_okay=False,
|
|
84
|
+
dir_okay=True,
|
|
85
|
+
resolve_path=True,
|
|
86
|
+
),
|
|
87
|
+
destination: Path = typer.Option( # noqa: B008
|
|
88
|
+
...,
|
|
89
|
+
"--destination",
|
|
90
|
+
help="Destination backup directory",
|
|
91
|
+
file_okay=False,
|
|
92
|
+
dir_okay=True,
|
|
93
|
+
resolve_path=True,
|
|
94
|
+
),
|
|
95
|
+
exclude: List[str] = typer.Option( # noqa: B008
|
|
96
|
+
[],
|
|
97
|
+
"--exclude",
|
|
98
|
+
help="File/directory patterns to exclude (can be repeated)",
|
|
99
|
+
),
|
|
100
|
+
debounce: int = typer.Option(
|
|
101
|
+
2,
|
|
102
|
+
"--debounce",
|
|
103
|
+
help="Seconds to wait before backup (default: 2)",
|
|
104
|
+
min=0,
|
|
105
|
+
),
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Initialize backup tracking for a directory.
|
|
108
|
+
|
|
109
|
+
This command sets up backup tracking by creating a state file in the destination
|
|
110
|
+
directory. The next 'run' or 'watch' command will back up all files (first
|
|
111
|
+
full backup). Subsequent runs will only backup modified files.
|
|
112
|
+
|
|
113
|
+
Examples:
|
|
114
|
+
|
|
115
|
+
# Basic initialization
|
|
116
|
+
trs-file-backup init --source C:\\Projects\\MyApp --destination C:\\Backup\\MyApp
|
|
117
|
+
|
|
118
|
+
# Initialize with exclusions and custom debounce
|
|
119
|
+
trs-file-backup init --source ./src --destination ./backup --exclude "*.log" --exclude "*.tmp" --debounce 5
|
|
120
|
+
|
|
121
|
+
Note:
|
|
122
|
+
Exclusion patterns and debounce setting specified here are saved and used for all future backups.
|
|
123
|
+
"""
|
|
124
|
+
logger = None
|
|
125
|
+
try:
|
|
126
|
+
# Add default exclusions
|
|
127
|
+
excluded_patterns = [".backup_state.json", *exclude]
|
|
128
|
+
|
|
129
|
+
# Create logger
|
|
130
|
+
logger = BackupLogger(destination)
|
|
131
|
+
|
|
132
|
+
# Log session start
|
|
133
|
+
logger.log_session_start(
|
|
134
|
+
command="init",
|
|
135
|
+
source_path=source,
|
|
136
|
+
destination_path=destination,
|
|
137
|
+
excluded_patterns=excluded_patterns,
|
|
138
|
+
debounce_seconds=debounce,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Create initial state
|
|
142
|
+
state = StateManager(
|
|
143
|
+
source_path=source,
|
|
144
|
+
destination_path=destination,
|
|
145
|
+
debounce_seconds=debounce,
|
|
146
|
+
excluded_patterns=excluded_patterns,
|
|
147
|
+
files={},
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Save state
|
|
151
|
+
state.save()
|
|
152
|
+
|
|
153
|
+
# Log success
|
|
154
|
+
logger.log_init_success()
|
|
155
|
+
logger.log_session_end()
|
|
156
|
+
|
|
157
|
+
# Show user message
|
|
158
|
+
console.print(f"[green]✓[/green] Initialized backup tracking for {source}")
|
|
159
|
+
console.print(f"State file saved to {state.state_file_path}")
|
|
160
|
+
console.print(f"Log file created: {destination / 'backup.log'}")
|
|
161
|
+
console.print("Next run will back up all files.")
|
|
162
|
+
|
|
163
|
+
except Exception as e:
|
|
164
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
165
|
+
raise typer.Exit(code=1) from None
|
|
166
|
+
finally:
|
|
167
|
+
if logger is not None:
|
|
168
|
+
logger.close()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.command()
|
|
172
|
+
def run(
|
|
173
|
+
source: Path = typer.Option( # noqa: B008
|
|
174
|
+
...,
|
|
175
|
+
"--source",
|
|
176
|
+
help="Source directory to monitor",
|
|
177
|
+
exists=True,
|
|
178
|
+
file_okay=False,
|
|
179
|
+
dir_okay=True,
|
|
180
|
+
resolve_path=True,
|
|
181
|
+
),
|
|
182
|
+
destination: Path = typer.Option( # noqa: B008
|
|
183
|
+
...,
|
|
184
|
+
"--destination",
|
|
185
|
+
help="Destination backup directory",
|
|
186
|
+
file_okay=False,
|
|
187
|
+
dir_okay=True,
|
|
188
|
+
resolve_path=True,
|
|
189
|
+
),
|
|
190
|
+
exclude: List[str] = typer.Option( # noqa: B008
|
|
191
|
+
[],
|
|
192
|
+
"--exclude",
|
|
193
|
+
help="File/directory patterns to exclude (can be repeated)",
|
|
194
|
+
),
|
|
195
|
+
debounce: int = typer.Option(
|
|
196
|
+
2,
|
|
197
|
+
"--debounce",
|
|
198
|
+
help="Seconds to wait before backup (default: 2)",
|
|
199
|
+
min=0,
|
|
200
|
+
),
|
|
201
|
+
dry_run: bool = typer.Option(
|
|
202
|
+
False,
|
|
203
|
+
"--dry-run",
|
|
204
|
+
help="Show what would be backed up without copying",
|
|
205
|
+
),
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Execute a one-time backup of modified files.
|
|
208
|
+
|
|
209
|
+
This command scans the source directory for files that have been modified
|
|
210
|
+
since the last backup and creates timestamped copies in the destination
|
|
211
|
+
directory. Only files changed after the last backup run are processed.
|
|
212
|
+
|
|
213
|
+
Examples:
|
|
214
|
+
|
|
215
|
+
# Basic backup
|
|
216
|
+
trs-file-backup run --source C:\\Projects\\MyApp --destination C:\\Backup\\MyApp
|
|
217
|
+
|
|
218
|
+
# Exclude log files
|
|
219
|
+
trs-file-backup run --source ./src --destination ./backup --exclude "*.log"
|
|
220
|
+
|
|
221
|
+
# Multiple exclusions with custom debounce
|
|
222
|
+
trs-file-backup run --source ./src --destination ./backup --exclude "*.log" --exclude "temp_*" --debounce 5
|
|
223
|
+
|
|
224
|
+
# Preview without copying
|
|
225
|
+
trs-file-backup run --source ./src --destination ./backup --dry-run
|
|
226
|
+
"""
|
|
227
|
+
logger = None
|
|
228
|
+
try:
|
|
229
|
+
# Add default exclusions
|
|
230
|
+
excluded_patterns = [".backup_state.json", *exclude]
|
|
231
|
+
|
|
232
|
+
# Load or create state
|
|
233
|
+
state_file = destination / ".backup_state.json"
|
|
234
|
+
state = StateManager.load(state_file)
|
|
235
|
+
|
|
236
|
+
if state is None:
|
|
237
|
+
# Create new state
|
|
238
|
+
state = StateManager(
|
|
239
|
+
source_path=source,
|
|
240
|
+
destination_path=destination,
|
|
241
|
+
debounce_seconds=debounce,
|
|
242
|
+
excluded_patterns=excluded_patterns,
|
|
243
|
+
files={},
|
|
244
|
+
)
|
|
245
|
+
else:
|
|
246
|
+
# Update state with current parameters
|
|
247
|
+
state.excluded_patterns = excluded_patterns
|
|
248
|
+
state.debounce_seconds = debounce
|
|
249
|
+
|
|
250
|
+
# Create logger
|
|
251
|
+
logger = BackupLogger(destination)
|
|
252
|
+
|
|
253
|
+
# Log session start
|
|
254
|
+
logger.log_session_start(
|
|
255
|
+
command="run",
|
|
256
|
+
source_path=source,
|
|
257
|
+
destination_path=destination,
|
|
258
|
+
excluded_patterns=excluded_patterns,
|
|
259
|
+
debounce_seconds=debounce,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Create backup manager
|
|
263
|
+
backup_manager = BackupManager(
|
|
264
|
+
source_path=source,
|
|
265
|
+
destination_path=destination,
|
|
266
|
+
excluded_patterns=excluded_patterns,
|
|
267
|
+
state=state,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Get files to backup
|
|
271
|
+
console.print(f"Scanning source directory: {source}")
|
|
272
|
+
files_to_backup = backup_manager.get_files_to_backup()
|
|
273
|
+
logger.log_found_files(len(files_to_backup))
|
|
274
|
+
|
|
275
|
+
if len(files_to_backup) == 0:
|
|
276
|
+
console.print("[yellow]No modified files found.[/yellow]")
|
|
277
|
+
logger.log_session_end(0, 0)
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
console.print(f"Found {len(files_to_backup)} modified files")
|
|
281
|
+
|
|
282
|
+
if dry_run:
|
|
283
|
+
console.print("\n[yellow]Dry-run mode: No files will be copied[/yellow]\n")
|
|
284
|
+
for file in files_to_backup:
|
|
285
|
+
timestamp = datetime.now()
|
|
286
|
+
backup_name = BackupManager.generate_backup_filename(file.name, timestamp)
|
|
287
|
+
console.print(f" Would backup: {file.name} → {backup_name}")
|
|
288
|
+
logger.log_session_end(0, len(files_to_backup))
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
# Backup files
|
|
292
|
+
console.print("Backing up files...")
|
|
293
|
+
backed_up_count = 0
|
|
294
|
+
failed_count = 0
|
|
295
|
+
|
|
296
|
+
for file in files_to_backup:
|
|
297
|
+
try:
|
|
298
|
+
# Wait debounce time
|
|
299
|
+
time.sleep(debounce)
|
|
300
|
+
|
|
301
|
+
# Backup file
|
|
302
|
+
timestamp = datetime.now()
|
|
303
|
+
backup_path = backup_manager.backup_file(file, timestamp)
|
|
304
|
+
|
|
305
|
+
# Update state
|
|
306
|
+
state.update_file_timestamp(file.name, file.stat().st_mtime)
|
|
307
|
+
|
|
308
|
+
# Log success
|
|
309
|
+
logger.log_file_backed_up(file.name, backup_path.name)
|
|
310
|
+
console.print(f" [green]✓[/green] {file.name} → {backup_path.name}")
|
|
311
|
+
backed_up_count += 1
|
|
312
|
+
|
|
313
|
+
except PermissionError:
|
|
314
|
+
logger.log_error(f"Permission denied: {file.name}")
|
|
315
|
+
console.print(f" [red]✗[/red] Permission denied: {file.name}")
|
|
316
|
+
failed_count += 1
|
|
317
|
+
|
|
318
|
+
except OSError:
|
|
319
|
+
logger.log_file_locked(file.name)
|
|
320
|
+
console.print(f" [yellow]⚠[/yellow] File locked, skipping: {file.name}")
|
|
321
|
+
failed_count += 1
|
|
322
|
+
|
|
323
|
+
# Save updated state
|
|
324
|
+
state.save()
|
|
325
|
+
|
|
326
|
+
# Log completion
|
|
327
|
+
logger.log_backup_completed(backed_up_count, len(files_to_backup))
|
|
328
|
+
logger.log_session_end(backed_up_count, len(files_to_backup))
|
|
329
|
+
|
|
330
|
+
# Show summary
|
|
331
|
+
console.print(f"\n[green]Successfully backed up {backed_up_count} files to {destination}[/green]")
|
|
332
|
+
if failed_count > 0:
|
|
333
|
+
console.print(f"[yellow]Failed: {failed_count} files[/yellow]")
|
|
334
|
+
console.print(f"Logging to: {destination / 'backup.log'}")
|
|
335
|
+
|
|
336
|
+
except Exception as e:
|
|
337
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
338
|
+
raise typer.Exit(code=1) from None
|
|
339
|
+
finally:
|
|
340
|
+
if logger is not None:
|
|
341
|
+
logger.close()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class BackupEventHandler(FileSystemEventHandler):
|
|
345
|
+
"""Handler for file system events in watch mode."""
|
|
346
|
+
|
|
347
|
+
def __init__(
|
|
348
|
+
self,
|
|
349
|
+
backup_manager: BackupManager,
|
|
350
|
+
state: StateManager,
|
|
351
|
+
logger: BackupLogger,
|
|
352
|
+
debounce_seconds: int,
|
|
353
|
+
):
|
|
354
|
+
"""Initialize event handler.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
backup_manager: BackupManager instance
|
|
358
|
+
state: StateManager instance
|
|
359
|
+
logger: BackupLogger instance
|
|
360
|
+
debounce_seconds: Debounce delay in seconds
|
|
361
|
+
"""
|
|
362
|
+
super().__init__()
|
|
363
|
+
self.backup_manager = backup_manager
|
|
364
|
+
self.state = state
|
|
365
|
+
self.logger = logger
|
|
366
|
+
self.debounce_seconds = debounce_seconds
|
|
367
|
+
self.pending_files: dict[Path, float] = {}
|
|
368
|
+
self.backed_up_count = 0
|
|
369
|
+
|
|
370
|
+
def on_modified(self, event: Any) -> None:
|
|
371
|
+
"""Handle file modification event.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
event: File system event
|
|
375
|
+
"""
|
|
376
|
+
if event.is_directory:
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
file_path = Path(event.src_path)
|
|
380
|
+
|
|
381
|
+
# Skip excluded files
|
|
382
|
+
if BackupManager.matches_exclusion_pattern(file_path.name, self.backup_manager.excluded_patterns):
|
|
383
|
+
# Find matching pattern for logging
|
|
384
|
+
for pattern in self.backup_manager.excluded_patterns:
|
|
385
|
+
if file_path.match(pattern) or (file_path.name.startswith(".") and pattern == ".backup_state.json"):
|
|
386
|
+
self.logger.log_file_excluded(file_path.name, pattern)
|
|
387
|
+
break
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
# Log modification
|
|
391
|
+
self.logger.log_file_modified(file_path.name)
|
|
392
|
+
console.print(f"\n[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] File modified: {file_path.name}")
|
|
393
|
+
|
|
394
|
+
# Update timestamp for this file (extends debounce window if already pending)
|
|
395
|
+
self.pending_files[file_path] = time.time()
|
|
396
|
+
|
|
397
|
+
def process_pending_files(self) -> None:
|
|
398
|
+
"""Process pending files that have passed the debounce window."""
|
|
399
|
+
now = time.time()
|
|
400
|
+
files_to_backup = []
|
|
401
|
+
|
|
402
|
+
# Find files that have passed debounce window
|
|
403
|
+
for file_path, event_time in list(self.pending_files.items()):
|
|
404
|
+
if now - event_time >= self.debounce_seconds:
|
|
405
|
+
files_to_backup.append(file_path)
|
|
406
|
+
|
|
407
|
+
# Backup files that passed debounce
|
|
408
|
+
for file_path in files_to_backup:
|
|
409
|
+
try:
|
|
410
|
+
# Check if file still exists and is not locked
|
|
411
|
+
if not file_path.exists():
|
|
412
|
+
del self.pending_files[file_path]
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
timestamp = datetime.now()
|
|
416
|
+
backup_path = self.backup_manager.backup_file(file_path, timestamp)
|
|
417
|
+
|
|
418
|
+
# Update state
|
|
419
|
+
self.state.update_file_timestamp(file_path.name, file_path.stat().st_mtime)
|
|
420
|
+
self.state.save()
|
|
421
|
+
|
|
422
|
+
# Log success
|
|
423
|
+
self.logger.log_file_backed_up(file_path.name, backup_path.name)
|
|
424
|
+
console.print(f"[{timestamp.strftime('%Y-%m-%d %H:%M:%S')}] Backing up: {file_path.name} -> {backup_path.name} [green]OK[/green]")
|
|
425
|
+
|
|
426
|
+
self.backed_up_count += 1
|
|
427
|
+
|
|
428
|
+
# Remove from pending
|
|
429
|
+
del self.pending_files[file_path]
|
|
430
|
+
|
|
431
|
+
except PermissionError:
|
|
432
|
+
self.logger.log_error(f"Permission denied: {file_path.name}")
|
|
433
|
+
console.print(f"[red]ERROR[/red] Permission denied: {file_path.name}")
|
|
434
|
+
del self.pending_files[file_path]
|
|
435
|
+
|
|
436
|
+
except OSError:
|
|
437
|
+
self.logger.log_file_locked(file_path.name)
|
|
438
|
+
console.print(f"[yellow]WARNING[/yellow] File locked, skipping: {file_path.name}")
|
|
439
|
+
del self.pending_files[file_path]
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@app.command()
|
|
443
|
+
def watch(
|
|
444
|
+
source: Path = typer.Option( # noqa: B008
|
|
445
|
+
...,
|
|
446
|
+
"--source",
|
|
447
|
+
help="Source directory to monitor",
|
|
448
|
+
exists=True,
|
|
449
|
+
file_okay=False,
|
|
450
|
+
dir_okay=True,
|
|
451
|
+
resolve_path=True,
|
|
452
|
+
),
|
|
453
|
+
destination: Path = typer.Option( # noqa: B008
|
|
454
|
+
...,
|
|
455
|
+
"--destination",
|
|
456
|
+
help="Destination backup directory",
|
|
457
|
+
file_okay=False,
|
|
458
|
+
dir_okay=True,
|
|
459
|
+
resolve_path=True,
|
|
460
|
+
),
|
|
461
|
+
exclude: List[str] = typer.Option( # noqa: B008
|
|
462
|
+
[],
|
|
463
|
+
"--exclude",
|
|
464
|
+
help="File/directory patterns to exclude (can be repeated)",
|
|
465
|
+
),
|
|
466
|
+
debounce: int = typer.Option(
|
|
467
|
+
2,
|
|
468
|
+
"--debounce",
|
|
469
|
+
help="Seconds to wait after modification before backup",
|
|
470
|
+
min=0,
|
|
471
|
+
),
|
|
472
|
+
) -> None:
|
|
473
|
+
"""Continuously monitor and backup files in real-time.
|
|
474
|
+
|
|
475
|
+
This command starts a file system monitor that watches the source directory
|
|
476
|
+
for changes. When a file is modified, it automatically creates a timestamped
|
|
477
|
+
backup after a short delay (debounce). The monitor runs until you press
|
|
478
|
+
Ctrl+C.
|
|
479
|
+
|
|
480
|
+
Examples:
|
|
481
|
+
|
|
482
|
+
# Basic watch mode
|
|
483
|
+
trs-file-backup watch --source C:\\Projects\\MyApp --destination C:\\Backup\\MyApp
|
|
484
|
+
|
|
485
|
+
# Watch with exclusions
|
|
486
|
+
trs-file-backup watch --source ./src --destination ./backup --exclude "*.log"
|
|
487
|
+
|
|
488
|
+
# Custom debounce delay (5 seconds)
|
|
489
|
+
trs-file-backup watch --source ./src --destination ./backup --debounce 5
|
|
490
|
+
|
|
491
|
+
Tips:
|
|
492
|
+
- Use Ctrl+C to stop monitoring
|
|
493
|
+
- Debounce prevents backing up files that are still being written
|
|
494
|
+
- Hidden directories (starting with .) are automatically excluded
|
|
495
|
+
"""
|
|
496
|
+
logger = None
|
|
497
|
+
try:
|
|
498
|
+
# Add default exclusions
|
|
499
|
+
excluded_patterns = [".backup_state.json", *exclude]
|
|
500
|
+
|
|
501
|
+
# Load or create state
|
|
502
|
+
state_file = destination / ".backup_state.json"
|
|
503
|
+
state = StateManager.load(state_file)
|
|
504
|
+
|
|
505
|
+
if state is None:
|
|
506
|
+
# Create new state
|
|
507
|
+
state = StateManager(
|
|
508
|
+
source_path=source,
|
|
509
|
+
destination_path=destination,
|
|
510
|
+
debounce_seconds=debounce,
|
|
511
|
+
excluded_patterns=excluded_patterns,
|
|
512
|
+
files={},
|
|
513
|
+
)
|
|
514
|
+
else:
|
|
515
|
+
# Update state with current parameters
|
|
516
|
+
state.excluded_patterns = excluded_patterns
|
|
517
|
+
state.debounce_seconds = debounce
|
|
518
|
+
|
|
519
|
+
# Save state
|
|
520
|
+
state.save()
|
|
521
|
+
|
|
522
|
+
# Create logger
|
|
523
|
+
logger = BackupLogger(destination)
|
|
524
|
+
|
|
525
|
+
# Log session start
|
|
526
|
+
logger.log_session_start(
|
|
527
|
+
command="watch",
|
|
528
|
+
source_path=source,
|
|
529
|
+
destination_path=destination,
|
|
530
|
+
excluded_patterns=excluded_patterns,
|
|
531
|
+
debounce_seconds=debounce,
|
|
532
|
+
)
|
|
533
|
+
logger.log_watch_started()
|
|
534
|
+
|
|
535
|
+
# Create backup manager
|
|
536
|
+
backup_manager = BackupManager(
|
|
537
|
+
source_path=source,
|
|
538
|
+
destination_path=destination,
|
|
539
|
+
excluded_patterns=excluded_patterns,
|
|
540
|
+
state=state,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# Create event handler
|
|
544
|
+
event_handler = BackupEventHandler(
|
|
545
|
+
backup_manager=backup_manager,
|
|
546
|
+
state=state,
|
|
547
|
+
logger=logger,
|
|
548
|
+
debounce_seconds=debounce,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# Start observer
|
|
552
|
+
observer = Observer()
|
|
553
|
+
observer.schedule(event_handler, str(source), recursive=False)
|
|
554
|
+
observer.start()
|
|
555
|
+
|
|
556
|
+
console.print(f"[green]Starting file system monitor for {source}[/green]")
|
|
557
|
+
console.print("Watching for changes... (Press Ctrl+C to stop)")
|
|
558
|
+
console.print(f"Logging to: {destination / 'backup.log'}\n")
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
while True:
|
|
562
|
+
# Check for pending files to backup
|
|
563
|
+
event_handler.process_pending_files()
|
|
564
|
+
time.sleep(0.5) # Check every 0.5 seconds
|
|
565
|
+
except KeyboardInterrupt:
|
|
566
|
+
observer.stop()
|
|
567
|
+
logger.log_watch_stopped()
|
|
568
|
+
logger.log_session_end(event_handler.backed_up_count, event_handler.backed_up_count)
|
|
569
|
+
|
|
570
|
+
console.print("\n\nCtrl+C detected. Stopping file system monitor...")
|
|
571
|
+
|
|
572
|
+
observer.join()
|
|
573
|
+
|
|
574
|
+
except Exception as e:
|
|
575
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
576
|
+
raise typer.Exit(code=1) from None
|
|
577
|
+
finally:
|
|
578
|
+
if logger is not None:
|
|
579
|
+
logger.close()
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
if __name__ == "__main__":
|
|
583
|
+
app()
|
trs_file_backup/py.typed
ADDED
|
File without changes
|
trs_file_backup/state.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""State management for backup operations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StateManager:
|
|
9
|
+
"""Manages the backup state including file timestamps and configuration."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
source_path: Path,
|
|
14
|
+
destination_path: Path,
|
|
15
|
+
debounce_seconds: int,
|
|
16
|
+
excluded_patterns: List[str],
|
|
17
|
+
files: Dict[str, float],
|
|
18
|
+
):
|
|
19
|
+
"""Initialize state manager.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
source_path: Source directory path
|
|
23
|
+
destination_path: Destination directory path
|
|
24
|
+
debounce_seconds: Debounce delay in seconds
|
|
25
|
+
excluded_patterns: List of exclusion patterns
|
|
26
|
+
files: Dictionary mapping filenames to their last backup timestamps
|
|
27
|
+
"""
|
|
28
|
+
self.source_path = source_path
|
|
29
|
+
self.destination_path = destination_path
|
|
30
|
+
self.debounce_seconds = debounce_seconds
|
|
31
|
+
self.excluded_patterns = excluded_patterns
|
|
32
|
+
self.files = files
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def state_file_path(self) -> Path:
|
|
36
|
+
"""Get the path to the state file.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Path to .backup_state.json in destination directory
|
|
40
|
+
"""
|
|
41
|
+
return self.destination_path / ".backup_state.json"
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def load(cls, state_file_path: str | Path) -> Optional["StateManager"]:
|
|
45
|
+
"""Load state from file.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
state_file_path: Path to the state file
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
StateManager instance if file exists, None otherwise
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
json.JSONDecodeError: If state file contains invalid JSON
|
|
55
|
+
"""
|
|
56
|
+
path = Path(state_file_path)
|
|
57
|
+
if not path.exists():
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
with open(path, encoding="utf-8") as f:
|
|
61
|
+
data = json.load(f)
|
|
62
|
+
|
|
63
|
+
return cls(
|
|
64
|
+
source_path=Path(data["source_path"]),
|
|
65
|
+
destination_path=Path(data["destination_path"]),
|
|
66
|
+
debounce_seconds=data["debounce_seconds"],
|
|
67
|
+
excluded_patterns=data["excluded_patterns"],
|
|
68
|
+
files=data["files"],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def save(self) -> None:
|
|
72
|
+
"""Save state to file using atomic write operation.
|
|
73
|
+
|
|
74
|
+
Creates destination directory if it doesn't exist.
|
|
75
|
+
Uses temp file + rename for atomic write.
|
|
76
|
+
"""
|
|
77
|
+
# Ensure destination directory exists
|
|
78
|
+
self.destination_path.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
# Prepare data
|
|
81
|
+
data = {
|
|
82
|
+
"source_path": str(self.source_path),
|
|
83
|
+
"destination_path": str(self.destination_path),
|
|
84
|
+
"log_file": "backup.log",
|
|
85
|
+
"debounce_seconds": self.debounce_seconds,
|
|
86
|
+
"excluded_patterns": self.excluded_patterns,
|
|
87
|
+
"files": self.files,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Write to temp file first (atomic operation)
|
|
91
|
+
import os
|
|
92
|
+
|
|
93
|
+
temp_file = self.state_file_path.with_suffix(".json.tmp")
|
|
94
|
+
with open(temp_file, "w", encoding="utf-8") as f:
|
|
95
|
+
json.dump(data, f, indent=2)
|
|
96
|
+
|
|
97
|
+
# Rename temp file to actual file (atomic on most filesystems)
|
|
98
|
+
# Use os.replace for cross-platform atomic replacement
|
|
99
|
+
os.replace(str(temp_file), str(self.state_file_path))
|
|
100
|
+
|
|
101
|
+
def update_file_timestamp(self, filename: str, timestamp: float) -> None:
|
|
102
|
+
"""Update or add file timestamp.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
filename: Name of the file
|
|
106
|
+
timestamp: Modification timestamp
|
|
107
|
+
"""
|
|
108
|
+
self.files[filename] = timestamp
|
|
109
|
+
|
|
110
|
+
def is_modified(self, filename: str, current_timestamp: float) -> bool:
|
|
111
|
+
"""Check if file is modified since last backup.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
filename: Name of the file
|
|
115
|
+
current_timestamp: Current modification timestamp
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
True if file is new or modified, False otherwise
|
|
119
|
+
"""
|
|
120
|
+
if filename not in self.files:
|
|
121
|
+
return True
|
|
122
|
+
return current_timestamp > self.files[filename]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trs-file-backup
|
|
3
|
+
Version: 2.0.1
|
|
4
|
+
Summary: Script to backup changed files in a given folder.
|
|
5
|
+
Author: Ruediger Reiner
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: py-app-dev>=2.6
|
|
10
|
+
Requires-Dist: rich>=13.7
|
|
11
|
+
Requires-Dist: typer>=0.16
|
|
12
|
+
Requires-Dist: watchdog>=4.0
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# trs-file-backup
|
|
16
|
+
|
|
17
|
+
**trs-file-backup** automatically watches your working directories and creates timestamped backups of every changed file — purely local, no cloud, no sync service, no complex setup. Ideal for developers and creators who want a simple safety net while working on files that are not (yet) in version control.
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+

|
|
21
|
+

|
|
22
|
+

|
|
23
|
+

|
|
24
|
+

|
|
25
|
+
[](https://github.com/TheRedSwabian/trs-file-backup/actions)
|
|
26
|
+
[](https://codecov.io/gh/TheRedSwabian/trs-file-backup)
|
|
27
|
+
|
|
28
|
+
## Requirements
|
|
29
|
+
|
|
30
|
+
- **Python** 3.10 or higher
|
|
31
|
+
- **OS**: Windows, Linux, macOS
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- 📁 **Top-Level Monitoring** *(subdirectory support planned)*: Monitors files directly in the source directory
|
|
36
|
+
- 📦 **Flat Backup Structure**: All backups stored in one directory for easy access
|
|
37
|
+
- 🔄 **Incremental Backups**: Only backup files that have changed
|
|
38
|
+
- 🕐 **Timestamped Files**: Each backup uses format `filename_YYYY_MM_DD__HH_MM_SS.ext`
|
|
39
|
+
- 👁️ **Watch Mode**: Continuously monitor directories and backup automatically
|
|
40
|
+
- ⏱️ **Configurable Debounce**: Adjustable delay before backup (default: 2 seconds)
|
|
41
|
+
- 🎯 **Smart Exclusions**: Automatically skip hidden files
|
|
42
|
+
- 🚫 **Custom Patterns**: Exclude specific files with wildcard patterns
|
|
43
|
+
- 📊 **State Tracking**: Maintain backup history in `.backup_state.json` (stored in backup directory)
|
|
44
|
+
- 📝 **Activity Logging**: All operations logged to `backup.log` with timestamps
|
|
45
|
+
- 🔍 **Dry-Run Mode**: Preview changes before backing up
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
### Installation
|
|
50
|
+
|
|
51
|
+
Install this via pip (or your favorite package manager):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install trs-file-backup
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Basic Usage
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Initialize backup tracking
|
|
61
|
+
trs-file-backup init --source ./myproject --destination ./backup
|
|
62
|
+
|
|
63
|
+
# One-time backup of modified files
|
|
64
|
+
trs-file-backup run --source ./myproject --destination ./backup
|
|
65
|
+
|
|
66
|
+
# Continuous monitoring (watch mode)
|
|
67
|
+
trs-file-backup watch --source ./myproject --destination ./backup
|
|
68
|
+
|
|
69
|
+
# Exclude specific patterns
|
|
70
|
+
trs-file-backup run --source ./myproject --destination ./backup --exclude "*.log" --exclude "temp_*"
|
|
71
|
+
|
|
72
|
+
# Custom debounce delay (wait 5 seconds before backup)
|
|
73
|
+
trs-file-backup run --source ./myproject --destination ./backup --debounce 5
|
|
74
|
+
|
|
75
|
+
# Preview without copying
|
|
76
|
+
trs-file-backup run --source ./myproject --destination ./backup --dry-run
|
|
77
|
+
|
|
78
|
+
# Get help
|
|
79
|
+
trs-file-backup --help
|
|
80
|
+
trs-file-backup run --help
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Example Output
|
|
84
|
+
|
|
85
|
+
Original file: `report.txt`
|
|
86
|
+
Backup file: `report_2026_01_13__14_30_22.txt`
|
|
87
|
+
|
|
88
|
+
## Commands
|
|
89
|
+
|
|
90
|
+
- **`init`**: Initialize backup tracking for a directory
|
|
91
|
+
- **`run`**: Execute a one-time backup of modified files
|
|
92
|
+
- **`watch`**: Continuously monitor and backup files in real-time
|
|
93
|
+
|
|
94
|
+
Use `trs-file-backup COMMAND --help` for detailed information on each command.
|
|
95
|
+
|
|
96
|
+
## Contributing
|
|
97
|
+
|
|
98
|
+
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, commit conventions, and the release process.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
trs_file_backup/__init__.py,sha256=wAxkK8w13vqoF47A8iqWdSlIgRRXmZiQ0R4wePZfzhs,22
|
|
2
|
+
trs_file_backup/backup.py,sha256=X9x3U1jRvrSBpvNA7iNGbl0FVQpoTs-zRD7Hmz5LPWU,4184
|
|
3
|
+
trs_file_backup/logger.py,sha256=mGZlfQpPpK8QIWduZCzaj5aGI224cTtpBQahcNtjOws,5637
|
|
4
|
+
trs_file_backup/main.py,sha256=Drr0wCo01jAiAWiog2B_z775TywbUht4JaOPw_XbPdE,18808
|
|
5
|
+
trs_file_backup/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
trs_file_backup/state.py,sha256=FGTc1MoG4DaqAJf2Vow3ko-b8SK3oE9tpJrtn2uGfZg,4047
|
|
7
|
+
trs_file_backup-2.0.1.dist-info/METADATA,sha256=YRI4LGw5KJVT2lVU0IzPt8NcaQrRMrKOaELeRCmKgMQ,3980
|
|
8
|
+
trs_file_backup-2.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
trs_file_backup-2.0.1.dist-info/entry_points.txt,sha256=jLDNVyqWd8_qjX_kEagbxP2lOB0DZd_2AmMlB9_RgCw,92
|
|
10
|
+
trs_file_backup-2.0.1.dist-info/licenses/LICENSE,sha256=LzV1Ewc6B77-EaOwzSlvTtF23k8o7HSaSQ_9Y3dZlv8,1072
|
|
11
|
+
trs_file_backup-2.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ruediger Reiner
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|