diggah.py (15654B)
1 #!/usr/bin/env python3 2 import os 3 import sys 4 from collections.abc import Iterable 5 from datetime import datetime, timedelta 6 from typing import TextIO, cast 7 8 import fire 9 import paramiko 10 11 12 class DateCalculator: 13 """Handles date validation and range calculations.""" 14 15 @staticmethod 16 def validate_date(date_str: str) -> datetime: 17 try: 18 return datetime.strptime(date_str, "%Y-%m-%d") 19 except ValueError: 20 raise ValueError(f"'{date_str}' is incorrent, please use YYYY-MM-DD") 21 22 def get_range_from_week( 23 self, year: int, month: int, week: int 24 ) -> tuple[datetime, datetime]: 25 """ 26 Calculates date range for a specific week. 27 """ 28 first_of_month = datetime(int(year), int(month), 1) 29 weeks_delta = timedelta(weeks=int(week) - 1) 30 start_date = first_of_month + weeks_delta 31 end_date = start_date + timedelta(days=7) 32 return start_date, end_date 33 34 def get_range_from_month(self, year: int, month: int) -> tuple[datetime, datetime]: 35 """ 36 Calculates date range for a specific month. 37 """ 38 start_date = datetime(int(year), int(month), 1) 39 if month == 12: 40 end_date = datetime(int(year) + 1, 1, 1) 41 else: 42 end_date = datetime(int(year), int(month) + 1, 1) 43 return start_date, end_date 44 45 def get_range_from_year(self, year: int) -> tuple[datetime, datetime]: 46 """ 47 Calculates date range for a specific year. 48 """ 49 start_date = datetime(int(year), 1, 1) 50 end_date = datetime(int(year) + 1, 1, 1) 51 return start_date, end_date 52 53 54 class FileSystemSearcher: 55 """Handles searching the file system for modified items.""" 56 57 def find_items( 58 self, 59 windows: list[tuple[float | None, float | None]], 60 include_files: bool = False, 61 root: str = ".", 62 ) -> Iterable[tuple[int, str]]: 63 """ 64 Walks the tree once and yields (window_index, path) for items whose 65 mtime falls within one of the (start_ts, end_ts) windows. 66 """ 67 for dirpath, _, filenames in os.walk(root): 68 index = self._window_index(dirpath, windows) 69 if index is not None: 70 yield index, dirpath 71 72 if include_files: 73 for filename in filenames: 74 filepath = os.path.join(dirpath, filename) 75 index = self._window_index(filepath, windows) 76 if index is not None: 77 yield index, filepath 78 79 def _window_index( 80 self, path: str, windows: list[tuple[float | None, float | None]] 81 ) -> int | None: 82 try: 83 mtime = os.path.getmtime(path) 84 except OSError: 85 return None 86 for index, (start_ts, end_ts) in enumerate(windows): 87 if start_ts is not None and mtime <= start_ts: 88 continue 89 if end_ts is not None and mtime >= end_ts: 90 continue 91 return index 92 return None 93 94 95 class RemoteExecutor: 96 """Handles executing the script on a remote host via SSH using Paramiko.""" 97 98 SENTINEL = "::diggah-next-window::" 99 100 def execute( 101 self, 102 host_str: str, 103 path: str, 104 windows: list[tuple[datetime | None, datetime | None]], 105 include_files: bool, 106 port: int | None = None, 107 ) -> Iterable[tuple[int, str]]: 108 """ 109 Executes a single fd command chain on the remote host over one SSH 110 connection and yields (window_index, path) results. 111 """ 112 username = None 113 hostname = host_str 114 if "@" in host_str: 115 username, hostname = host_str.split("@", 1) 116 117 if ":" in hostname: 118 host_part, _, port_part = hostname.rpartition(":") 119 if not port_part.isdigit(): 120 raise ValueError(f"invalid port '{port_part}' in host '{host_str}'") 121 hostname = host_part 122 if port is None: 123 port = int(port_part) 124 125 # Load SSH config 126 ssh_config = paramiko.SSHConfig() 127 user_config_file = os.path.expanduser("~/.ssh/config") 128 if os.path.exists(user_config_file): 129 with open(user_config_file) as f: 130 ssh_config.parse(f) 131 132 user_config = ssh_config.lookup(hostname) 133 134 if "hostname" in user_config: 135 hostname = user_config["hostname"] 136 137 if username is None and "user" in user_config: 138 username = user_config["user"] 139 140 if port is None: 141 port = int(user_config.get("port", 22)) 142 key_filename = user_config.get("identityfile") 143 144 commands: list[str] = [] 145 for start_dt, end_dt in windows: 146 cmd_parts = ["fd", "--hidden", "--no-ignore"] 147 148 if not include_files: 149 cmd_parts.extend(["--type", "d"]) 150 151 if start_dt: 152 start_str = start_dt.strftime("%Y-%m-%d %H:%M:%S") 153 cmd_parts.extend(["--changed-after", f"'{start_str}'"]) 154 155 if end_dt: 156 end_str = end_dt.strftime("%Y-%m-%d %H:%M:%S") 157 cmd_parts.extend(["--changed-before", f"'{end_str}'"]) 158 159 cmd_parts.extend([".", path]) 160 commands.append(" ".join(cmd_parts)) 161 162 cmd = f"; echo '{self.SENTINEL}'; ".join(commands) 163 164 client = paramiko.SSHClient() 165 client.load_system_host_keys() 166 client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 167 168 try: 169 sock = None 170 if "proxycommand" in user_config: 171 sock = paramiko.ProxyCommand(user_config["proxycommand"]) 172 173 client.connect( 174 hostname=hostname, 175 username=username, 176 port=port, 177 key_filename=key_filename, 178 sock=sock, 179 ) 180 181 stdin, stdout, stderr = client.exec_command(cmd) 182 stdin.close() 183 184 # Read stderr first/async or just handle stdout for now. 185 index = 0 186 for line in cast(Iterable[str], stdout): 187 line_str = str(line).strip() 188 if line_str == self.SENTINEL: 189 index += 1 190 elif line_str: 191 yield index, line_str 192 193 for line in cast(Iterable[str], stderr): 194 print(str(line), end="", file=sys.stderr) 195 196 exit_status = stdout.channel.recv_exit_status() 197 if exit_status != 0: 198 sys.exit(exit_status) 199 200 except Exception as e: 201 print(f"({hostname}): {e}", file=sys.stderr) 202 sys.exit(1) 203 finally: 204 client.close() 205 206 207 class Diggah: 208 """ 209 Diggah: A tool to find modified files in a date range. 210 """ 211 212 def search( 213 self, 214 start_date: str | None = None, 215 end_date: str | None = None, 216 year: int | None = None, 217 month: int | None = None, 218 week: int | None = None, 219 today: bool = False, 220 files: bool = False, 221 all: bool = False, 222 path: str = ".", 223 dry_run: bool = False, 224 host: str | None = None, 225 port: int | None = None, 226 relative: bool = False, 227 output: str | bool = False, 228 ): 229 """ 230 Search for files/directories modified within a specified timeframe. 231 232 Args: 233 start_date: Start date (YYYY-MM-DD). 234 end_date: End date (YYYY-MM-DD). 235 year: Year for searching. 236 month: Month for searching (optional). 237 week: Week number for searching (optional). 238 today: If True, search the last 24 hours. 239 files: If True, include files in output (default is dirs only). 240 all: If True, show all finds regardless of time. 241 path: Directory to search in (default: "."). 242 dry_run: If True, print calculated dates and exit. 243 host: SSH host to run the command on (e.g., user@example.com 244 or user@example.com:2222). 245 port: SSH port for the remote host (overrides host:port and 246 ssh config; default 22). 247 relative: If True, output paths relative to the search root. 248 output: If True or a path, write output to file(s) instead of stdout. 249 If a path is provided, writes to that file. 250 If True (flag only), writes to default filename(s). 251 For month search with default filename, this splits output into 4 weekly files. 252 """ 253 254 ranges = self._determine_search_ranges( 255 start_date, end_date, year, month, week, today, all, output 256 ) 257 258 if not ranges: 259 fire.Fire(self.search, command=["--help"], name="diggah search") 260 return 261 262 if dry_run: 263 for start_dt, end_dt, outfile in ranges: 264 self._print_dry_run( 265 start_dt, end_dt, path, files, host, port, relative, output, outfile 266 ) 267 return 268 269 self._run_search(ranges, path, files, host, port, relative) 270 271 def _determine_search_ranges( 272 self, 273 start_date: str | None, 274 end_date: str | None, 275 year: int | None, 276 month: int | None, 277 week: int | None, 278 today: bool, 279 all_time: bool, 280 output: str | bool, 281 ) -> list[tuple[datetime | None, datetime | None, str | None]]: 282 """ 283 Determines the list of (start_dt, end_dt, outfile) tuples based on arguments. 284 """ 285 calc = DateCalculator() 286 ranges: list[tuple[datetime | None, datetime | None, str | None]] = [] 287 288 custom_outfile = os.path.expanduser(output) if isinstance(output, str) else None 289 290 if all_time: 291 if any([today, year, start_date, end_date]): 292 raise ValueError("cannot combine with other time constraints") 293 outfile = custom_outfile or ("all.txt" if output else None) 294 ranges.append((None, None, outfile)) 295 296 elif today: 297 if start_date or year: 298 raise ValueError("cannot combine with specific dates") 299 now = datetime.now() 300 start_dt = now - timedelta(days=1) 301 end_dt = now 302 outfile = custom_outfile or ( 303 f"{now.strftime('%Y-%m-%d')}.txt" if output else None 304 ) 305 ranges.append((start_dt, end_dt, outfile)) 306 307 elif year is None and month is not None: 308 # Default to current year if month is specified but year is not 309 year = datetime.now().year 310 311 if year is not None: 312 year = int(year) 313 if start_date: 314 raise ValueError("cannot combine with positional dates") 315 316 if month is None: 317 # Year only 318 start_dt, end_dt = calc.get_range_from_year(year) 319 outfile = custom_outfile or (f"{year}.txt" if output else None) 320 ranges.append((start_dt, end_dt, outfile)) 321 else: 322 month = int(month) 323 if week is not None: 324 # Specific week 325 week = int(week) 326 start_dt, end_dt = calc.get_range_from_week(year, month, week) 327 outfile = custom_outfile or ( 328 f"{week}_{month:02d}_{year}.txt" if output else None 329 ) 330 ranges.append((start_dt, end_dt, outfile)) 331 else: 332 # Whole month 333 if custom_outfile: 334 start_dt, end_dt = calc.get_range_from_month(year, month) 335 ranges.append((start_dt, end_dt, custom_outfile)) 336 elif output: 337 # Split by week if writing to default files 338 for i in range(1, 5): 339 s, e = calc.get_range_from_week(year, month, i) 340 outfile = f"{i}_{month:02d}_{year}.txt" 341 ranges.append((s, e, outfile)) 342 else: 343 start_dt, end_dt = calc.get_range_from_month(year, month) 344 outfile = None 345 ranges.append((start_dt, end_dt, outfile)) 346 347 elif start_date and end_date: 348 start_dt = calc.validate_date(start_date) 349 end_dt = calc.validate_date(end_date) 350 outfile = custom_outfile or ( 351 f"{start_date}_{end_date}.txt" if output else None 352 ) 353 ranges.append((start_dt, end_dt, outfile)) 354 355 return ranges 356 357 def _print_dry_run( 358 self, 359 start_dt: datetime | None, 360 end_dt: datetime | None, 361 path: str, 362 include_files: bool, 363 host: str | None, 364 port: int | None, 365 relative: bool, 366 output: str | bool, 367 outfile: str | None, 368 ): 369 print(f"Start: {start_dt if start_dt else 'None (All Time)'}") 370 print(f"End: {end_dt if end_dt else 'None (All Time)'}") 371 print(f"Path: {path}") 372 print(f"Include Files: {include_files}") 373 if host: 374 print(f"Host: {host}") 375 if port: 376 print(f"Port: {port}") 377 print("Mode: Remote (fd command)") 378 else: 379 print("Mode: Local (python walk)") 380 if relative: 381 print("Output: Relative paths") 382 if output: 383 print(f"Write to: {outfile}") 384 385 def _run_search( 386 self, 387 ranges: list[tuple[datetime | None, datetime | None, str | None]], 388 path: str, 389 include_files: bool, 390 host: str | None, 391 port: int | None, 392 relative: bool, 393 ): 394 results: Iterable[tuple[int, str]] 395 if host: 396 windows = [(start_dt, end_dt) for start_dt, end_dt, _ in ranges] 397 results = RemoteExecutor().execute(host, path, windows, include_files, port) 398 else: 399 ts_windows = [ 400 ( 401 start_dt.timestamp() if start_dt else None, 402 end_dt.timestamp() if end_dt else None, 403 ) 404 for start_dt, end_dt, _ in ranges 405 ] 406 results = FileSystemSearcher().find_items( 407 ts_windows, 408 include_files=include_files, 409 root=path, 410 ) 411 412 sinks: list[TextIO | None] = [] 413 failed: set[int] = set() 414 for index, (_, _, outfile) in enumerate(ranges): 415 if not outfile: 416 sinks.append(None) 417 continue 418 try: 419 f = open(outfile, "w") 420 print(f"{outfile}") 421 sinks.append(f) 422 except IOError as e: 423 print(f"opening output file: {e} failed", file=sys.stderr) 424 sinks.append(None) 425 failed.add(index) 426 427 try: 428 for index, item in results: 429 if index in failed: 430 continue 431 432 if relative: 433 item = os.path.relpath(item, start=path) 434 435 f = sinks[index] 436 if f: 437 _ = f.write(item + "\n") 438 else: 439 print(item) 440 finally: 441 for f in sinks: 442 if f: 443 f.close() 444 445 446 def main(): 447 try: 448 fire.Fire(Diggah()) 449 except ValueError as e: 450 print(f"{e}", file=sys.stderr) 451 sys.exit(1) 452 453 454 if __name__ == "__main__": 455 main()