From fbd303f6d97cd6b3349d5867549a52297da6ecec Mon Sep 17 00:00:00 2001 From: HERITAGE-XION Date: Mon, 5 Jan 2026 17:15:13 +0100 Subject: [PATCH] metadata-scrubber-tool: implemented single file processing and recursive processing of file within a folder based on the extension provided in the -ext flag. implemented cli command for displaying metadata. implemented human readable format for display metadata value. refactored and moved png and jpg metadata processor logic to the core dir. implemented logging with -v(verbose flag) --- requirements.txt | Bin 326 -> 984 bytes src/commands/read.py | 53 ++++++++++++++++++++ src/core/jpeg_metadata.py | 59 +++++++++++++++++++++++ src/core/png_metadata.py | 58 ++++++++++++++++++++++ src/main.py | 52 ++++++++++---------- src/services/image_handler.py | 6 +-- src/services/metadata_factory.py | 26 ++++++++++ src/utils/display.py | 80 +++++++++++++++++++++++++++++++ src/utils/formatter.py | 23 +++++++++ src/utils/get_target_files.py | 5 ++ src/utils/jpeg_metadata.py | 63 ------------------------ src/utils/logger.py | 32 +++++++++++++ src/utils/png_metadata.py | 60 ----------------------- 13 files changed, 365 insertions(+), 152 deletions(-) create mode 100644 src/commands/read.py create mode 100644 src/core/jpeg_metadata.py create mode 100644 src/core/png_metadata.py create mode 100644 src/services/metadata_factory.py create mode 100644 src/utils/display.py create mode 100644 src/utils/formatter.py create mode 100644 src/utils/get_target_files.py delete mode 100644 src/utils/jpeg_metadata.py create mode 100644 src/utils/logger.py delete mode 100644 src/utils/png_metadata.py diff --git a/requirements.txt b/requirements.txt index 50e3b916229b8d0fde24d7c193b65ff22f7f73a9..23f28d13208a86700aebc2783762dcc5cb77a850 100644 GIT binary patch literal 984 zcmaJC_D_)SHVI{-OYH$3$y6F_&itERw>AuVCGzd z(z*)k+Hh7F-c?hb@F%Ms|J0=G`dibC>PJIw0LN?5=J+Jq!(U3S$g?0~>T8OV{VLOzT&g8RoylHM$4DKW{Y$e?#1uMq zt4A|)VDl}kus6RwHBNXZiaIlMBCRRa#BKFt5MRx_pEdE|7Uh!%o!Xz!J#$UUsj^Dg)$?r* ziq#Vt2BK%|b1%a8o2C2*ZmkUHo|5WmU>G@7P@U_m_@>((6O$5GIZ=ad?~P3E!m+d& q&vc?z?PIWM*QU+76R#?R>Ytn7${zl;|B;UGL=|sD7pokeEWsbw*Nkld delta 58 zcmcb?evFCf|G$Y!b`w7tOm<+DVhorZ%A`E`7L&|m5oWE)3CtRd@ssNrB?U|v^cV~o NOo5~U11|#?0{}jG4($K{ diff --git a/src/commands/read.py b/src/commands/read.py new file mode 100644 index 00000000..d200824f --- /dev/null +++ b/src/commands/read.py @@ -0,0 +1,53 @@ +import logging +from pathlib import Path + +import typer +from rich.console import Console + +from src.services.metadata_factory import MetadataFactory +from src.utils.display import print_metadata_table +from src.utils.get_target_files import get_target_files + +console = Console() +log = logging.getLogger("metadata-scrubber") + + +# fmt: off +def get_metadata( + file_path: Path = typer.Argument( + exists=True, # Must exist on the filesystem + file_okay=True, # Can be a file + dir_okay=True, # Can be a directory + readable=True, # Must be readable (permissions check) + writable=True, # Must be writable (permissions check) + resolve_path=True, # Auto-convert to absolute path + help="The path to the file you want to process", + ), + recursive: bool = typer.Option(False, "--recursive", "-r", help="Recursively process files in the specified directory."), + ext: str = typer.Option(None,"--extension", "-ext", help="The file extension to filter by. eg: jpg, png, pdf"), +): + if recursive and not ext: + raise typer.BadParameter("If you provide --recursive or -r, you must also provide --extension or -ext.") + if ext and not recursive: + raise typer.BadParameter("If you provide --extension or -ext, you must also provide --recursive or -r.") + + for file in get_target_files(file_path, ext) if recursive else [file_path]: + try: + # Get the correct object from the factory + handler = MetadataFactory.get_handler(str(file)) + + # Read + console.print(f"🔎 Processing [bold cyan]{file.name}[/bold cyan]...") + current_data = handler.read() + log.info(f"Successfully read metadata from {file_path.name}") + print_metadata_table(current_data) + + except Exception as e: + # display error in console + console.print(f"❌ [bold red]Skipped[/bold red] [cyan]{file_path.name}[/cyan]: [dim]{e}[/dim]") + + # LOG: Full technical details (Stack trace) for you to debug + + if log.isEnabledFor(logging.DEBUG): + # if verbose mode is enabled, log the traceback + log.error(f"Failed to process {file_path}", exc_info=True) diff --git a/src/core/jpeg_metadata.py b/src/core/jpeg_metadata.py new file mode 100644 index 00000000..9e382851 --- /dev/null +++ b/src/core/jpeg_metadata.py @@ -0,0 +1,59 @@ +import piexif # pyright: ignore[reportMissingTypeStubs] + +from src.utils.exceptions import MetadataNotFoundError, MetadataProcessingError + + +class JpegProcessaor: + def __init__(self): + self.tags_to_delete = [] + self.data = {} + + def get_metadata(self, img): + if "exif" not in img.info: + raise MetadataNotFoundError("No EXIF data found in the image.") + + exif_dict = piexif.load(img.info["exif"]) + for ifd, value in exif_dict.items(): + # this exclude thumbnail IFD (its the thumbnails blob data. it can be removed but the image will take a couple more seconds to load) + if not isinstance(exif_dict[ifd], dict): + continue + + # iterate through the IFD + for tag, tag_value in exif_dict[ifd].items(): + tag_info = piexif.TAGS[ifd].get(tag, {}) + + # Get the human-readable name for the tag + tag_name = ( + tag_info.get("name", "Unknown Tag") if tag_info else "Unknown Tag" + ) + + # exculudes tags that are necessary for image display integrity + if ( + tag_name == "Orientation" + or tag_name == "ColorSpace" + or tag_name == "ExifTag" + ): + continue + + # save to list and dict + self.tags_to_delete.append(tag) + self.data[tag_name] = tag_value + + return {"data": self.data, "tags_to_delete": self.tags_to_delete} + + def delete_metadata(self, img, tags_to_delete): + try: + exif_dict = piexif.load(img.info["exif"]) + for ifd, value in exif_dict.items(): + # exclude thumbnail IFD (its the thumbnails blob data so i dont wanna deal with that) + if not isinstance(exif_dict[ifd], dict): + continue + + # iterate through and delete tags + for tag in list(exif_dict[ifd]): + if tag in tags_to_delete: + del exif_dict[ifd][tag] + + return exif_dict + except Exception as e: + raise MetadataProcessingError(f"Error Processing: {str(e)}") diff --git a/src/core/png_metadata.py b/src/core/png_metadata.py new file mode 100644 index 00000000..2e0f61d0 --- /dev/null +++ b/src/core/png_metadata.py @@ -0,0 +1,58 @@ +from PIL import ExifTags + +from src.utils.exceptions import MetadataNotFoundError, MetadataProcessingError + + +class PngProcessor: + def __init__(self): + self.tags_to_delete = [] + self.data = {} + + def get_metadata(self, img): + img.load() + exif = img.getexif() + + if not exif: + raise MetadataNotFoundError("No EXIF data found in the image.") + + # iterate through the (0th) IFD + for tag, value in exif.items(): + # Get the human-readable name for the tag + tag_name = ExifTags.TAGS.get(tag, tag) + + # save to list and dict + self.tags_to_delete.append(tag) + self.data[tag_name] = value + print(f"{tag_name}: {value}") + + # iterate through the (GPS) IFD + gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo) + for tag, value in gps_ifd.items(): + # Get the human-readable name for the tag + tag_name = ExifTags.GPSTAGS.get(tag, tag) + + # save to list and dict + self.tags_to_delete.append(tag) + self.data[tag_name] = value + print(f"{tag_name}: {value}") + + return {"data": self.data, "tags_to_delete": self.tags_to_delete} + + def delete_metadata(self, img, tags_to_delete): + img.load() + exif = img.getexif() + try: + # iterate through the (0th) IFD + for tag_id, value in exif.items(): + if tag_id in tags_to_delete: + del exif[tag_id] + + # terate through the (GPS) IFD + gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo) + for tag_id, value in gps_ifd.items(): + if tag_id in tags_to_delete: + del gps_ifd[tag_id] + + return exif + gps_ifd + except Exception as e: + raise MetadataProcessingError(f"Error Processing image: {str(e)}") diff --git a/src/main.py b/src/main.py index 15a6fccd..05341901 100644 --- a/src/main.py +++ b/src/main.py @@ -1,38 +1,38 @@ +import logging + import typer -from rich.console import Console -from rich.panel import Panel + +from src.commands.read import get_metadata +from src.utils.logger import setup_logging # Initialize the app and the console -app = typer.Typer() -console = Console() +app = typer.Typer(no_args_is_help=True, pretty_exceptions_show_locals=False) +log = logging.getLogger("metadata-scrubber") -@app.command() -def hello(name: str, formal: bool = False): +# fmt: off +@app.callback() +def main( + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Show detailed debug logs for every file processed.", + ), +): """ - Say hello to a user. - If --formal is used, it greets more politely. + Metadata Scrubber Tool - Clean your images privacy data. """ - if formal: - message = ( - f"Good day to you, [bold magenta]{name}[/bold magenta]. It is a pleasure." - ) - color = "green" - else: - message = f"Yo [bold cyan]{name}[/bold cyan]! What's up?" - color = "yellow" + # Initialize the logger based on the user's flag + setup_logging(verbose) - # Use Rich to print a pretty panel instead of a boring print() - console.print(Panel(message, title="Greeting System", style=color)) - - -@app.command() -def goodbye(name: str): - """ - Say goodbye. - """ - console.print(f"[red]Goodbye, {name}![/red] 👋") + if verbose: + log.debug("🐛 Verbose mode enabled. Detailed logs active.") +# fmt: on +# register commands +app.command(name="get-metadata")(get_metadata) +# run app if __name__ == "__main__": app() diff --git a/src/services/image_handler.py b/src/services/image_handler.py index e09435f6..c54999e7 100644 --- a/src/services/image_handler.py +++ b/src/services/image_handler.py @@ -2,12 +2,12 @@ import shutil from pathlib import Path from typing import Optional -import piexif +import piexif # pyright: ignore[reportMissingTypeStubs] from PIL import Image +from src.core.jpeg_metadata import JpegProcessaor +from src.core.png_metadata import PngProcessor from src.services.metadata_handler import MetadataHandler -from src.utils.jpeg_metadata import JpegProcessaor -from src.utils.png_metadata import PngProcessor class ImageHandler(MetadataHandler): diff --git a/src/services/metadata_factory.py b/src/services/metadata_factory.py new file mode 100644 index 00000000..2f78de66 --- /dev/null +++ b/src/services/metadata_factory.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from src.services.image_handler import ImageHandler + + +class MetadataFactory: + @staticmethod + def get_handler(filepath: str): + ext = Path(filepath).suffix.lower() + if Path(filepath).is_file(): + if ext in [".jpg", ".jpeg", ".png"]: + return ImageHandler(filepath) + + # TODO: implement other handlers + # elif ext == ".pdf": + # return PDFHandler(filepath) + # elif ext == ".xlsx": + # return ExcelHandler(filepath) + # elif ext == ".pptx": + # return PowerPointHandler(filepath) + else: + raise ValueError(f"No handler defined for {ext} files.") + else: + raise ValueError( + f"{filepath} is not a file. if you want to process a directory, use the --recursive flag." + ) diff --git a/src/utils/display.py b/src/utils/display.py new file mode 100644 index 00000000..73393e5d --- /dev/null +++ b/src/utils/display.py @@ -0,0 +1,80 @@ +from typing import Any, Dict + +from rich import box +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from src.utils.formatter import clean_value + +console = Console() + + +def print_metadata_table(metadata: Dict[str, Any]): + """ + Displays metadata organized by logical groups. + """ + + # Define the groups using simple lists of keys + groups = { + "📸 Device Info": ["Make", "Model", "Software", "ExifVersion"], + "⚙️ Exposure Settings": [ + "ExposureTime", + "FNumber", + "ISOSpeedRatings", + "ShutterSpeedValue", + "ApertureValue", + "Flash", + "FocalLength", + ], + "🖼️ Image Data": [ + "ImageWidth", + "ImageLength", + "PixelXDimension", + "PixelYDimension", + "Orientation", + "ResolutionUnit", + ], + "📅 Dates": ["DateTime", "DateTimeOriginal", "DateTimeDigitized", "OffsetTime"], + } + + # Create the main table + table = Table(box=box.ROUNDED, show_header=True, header_style="bold magenta") + table.add_column("Property", style="cyan") + table.add_column("Value", style="green") + + # Track which keys we have displayed to handle the "leftovers" + displayed_keys = set() + + # Loop through the defined groups to create sections + for section_name, keys in groups.items(): + # Check if we have any data for this section + section_data = {k: metadata[k] for k in keys if k in metadata} + + if section_data: + # Add a section row (acts as a sub-header) + table.add_row(Text(section_name, style="bold yellow"), "") + + for key, val in section_data.items(): + table.add_row(f" {key}", clean_value(val)) + displayed_keys.add(key) + + # Add a blank row for spacing + table.add_section() + + # Handle "Other" (Any keys that isn't in the groups) + leftovers = { + k: v + for k, v in metadata.items() + if k not in displayed_keys and k != "JPEGInterchangeFormat" + } # skip binary blobs + if leftovers: + table.add_row(Text("📝 Other", style="bold yellow"), "") + for key, val in leftovers.items(): + table.add_row(f" {key}", clean_value(val)) + + # Print nicely inside a panel + console.print( + Panel(table, title="Metadata Report", border_style="blue", expand=False) + ) diff --git a/src/utils/formatter.py b/src/utils/formatter.py new file mode 100644 index 00000000..4a16a5c7 --- /dev/null +++ b/src/utils/formatter.py @@ -0,0 +1,23 @@ +from typing import Any + + +def clean_value(value: Any) -> str: + """ + Helper to make raw EXIF data human-readable. + """ + # Decode bytes (e.g., b'samsung' -> 'samsung') + if isinstance(value, bytes): + try: + return value.decode("utf-8").strip() + except UnicodeDecodeError: + return str(value) + + # Format Tuples (e.g., (1, 50) -> '1/50') + if isinstance(value, tuple) or isinstance(value, list): + return "/".join(map(str, value)) + + # Handle empty values + if value == "": + return "-" + + return str(value) diff --git a/src/utils/get_target_files.py b/src/utils/get_target_files.py new file mode 100644 index 00000000..849773e2 --- /dev/null +++ b/src/utils/get_target_files.py @@ -0,0 +1,5 @@ +def get_target_files(input_path_str, ext: str): + """Yields a list of files to process based on the input path. handles recursive searches""" + # if input_path_str is a directory, yield all files with the specified extension + if input_path_str.is_dir(): + yield from input_path_str.rglob(f"*.{ext}") diff --git a/src/utils/jpeg_metadata.py b/src/utils/jpeg_metadata.py deleted file mode 100644 index ed005c2d..00000000 --- a/src/utils/jpeg_metadata.py +++ /dev/null @@ -1,63 +0,0 @@ -import piexif # pyright: ignore[reportMissingTypeStubs] - -from src.utils.exceptions import MetadataNotFoundError, MetadataProcessingError - - -class JpegProcessaor: - def __init__(self): - self.tags_to_delete = [] - self.data = {} - - def get_metadata(self, img): - try: - if "exif" in img.info: - exif_dict = piexif.load(img.info["exif"]) - for ifd, value in exif_dict.items(): - # this exclude thumbnail IFD (its the thumbnails blob data. it can be removed but the image will take a couple more seconds to load) - if not isinstance(exif_dict[ifd], dict): - continue - - # iterate through the IFD - for tag, tag_value in exif_dict[ifd].items(): - tag_info = piexif.TAGS[ifd].get(tag, {}) - - # Get the human-readable name for the tag - tag_name = ( - tag_info.get("name", "Unknown Tag") - if tag_info - else "Unknown Tag" - ) - - # exculudes tags that are necessary for image display integrity - if ( - tag_name == "Orientation" - or tag_name == "ColorSpace" - or tag_name == "ExifTag" - ): - continue - - # save to list and dict - self.tags_to_delete.append(tag) - self.data[tag_name] = tag_value - return {"data": self.data, "tags_to_delete": self.tags_to_delete} - else: - raise MetadataNotFoundError("No EXIF data found in the image.") - except MetadataNotFoundError as e: - return f"Error: {str(e)}" - - def delete_metadata(self, img, tags_to_delete): - try: - exif_dict = piexif.load(img.info["exif"]) - for ifd, value in exif_dict.items(): - # exclude thumbnail IFD (its the thumbnails blob data so i dont wanna deal with that) - if not isinstance(exif_dict[ifd], dict): - continue - - # iterate through and delete tags - for tag in list(exif_dict[ifd]): - if tag in tags_to_delete: - del exif_dict[ifd][tag] - - return exif_dict - except Exception as e: - raise MetadataProcessingError(f"Error Processing: {str(e)}") diff --git a/src/utils/logger.py b/src/utils/logger.py new file mode 100644 index 00000000..3ee3bd05 --- /dev/null +++ b/src/utils/logger.py @@ -0,0 +1,32 @@ +import logging + +from rich.logging import RichHandler + + +def setup_logging(verbose: bool = False): + """ + - Default (Info): Shows main steps and errors. + - Verbose (Debug): Shows file paths, extraction details, and raw values. + """ + # Define the log level + level = logging.DEBUG if verbose else logging.INFO + + # Configure the logger + # remove existing handlers to avoid duplicate lines if the app restarts + logging.getLogger().handlers.clear() + + logging.basicConfig( + level=level, + format="%(message)s", + datefmt="[%X]", + handlers=[ + RichHandler( + rich_tracebacks=True, # Beautiful colorful stack traces + markup=True, # Allow [bold red] styles in logs + show_path=False, # Hide line number (cleaner for CLI tools) + ) + ], + ) + + # Return the logger instance + return logging.getLogger("metadata-scrubber") diff --git a/src/utils/png_metadata.py b/src/utils/png_metadata.py deleted file mode 100644 index 1d88e0b3..00000000 --- a/src/utils/png_metadata.py +++ /dev/null @@ -1,60 +0,0 @@ -from PIL import ExifTags - -from src.utils.exceptions import MetadataNotFoundError, MetadataProcessingError - - -class PngProcessor: - def __init__(self): - self.tags_to_delete = [] - self.data = {} - - def get_metadata(self, img): - img.load() - exif = img.getexif() - try: - if exif: - # iterate through the (0th) IFD - for tag, value in exif.items(): - # Get the human-readable name for the tag - tag_name = ExifTags.TAGS.get(tag, tag) - - # save to list and dict - self.tags_to_delete.append(tag) - self.data[tag_name] = value - print(f"{tag_name}: {value}") - - # terate through the (GPS) IFD - gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo) - for tag, value in gps_ifd.items(): - # Get the human-readable name for the tag - tag_name = ExifTags.GPSTAGS.get(tag, tag) - - # save to list and dict - self.tags_to_delete.append(tag) - self.data[tag_name] = value - print(f"{tag_name}: {value}") - - return {"data": self.data, "tags_to_delete": self.tags_to_delete} - else: - raise MetadataNotFoundError("No EXIF data found in the image.") - except MetadataNotFoundError as e: - return f"Error: {str(e)}" - - def delete_metadata(self, img, tags_to_delete): - img.load() - exif = img.getexif() - try: - # iterate through the (0th) IFD - for tag_id, value in exif.items(): - if tag_id in tags_to_delete: - del exif[tag_id] - - # terate through the (GPS) IFD - gps_ifd = exif.get_ifd(ExifTags.IFD.GPSInfo) - for tag_id, value in gps_ifd.items(): - if tag_id in tags_to_delete: - del gps_ifd[tag_id] - - return exif + gps_ifd - except Exception as e: - raise MetadataProcessingError(f"Error Processing image: {str(e)}")