Script Python qui lit un fichier JSON généré par Exif Notes et écrit les métadonnées EXIF dans les fichiers images (TIFF, JPEG) ou crée des fichiers XMP sidecar pour les formats non supportés. Fonctionnalités : - Validation des fichiers JSON Exif Notes - Mapping complet JSON → tags EXIF standard - Écriture EXIF via exiftool avec fallback XMP - Support des formats de vitesse (1/125, 2", B/Bulb) - Support des dates ISO avec ou sans secondes - CLI avec options --dry-run, --verbose, --force-xmp Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
258 lines
8.5 KiB
Python
258 lines
8.5 KiB
Python
"""Tests pour le module exif_writer."""
|
|
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from json_to_metadata.exif_writer import (
|
|
ExiftoolError,
|
|
ExiftoolNotFoundError,
|
|
_build_exiftool_args,
|
|
_format_tag_value,
|
|
check_exiftool_available,
|
|
supports_embedded_exif,
|
|
supports_partial_exif,
|
|
write_exif_to_file,
|
|
write_exif_with_fallback,
|
|
)
|
|
|
|
|
|
class TestCheckExiftoolAvailable:
|
|
"""Tests pour la fonction check_exiftool_available."""
|
|
|
|
@patch('shutil.which')
|
|
@patch('subprocess.run')
|
|
def test_exiftool_available(self, mock_run, mock_which):
|
|
"""Retourne True si exiftool est disponible."""
|
|
mock_which.return_value = '/usr/bin/exiftool'
|
|
mock_run.return_value = MagicMock(returncode=0, stdout='12.50')
|
|
|
|
assert check_exiftool_available() is True
|
|
|
|
@patch('shutil.which')
|
|
def test_exiftool_not_found(self, mock_which):
|
|
"""Lève une exception si exiftool n'est pas trouvé."""
|
|
mock_which.return_value = None
|
|
|
|
with pytest.raises(ExiftoolNotFoundError):
|
|
check_exiftool_available()
|
|
|
|
@patch('shutil.which')
|
|
@patch('subprocess.run')
|
|
def test_exiftool_timeout(self, mock_run, mock_which):
|
|
"""Lève une exception en cas de timeout."""
|
|
mock_which.return_value = '/usr/bin/exiftool'
|
|
mock_run.side_effect = subprocess.TimeoutExpired('exiftool', 10)
|
|
|
|
with pytest.raises(ExiftoolError, match='timeout'):
|
|
check_exiftool_available()
|
|
|
|
|
|
class TestSupportsEmbeddedExif:
|
|
"""Tests pour la fonction supports_embedded_exif."""
|
|
|
|
def test_tiff_supported(self):
|
|
"""Les fichiers TIFF supportent l'EXIF intégré."""
|
|
assert supports_embedded_exif(Path('photo.tif')) is True
|
|
assert supports_embedded_exif(Path('photo.tiff')) is True
|
|
assert supports_embedded_exif(Path('photo.TIF')) is True
|
|
|
|
def test_jpeg_supported(self):
|
|
"""Les fichiers JPEG supportent l'EXIF intégré."""
|
|
assert supports_embedded_exif(Path('photo.jpg')) is True
|
|
assert supports_embedded_exif(Path('photo.jpeg')) is True
|
|
assert supports_embedded_exif(Path('photo.JPG')) is True
|
|
|
|
def test_avif_not_fully_supported(self):
|
|
"""Les fichiers AVIF n'ont pas un support complet."""
|
|
assert supports_embedded_exif(Path('photo.avif')) is False
|
|
|
|
def test_png_not_supported(self):
|
|
"""Les fichiers PNG ne supportent pas l'EXIF intégré."""
|
|
assert supports_embedded_exif(Path('photo.png')) is False
|
|
|
|
|
|
class TestSupportsPartialExif:
|
|
"""Tests pour la fonction supports_partial_exif."""
|
|
|
|
def test_avif_partial_support(self):
|
|
"""Les fichiers AVIF ont un support partiel."""
|
|
assert supports_partial_exif(Path('photo.avif')) is True
|
|
|
|
def test_heic_partial_support(self):
|
|
"""Les fichiers HEIC ont un support partiel."""
|
|
assert supports_partial_exif(Path('photo.heic')) is True
|
|
|
|
def test_webp_partial_support(self):
|
|
"""Les fichiers WebP ont un support partiel."""
|
|
assert supports_partial_exif(Path('photo.webp')) is True
|
|
|
|
def test_jpeg_full_support(self):
|
|
"""Les fichiers JPEG n'ont pas un support 'partiel'."""
|
|
assert supports_partial_exif(Path('photo.jpg')) is False
|
|
|
|
|
|
class TestBuildExiftoolArgs:
|
|
"""Tests pour la fonction _build_exiftool_args."""
|
|
|
|
def test_simple_tags(self):
|
|
"""Construit des arguments pour des tags simples."""
|
|
tags = {
|
|
'Make': 'Nikon',
|
|
'Model': 'FM2',
|
|
'ISO': 400
|
|
}
|
|
|
|
args = _build_exiftool_args(tags)
|
|
|
|
assert '-Make=Nikon' in args
|
|
assert '-Model=FM2' in args
|
|
assert '-ISO=400' in args
|
|
|
|
def test_skip_none_values(self):
|
|
"""Ignore les valeurs None."""
|
|
tags = {'Make': 'Nikon', 'Model': None}
|
|
|
|
args = _build_exiftool_args(tags)
|
|
|
|
assert '-Make=Nikon' in args
|
|
assert len([a for a in args if 'Model' in a]) == 0
|
|
|
|
def test_float_formatting(self):
|
|
"""Formate correctement les nombres flottants."""
|
|
tags = {'FNumber': 2.8, 'ExposureTime': 0.008}
|
|
|
|
args = _build_exiftool_args(tags)
|
|
|
|
assert '-FNumber=2.8' in args
|
|
assert any('ExposureTime' in a for a in args)
|
|
|
|
|
|
class TestFormatTagValue:
|
|
"""Tests pour la fonction _format_tag_value."""
|
|
|
|
def test_boolean_true(self):
|
|
"""Formate True en '1'."""
|
|
assert _format_tag_value('Flash', True) == '1'
|
|
|
|
def test_boolean_false(self):
|
|
"""Formate False en '0'."""
|
|
assert _format_tag_value('Flash', False) == '0'
|
|
|
|
def test_integer_float(self):
|
|
"""Formate les floats entiers sans décimales."""
|
|
assert _format_tag_value('ISO', 400.0) == '400'
|
|
|
|
def test_decimal_float(self):
|
|
"""Formate les floats décimaux avec précision."""
|
|
result = _format_tag_value('FNumber', 2.8)
|
|
assert '2.8' in result
|
|
|
|
def test_list_formatting(self):
|
|
"""Formate les listes en chaîne séparée par virgules."""
|
|
assert _format_tag_value('Keywords', ['a', 'b', 'c']) == 'a, b, c'
|
|
|
|
|
|
class TestWriteExifToFile:
|
|
"""Tests pour la fonction write_exif_to_file."""
|
|
|
|
def test_file_not_found(self):
|
|
"""Lève une exception si le fichier n'existe pas."""
|
|
with pytest.raises(FileNotFoundError):
|
|
write_exif_to_file(Path('/nonexistent/photo.jpg'), {'Make': 'Test'})
|
|
|
|
def test_empty_tags(self, tmp_path):
|
|
"""Retourne True pour des tags vides."""
|
|
image = tmp_path / 'photo.jpg'
|
|
image.write_bytes(b'fake image content')
|
|
|
|
result = write_exif_to_file(image, {})
|
|
|
|
assert result is True
|
|
|
|
@patch('subprocess.run')
|
|
def test_dry_run_mode(self, mock_run, tmp_path):
|
|
"""Le mode dry-run n'exécute pas exiftool."""
|
|
image = tmp_path / 'photo.jpg'
|
|
image.write_bytes(b'fake image content')
|
|
|
|
result = write_exif_to_file(image, {'Make': 'Nikon'}, dry_run=True)
|
|
|
|
assert result is True
|
|
mock_run.assert_not_called()
|
|
|
|
@patch('subprocess.run')
|
|
def test_successful_write(self, mock_run, tmp_path):
|
|
"""Écrit les métadonnées avec succès."""
|
|
image = tmp_path / 'photo.jpg'
|
|
image.write_bytes(b'fake image content')
|
|
mock_run.return_value = MagicMock(
|
|
returncode=0,
|
|
stdout='1 image files updated',
|
|
stderr=''
|
|
)
|
|
|
|
result = write_exif_to_file(image, {'Make': 'Nikon'})
|
|
|
|
assert result is True
|
|
mock_run.assert_called_once()
|
|
|
|
@patch('subprocess.run')
|
|
def test_exiftool_error(self, mock_run, tmp_path):
|
|
"""Lève une exception en cas d'erreur exiftool."""
|
|
image = tmp_path / 'photo.jpg'
|
|
image.write_bytes(b'fake image content')
|
|
mock_run.return_value = MagicMock(
|
|
returncode=1,
|
|
stdout='',
|
|
stderr='Error: Invalid file format'
|
|
)
|
|
|
|
with pytest.raises(ExiftoolError, match='Invalid file format'):
|
|
write_exif_to_file(image, {'Make': 'Nikon'})
|
|
|
|
|
|
class TestWriteExifWithFallback:
|
|
"""Tests pour la fonction write_exif_with_fallback."""
|
|
|
|
@patch('json_to_metadata.exif_writer.write_exif_to_file')
|
|
def test_jpeg_uses_exif(self, mock_write, tmp_path):
|
|
"""Les JPEG utilisent l'écriture EXIF directe."""
|
|
image = tmp_path / 'photo.jpg'
|
|
image.write_bytes(b'fake image content')
|
|
mock_write.return_value = True
|
|
mock_xmp = MagicMock()
|
|
|
|
success, method = write_exif_with_fallback(image, {'Make': 'Nikon'}, mock_xmp)
|
|
|
|
assert success is True
|
|
assert method == 'exif'
|
|
mock_write.assert_called_once()
|
|
|
|
@patch('json_to_metadata.exif_writer.write_exif_to_file')
|
|
def test_avif_fallback_to_xmp(self, mock_write, tmp_path):
|
|
"""Les AVIF utilisent XMP en fallback si EXIF échoue."""
|
|
image = tmp_path / 'photo.avif'
|
|
image.write_bytes(b'fake image content')
|
|
mock_write.side_effect = ExiftoolError('Not supported')
|
|
mock_xmp = MagicMock(return_value=image.with_suffix('.xmp'))
|
|
|
|
success, method = write_exif_with_fallback(image, {'Make': 'Nikon'}, mock_xmp)
|
|
|
|
assert success is True
|
|
assert method == 'xmp'
|
|
|
|
def test_png_uses_xmp_directly(self, tmp_path):
|
|
"""Les PNG utilisent directement XMP."""
|
|
image = tmp_path / 'photo.png'
|
|
image.write_bytes(b'fake image content')
|
|
xmp_path = image.with_suffix('.xmp')
|
|
mock_xmp = MagicMock(return_value=xmp_path)
|
|
|
|
success, method = write_exif_with_fallback(image, {'Make': 'Nikon'}, mock_xmp)
|
|
|
|
assert success is True
|
|
assert method == 'xmp'
|