diff --git a/requirements.txt b/requirements.txt index 50e3b916..23f28d13 100644 Binary files a/requirements.txt and b/requirements.txt differ 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)}")