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.
@@ -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)
@@ -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()
File without changes
@@ -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
+ ![maintained](https://img.shields.io/badge/maintained-yes-success?style=flat-square)
20
+ ![license](https://img.shields.io/badge/license-MIT-009b9b?style=flat-square)
21
+ ![PyPI](https://img.shields.io/pypi/v/trs-file-backup?style=flat-square)
22
+ ![Python](https://img.shields.io/pypi/pyversions/trs-file-backup?style=flat-square)
23
+ ![ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)
24
+ ![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)
25
+ [![Build Status](https://github.com/TheRedSwabian/trs-file-backup/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/TheRedSwabian/trs-file-backup/actions)
26
+ [![codecov](https://codecov.io/gh/TheRedSwabian/trs-file-backup/branch/develop/graph/badge.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ bcs = trs_file_backup.main:app
3
+ trs-file-backup = trs_file_backup.main:app
@@ -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.