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

263
tests/test_json_parser.py Normal file
View File

@@ -0,0 +1,263 @@
"""Tests pour le module json_parser."""
import json
from pathlib import Path
import pytest
from json_to_metadata.json_parser import (
ValidationError,
load_json,
validate_frame,
validate_roll,
)
class TestLoadJson:
"""Tests pour la fonction load_json."""
def test_load_valid_json(self, tmp_path):
"""Charge un fichier JSON valide."""
data = {'id': 1, 'frames': []}
json_file = tmp_path / 'test.json'
json_file.write_text(json.dumps(data), encoding='utf-8')
result = load_json(json_file)
assert result == data
def test_load_json_file_not_found(self):
"""Lève une exception si le fichier n'existe pas."""
with pytest.raises(FileNotFoundError):
load_json(Path('/nonexistent/file.json'))
def test_load_invalid_json(self, tmp_path):
"""Lève une exception pour un JSON malformé."""
json_file = tmp_path / 'invalid.json'
json_file.write_text('{ invalid json }', encoding='utf-8')
with pytest.raises(json.JSONDecodeError):
load_json(json_file)
def test_load_json_with_unicode(self, tmp_path):
"""Charge un JSON avec des caractères Unicode."""
data = {'note': 'Café à Paris 🇫🇷'}
json_file = tmp_path / 'unicode.json'
json_file.write_text(json.dumps(data, ensure_ascii=False), encoding='utf-8')
result = load_json(json_file)
assert result['note'] == 'Café à Paris 🇫🇷'
class TestValidateRoll:
"""Tests pour la fonction validate_roll."""
def test_valid_roll_minimal(self):
"""Valide un roll avec les champs minimaux."""
roll = {
'id': 1,
'frames': [{'id': 1}]
}
assert validate_roll(roll) is True
def test_valid_roll_complete(self):
"""Valide un roll complet avec tous les champs optionnels."""
roll = {
'id': 'roll-001',
'frames': [
{'id': 1, 'date': '2024-03-15T14:30:00'}
],
'camera': {'make': 'Nikon', 'model': 'FM2'},
'filmStock': {'make': 'Kodak', 'model': 'Portra 400'},
'iso': 400
}
assert validate_roll(roll) is True
def test_missing_id(self):
"""Lève une exception si l'id est manquant."""
roll = {'frames': [{'id': 1}]}
with pytest.raises(ValidationError, match="'id'"):
validate_roll(roll)
def test_missing_frames(self):
"""Lève une exception si frames est manquant."""
roll = {'id': 1}
with pytest.raises(ValidationError, match="'frames'"):
validate_roll(roll)
def test_empty_frames(self):
"""Lève une exception si frames est vide."""
roll = {'id': 1, 'frames': []}
with pytest.raises(ValidationError, match="aucun frame"):
validate_roll(roll)
def test_frames_not_list(self):
"""Lève une exception si frames n'est pas une liste."""
roll = {'id': 1, 'frames': 'invalid'}
with pytest.raises(ValidationError, match="liste"):
validate_roll(roll)
def test_invalid_camera_type(self):
"""Lève une exception si camera n'est pas un dict."""
roll = {
'id': 1,
'frames': [{'id': 1}],
'camera': 'Nikon FM2'
}
with pytest.raises(ValidationError, match="dictionnaire"):
validate_roll(roll)
def test_invalid_iso_type(self):
"""Lève une exception si iso n'est pas numérique."""
roll = {
'id': 1,
'frames': [{'id': 1}],
'iso': 'four hundred'
}
with pytest.raises(ValidationError, match="numérique"):
validate_roll(roll)
def test_frame_validation_error_propagation(self):
"""Les erreurs de validation des frames sont propagées."""
roll = {
'id': 1,
'frames': [
{'id': 1},
{'note': 'missing id'} # Frame invalide
]
}
with pytest.raises(ValidationError, match="frame 2"):
validate_roll(roll)
class TestValidateFrame:
"""Tests pour la fonction validate_frame."""
def test_valid_frame_minimal(self):
"""Valide un frame avec les champs minimaux."""
frame = {'id': 1}
assert validate_frame(frame) is True
def test_valid_frame_complete(self):
"""Valide un frame complet."""
frame = {
'id': 1,
'date': '2024-03-15T14:30:00',
'aperture': 'f/2.8',
'shutter': '1/125',
'focalLength': 50,
'location': {'latitude': 48.8584, 'longitude': 2.2945},
'note': 'Tour Eiffel'
}
assert validate_frame(frame) is True
def test_missing_id(self):
"""Lève une exception si l'id est manquant."""
frame = {'date': '2024-03-15'}
with pytest.raises(ValidationError, match="'id'"):
validate_frame(frame)
def test_invalid_frame_type(self):
"""Lève une exception si le frame n'est pas un dict."""
with pytest.raises(ValidationError, match="dictionnaire"):
validate_frame("not a frame")
def test_valid_aperture_formats(self):
"""Accepte différents formats d'ouverture."""
frames = [
{'id': 1, 'aperture': 2.8},
{'id': 2, 'aperture': 'f/2.8'},
{'id': 3, 'aperture': 'f2.8'},
{'id': 4, 'aperture': '5.6'},
]
for frame in frames:
assert validate_frame(frame) is True
def test_invalid_aperture(self):
"""Lève une exception pour une ouverture invalide."""
frame = {'id': 1, 'aperture': 'wide open'}
with pytest.raises(ValidationError, match="ouverture"):
validate_frame(frame)
def test_valid_shutter_formats(self):
"""Accepte différents formats de vitesse."""
frames = [
{'id': 1, 'shutter': '1/125'},
{'id': 2, 'shutter': '1/125s'},
{'id': 3, 'shutter': '2'},
{'id': 4, 'shutter': '2s'},
{'id': 5, 'shutter': 0.5},
{'id': 6, 'shutter': '2"'}, # 2 secondes avec guillemet
{'id': 7, 'shutter': "4'"}, # 4 secondes avec apostrophe
{'id': 8, 'shutter': 'B'}, # Mode Bulb
{'id': 9, 'shutter': 'bulb'}, # Mode Bulb (minuscule)
]
for frame in frames:
assert validate_frame(frame) is True
def test_invalid_shutter(self):
"""Lève une exception pour une vitesse invalide."""
frame = {'id': 1, 'shutter': 'fast'}
with pytest.raises(ValidationError, match="vitesse"):
validate_frame(frame)
def test_valid_gps_coordinates(self):
"""Accepte des coordonnées GPS valides."""
frame = {
'id': 1,
'location': {
'latitude': 48.8584,
'longitude': 2.2945
}
}
assert validate_frame(frame) is True
def test_invalid_latitude_range(self):
"""Lève une exception pour une latitude hors limites."""
frame = {
'id': 1,
'location': {'latitude': 91.0, 'longitude': 0}
}
with pytest.raises(ValidationError, match="latitude"):
validate_frame(frame)
def test_invalid_longitude_range(self):
"""Lève une exception pour une longitude hors limites."""
frame = {
'id': 1,
'location': {'latitude': 0, 'longitude': 181.0}
}
with pytest.raises(ValidationError, match="longitude"):
validate_frame(frame)
def test_null_optional_fields(self):
"""Accepte des champs optionnels à None."""
frame = {
'id': 1,
'date': None,
'aperture': None,
'shutter': None,
'location': None
}
assert validate_frame(frame) is True