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)
This commit is contained in:
HERITAGE-XION 2026-01-05 17:15:13 +01:00
parent a517d774c2
commit fbd303f6d9
13 changed files with 365 additions and 152 deletions

Binary file not shown.

53
src/commands/read.py Normal file
View File

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

59
src/core/jpeg_metadata.py Normal file
View File

@ -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)}")

58
src/core/png_metadata.py Normal file
View File

@ -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)}")

View File

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

View File

@ -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):

View File

@ -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."
)

80
src/utils/display.py Normal file
View File

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

23
src/utils/formatter.py Normal file
View File

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

View File

@ -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}")

View File

@ -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)}")

32
src/utils/logger.py Normal file
View File

@ -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")

View File

@ -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)}")