Skip to content

Commit

Permalink
Remove extended attributes logic since that isn't on the file itself (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
stefmolin authored Nov 13, 2024
1 parent 1d36262 commit 9be529c
Show file tree
Hide file tree
Showing 4 changed files with 27 additions and 118 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@

# exif-stripper

An easy-to-use tool to ensure image metadata (EXIF data and extended attributes) is removed. Read more about why this is important [here](https://stefaniemolin.com/articles/devx/pre-commit/exif-stripper/).
An easy-to-use tool to ensure image EXIF metadata is removed. Read more about why this is important [here](https://stefaniemolin.com/articles/devx/pre-commit/exif-stripper/).

## Usage

Expand Down
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ requires = [

[project]
name = "exif-stripper"
description = "An easy-to-use tool to ensure image metadata (EXIF data and extended attributes) is removed."
description = "An easy-to-use tool to ensure image EXIF metadata is removed."
readme = "README.md"
keywords = [
"exif",
Expand All @@ -18,7 +18,7 @@ keywords = [
]
license = { file = "LICENSE" }
authors = [
{ name = "Stefanie Molin", email = "[email protected].com" },
{ name = "Stefanie Molin", email = "exif-stripper@stefaniemolin.com" },
]
requires-python = ">=3.8"
classifiers = [
Expand All @@ -39,7 +39,6 @@ dynamic = [

dependencies = [
"pillow>=10.3.0",
"xattr; platform_system!='Windows'",
]
optional-dependencies.dev = [
"pre-commit",
Expand Down
53 changes: 11 additions & 42 deletions src/exif_stripper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

import os
import platform
from contextlib import suppress

from PIL import Image, UnidentifiedImageError

Expand All @@ -12,7 +12,7 @@

def process_image(filename: str | os.PathLike) -> bool:
"""
Process image metadata.
Process image EXIF metadata.
Parameters
----------
Expand All @@ -24,43 +24,12 @@ def process_image(filename: str | os.PathLike) -> bool:
bool
Indicator of whether metadata was stripped.
"""
has_changed = False
try:
# remove EXIF data
with Image.open(filename) as im:
if exif := im.getexif():
exif.clear()
im.save(filename)
has_changed = True
except (FileNotFoundError, UnidentifiedImageError):
pass # not an image
else:
# remove extended attributes (Unix only)
if platform.system() != 'Windows':
import warnings

from xattr import xattr

xattr_obj = xattr(filename)
xattr_list = xattr_obj.list()

if xattr_list:
original_xattr_list = xattr_list[:]

xattr_obj.clear()

xattr_obj = xattr(filename)
xattr_list = xattr_obj.list()

has_changed |= set(xattr_list) != set(original_xattr_list)
if xattr_list:
xattr_list_str = ', '.join(xattr_list)
warnings.warn(
f'Extended attributes {xattr_list_str} in {filename} cannot be removed.',
stacklevel=2,
)

if has_changed:
print(f'Stripped metadata from {filename}')

return has_changed
with suppress(FileNotFoundError, UnidentifiedImageError), Image.open(
filename
) as image:
if exif := image.getexif():
exif.clear()
image.save(filename)
print(f'Stripped EXIF metadata from {filename}')
return True
return False
85 changes: 13 additions & 72 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,13 @@
"""Test the CLI."""

import platform
import subprocess
import sys
from contextlib import suppress
from getpass import getuser

import pytest
from PIL import Image

from exif_stripper import cli

RUNNING_ON = platform.system()
RUNNING_ON_WINDOWS = RUNNING_ON == 'Windows'
RUNNING_ON_MACOS = RUNNING_ON == 'Darwin'

if not RUNNING_ON_WINDOWS:
from xattr import xattr


@pytest.fixture
def image_with_exif_data(tmp_path):
Expand All @@ -31,74 +21,25 @@ def image_with_exif_data(tmp_path):
return image_file


@pytest.fixture
def image_with_metadata(image_with_exif_data):
"""Fixture for an image with metadata."""
if RUNNING_ON in ['Darwin', 'Linux']:
with suppress(OSError):
# OSError raised if filesystem does not support extended attributes
xattr(image_with_exif_data).set(
f'{getuser()}.test_extended_attribute'
if RUNNING_ON == 'Linux'
else 'com.apple.macl',
b'\x00',
)
return image_with_exif_data


def has_metadata(filepath, on_windows):
def has_metadata(filepath) -> bool:
"""Utility to check if a file has metadata."""
with Image.open(filepath) as im:
has_exif = dict(im.getexif()) != {}
if on_windows:
return has_exif
return bool(im.getexif())

xattr_list = xattr(filepath).list()
if RUNNING_ON_MACOS:
has_removable_xattr = any(
not attr.startswith('com.apple.') for attr in xattr_list
)
else:
has_removable_xattr = bool(xattr_list)

return has_exif or has_removable_xattr
def test_process_image_full(image_with_exif_data, monkeypatch):
"""Test that cli.process_image() removes EXIF metadata."""
assert has_metadata(image_with_exif_data)

has_changed = cli.process_image(image_with_exif_data)

def assert_metadata_stripped(filepath, on_windows=RUNNING_ON_WINDOWS):
"""Checks that a file that had metadata before no longer does."""
assert has_metadata(filepath, on_windows)

has_changed = cli.process_image(filepath)

assert not has_metadata(filepath, on_windows)
assert not has_metadata(image_with_exif_data)
assert has_changed

has_changed = cli.process_image(filepath)
has_changed = cli.process_image(image_with_exif_data)
assert not has_changed


@pytest.mark.skipif(RUNNING_ON_WINDOWS, reason='xattr does not work on Windows')
def test_process_image_full(image_with_metadata, monkeypatch, recwarn):
"""Test that cli.process_image() removes EXIF and extended attributes."""

assert_metadata_stripped(image_with_metadata)

# Unremovable attributes may not be present in all system setups.
# This is to assert the warning message if the user has such system configurations.
if recwarn:
message = str(recwarn[0].message) # pragma: no cover
assert message.startswith('Extended attributes') # pragma: no cover
assert message.endswith('cannot be removed.') # pragma: no cover


def test_process_image_exif_only(image_with_exif_data, monkeypatch):
"""Test that cli.process_image() removes EXIF only (Windows version)."""
if not RUNNING_ON_WINDOWS:
monkeypatch.setattr(platform, 'system', lambda: 'Windows')

assert_metadata_stripped(image_with_exif_data, on_windows=True)


@pytest.mark.parametrize('exists', [True, False])
def test_process_image_file_issues(tmp_path, exists):
"""Test that cli.process_image() continues if files don't exist or aren't images."""
Expand All @@ -110,16 +51,16 @@ def test_process_image_file_issues(tmp_path, exists):
assert not has_changed


def test_main(tmp_path, image_with_metadata, capsys):
def test_main(tmp_path, image_with_exif_data, capsys):
"""Test that cli.main() returns the number of files altered."""
file_without_metadata = tmp_path / 'clean.png'
file_without_metadata.touch()

files_changed = cli.main([str(file_without_metadata), str(image_with_metadata)])
files_changed = cli.main([str(file_without_metadata), str(image_with_exif_data)])

assert files_changed == 1

assert capsys.readouterr().out.strip().endswith(str(image_with_metadata))
assert capsys.readouterr().out.strip().endswith(str(image_with_exif_data))


def test_cli_version(capsys):
Expand All @@ -130,9 +71,9 @@ def test_cli_version(capsys):


@pytest.mark.parametrize(['flag', 'return_code'], [['--version', 0], ['', 1]])
def test_main_access_cli(flag, return_code, image_with_metadata):
def test_main_access_cli(flag, return_code, image_with_exif_data):
"""Confirm that CLI can be accessed via python -m."""
result = subprocess.run(
[sys.executable, '-m', 'exif_stripper.cli', flag or str(image_with_metadata)]
[sys.executable, '-m', 'exif_stripper.cli', flag or str(image_with_exif_data)]
)
assert result.returncode == return_code

0 comments on commit 9be529c

Please sign in to comment.