tools

various tools I have been using throughout the years
Log | Files | Refs | README | LICENSE

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()