Initial commit: script de gestion des métadonnées EXIF pour photos argentiques

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>
This commit is contained in:
2026-02-10 16:22:12 +01:00
commit 25bd8bc961
14 changed files with 2715 additions and 0 deletions

257
tests/test_exif_writer.py Normal file
View File

@@ -0,0 +1,257 @@
"""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'