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

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
.venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Testing
.pytest_cache/
.coverage
htmlcov/
# OS
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,8 @@
"""
json-to-metadata : Script de gestion des métadonnées EXIF pour photos argentiques.
Ce package permet de lire un fichier JSON généré par Exif Notes et d'écrire
les métadonnées EXIF dans les fichiers images correspondants.
"""
__version__ = "0.1.0"

323
json_to_metadata/cli.py Normal file
View File

@@ -0,0 +1,323 @@
"""
Interface en ligne de commande pour json-to-metadata.
Ce module fournit le point d'entrée CLI pour le script de gestion
des métadonnées EXIF.
"""
import argparse
import logging
import sys
from pathlib import Path
from . import __version__
from .exif_writer import (
ExiftoolError,
ExiftoolNotFoundError,
check_exiftool_available,
write_exif_with_fallback,
)
from .json_parser import ValidationError, load_json, validate_roll
from .metadata_mapper import map_frame_to_exif
from .xmp_writer import write_xmp_sidecar
# Configuration du logging
logger = logging.getLogger('json_to_metadata')
def setup_logging(verbose: bool = False) -> None:
"""
Configure le système de logging.
Args:
verbose: Si True, active le niveau DEBUG.
"""
level = logging.DEBUG if verbose else logging.INFO
format_str = '%(levelname)s: %(message)s'
if verbose:
format_str = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
logging.basicConfig(
level=level,
format=format_str,
handlers=[logging.StreamHandler(sys.stderr)]
)
def find_image_for_frame(
frame: dict,
directory: Path,
roll_id: str | int
) -> Path | None:
"""
Recherche le fichier image correspondant à un frame.
Essaie plusieurs conventions de nommage courantes.
Args:
frame: Dictionnaire du frame.
directory: Répertoire contenant les images.
roll_id: Identifiant du roll.
Returns:
Chemin vers l'image trouvée, ou None si non trouvée.
"""
frame_id = frame.get('id', '')
# Extensions d'images supportées
extensions = ['.tif', '.tiff', '.jpg', '.jpeg', '.avif', '.heic', '.webp', '.png']
# Patterns de noms à essayer
patterns = [
f"{frame_id}", # 1, 2, 3...
f"{frame_id:02d}" if isinstance(frame_id, int) else frame_id, # 01, 02...
f"{frame_id:03d}" if isinstance(frame_id, int) else frame_id, # 001, 002...
f"frame_{frame_id}", # frame_1, frame_2...
f"frame{frame_id}", # frame1, frame2...
f"{roll_id}_{frame_id}", # roll_frame
f"img_{frame_id}", # img_1, img_2...
f"IMG_{frame_id}", # IMG_1, IMG_2...
]
for pattern in patterns:
for ext in extensions:
# Essayer en minuscule et majuscule
for name in [f"{pattern}{ext}", f"{pattern}{ext.upper()}"]:
filepath = directory / name
if filepath.exists():
return filepath
return None
def process_roll(
roll: dict,
image_dir: Path,
dry_run: bool = False,
force_xmp: bool = False
) -> tuple[int, int, int]:
"""
Traite un roll complet : valide et écrit les métadonnées pour chaque frame.
Args:
roll: Dictionnaire du roll validé.
image_dir: Répertoire contenant les images.
dry_run: Mode simulation.
force_xmp: Force la création de fichiers XMP.
Returns:
Tuple (succès, échecs, images non trouvées).
"""
success_count = 0
failure_count = 0
not_found_count = 0
roll_id = roll.get('id', 'unknown')
frames = roll.get('frames', [])
logger.info(f"Traitement du roll '{roll_id}' : {len(frames)} frames")
for frame in frames:
frame_id = frame.get('id', '?')
# Rechercher l'image
image_path = find_image_for_frame(frame, image_dir, roll_id)
if image_path is None:
logger.warning(f"Image non trouvée pour le frame {frame_id}")
not_found_count += 1
continue
logger.debug(f"Image trouvée : {image_path.name}")
# Mapper les métadonnées
try:
tags = map_frame_to_exif(frame, roll)
except Exception as e:
logger.error(f"Erreur de mapping pour le frame {frame_id} : {e}")
failure_count += 1
continue
if not tags:
logger.warning(f"Aucune métadonnée à écrire pour le frame {frame_id}")
continue
# Écrire les métadonnées
try:
if force_xmp:
xmp_path = write_xmp_sidecar(image_path, tags, dry_run)
if xmp_path:
success_count += 1
logger.info(f"Frame {frame_id} : XMP créé ({image_path.name})")
else:
success, method = write_exif_with_fallback(
image_path,
tags,
lambda p, t, d: write_xmp_sidecar(p, t, d),
dry_run
)
if success:
success_count += 1
logger.info(f"Frame {frame_id} : métadonnées écrites via {method}")
else:
failure_count += 1
logger.error(f"Frame {frame_id} : échec de l'écriture")
except ExiftoolError as e:
logger.error(f"Erreur exiftool pour le frame {frame_id} : {e}")
failure_count += 1
except IOError as e:
logger.error(f"Erreur I/O pour le frame {frame_id} : {e}")
failure_count += 1
return success_count, failure_count, not_found_count
def main(args: list[str] | None = None) -> int:
"""
Point d'entrée principal du CLI.
Args:
args: Arguments de ligne de commande (utilise sys.argv si None).
Returns:
Code de retour (0 = succès, 1 = erreur).
"""
parser = argparse.ArgumentParser(
prog='json-to-metadata',
description='Écrit les métadonnées EXIF depuis un fichier JSON Exif Notes.',
epilog='Exemple : python -m json_to_metadata roll.json -d ./images'
)
parser.add_argument(
'json_file',
type=Path,
help='Chemin vers le fichier JSON Exif Notes'
)
parser.add_argument(
'-d', '--directory',
type=Path,
default=None,
help='Répertoire contenant les images (défaut : même répertoire que le JSON)'
)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Mode verbeux (niveau DEBUG)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Affiche les actions sans les exécuter'
)
parser.add_argument(
'--force-xmp',
action='store_true',
help='Force la création de fichiers XMP même pour les formats supportant EXIF'
)
parser.add_argument(
'--version',
action='version',
version=f'%(prog)s {__version__}'
)
parsed_args = parser.parse_args(args)
# Configuration du logging
setup_logging(parsed_args.verbose)
# Vérification de exiftool (sauf si force-xmp)
if not parsed_args.force_xmp:
try:
check_exiftool_available()
except ExiftoolNotFoundError as e:
logger.error(str(e))
return 1
# Chargement et validation du JSON
try:
data = load_json(parsed_args.json_file)
except FileNotFoundError as e:
logger.error(str(e))
return 1
except Exception as e:
logger.error(f"Erreur lors du chargement du JSON : {e}")
return 1
# Le JSON peut contenir un roll unique ou une liste de rolls
if isinstance(data, list):
rolls = data
elif isinstance(data, dict):
# Si c'est un objet avec une clé 'rolls', l'utiliser
if 'rolls' in data:
rolls = data['rolls']
else:
# Sinon, c'est un roll unique
rolls = [data]
else:
logger.error("Format JSON non reconnu : attendu un objet ou une liste")
return 1
# Validation des rolls
valid_rolls = []
for i, roll in enumerate(rolls):
try:
validate_roll(roll)
valid_rolls.append(roll)
except ValidationError as e:
logger.error(f"Roll {i + 1} invalide : {e}")
if not valid_rolls:
logger.error("Aucun roll valide trouvé dans le fichier JSON")
return 1
# Déterminer le répertoire des images
if parsed_args.directory:
image_dir = parsed_args.directory
else:
image_dir = parsed_args.json_file.parent
if not image_dir.is_dir():
logger.error(f"Répertoire d'images invalide : {image_dir}")
return 1
logger.info(f"Répertoire des images : {image_dir}")
if parsed_args.dry_run:
logger.info("=== MODE DRY-RUN : aucune modification ne sera effectuée ===")
# Traitement de chaque roll
total_success = 0
total_failure = 0
total_not_found = 0
for roll in valid_rolls:
success, failure, not_found = process_roll(
roll,
image_dir,
dry_run=parsed_args.dry_run,
force_xmp=parsed_args.force_xmp
)
total_success += success
total_failure += failure
total_not_found += not_found
# Résumé
logger.info("=" * 50)
logger.info(f"Résumé : {total_success} succès, {total_failure} échecs, "
f"{total_not_found} images non trouvées")
if total_failure > 0:
return 1
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,320 @@
"""
Module d'écriture des métadonnées EXIF via exiftool.
Ce module gère l'écriture des tags EXIF dans les fichiers images
en utilisant l'outil en ligne de commande exiftool.
"""
import logging
import shutil
import subprocess
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Extensions supportant l'EXIF intégré
EMBEDDED_EXIF_EXTENSIONS = {'.tif', '.tiff', '.jpg', '.jpeg'}
# Extensions avec support partiel (tentative d'intégration)
PARTIAL_SUPPORT_EXTENSIONS = {'.avif', '.heic', '.heif', '.webp'}
# Extensions nécessitant obligatoirement un fichier XMP sidecar
XMP_ONLY_EXTENSIONS = {'.dng', '.cr2', '.cr3', '.nef', '.arw', '.orf', '.rw2', '.pef', '.raw'}
class ExiftoolError(Exception):
"""Exception levée lors d'une erreur exiftool."""
pass
class ExiftoolNotFoundError(ExiftoolError):
"""Exception levée quand exiftool n'est pas installé."""
pass
def check_exiftool_available() -> bool:
"""
Vérifie que exiftool est installé et accessible.
Returns:
True si exiftool est disponible.
Raises:
ExiftoolNotFoundError: Si exiftool n'est pas trouvé.
"""
if shutil.which('exiftool') is None:
raise ExiftoolNotFoundError(
"exiftool n'est pas installé ou n'est pas dans le PATH.\n"
"Installation :\n"
" - Arch Linux : sudo pacman -S perl-image-exiftool\n"
" - Ubuntu/Debian : sudo apt install libimage-exiftool-perl\n"
" - macOS : brew install exiftool\n"
" - Windows : https://exiftool.org/"
)
# Vérifier que ça fonctionne
try:
result = subprocess.run(
['exiftool', '-ver'],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
version = result.stdout.strip()
logger.debug(f"exiftool version {version} détecté")
return True
except subprocess.TimeoutExpired:
raise ExiftoolError("exiftool ne répond pas (timeout)")
except Exception as e:
raise ExiftoolError(f"Erreur lors de la vérification de exiftool : {e}")
return False
def supports_embedded_exif(filepath: Path) -> bool:
"""
Détermine si le format du fichier supporte les métadonnées EXIF intégrées.
Args:
filepath: Chemin vers le fichier image.
Returns:
True si le format supporte l'EXIF intégré, False sinon.
"""
ext = filepath.suffix.lower()
return ext in EMBEDDED_EXIF_EXTENSIONS
def supports_partial_exif(filepath: Path) -> bool:
"""
Détermine si le format a un support partiel des métadonnées.
Ces formats peuvent accepter les métadonnées mais avec des limitations.
Args:
filepath: Chemin vers le fichier image.
Returns:
True si le format a un support partiel.
"""
ext = filepath.suffix.lower()
return ext in PARTIAL_SUPPORT_EXTENSIONS
def write_exif_to_file(filepath: Path, tags: dict, dry_run: bool = False) -> bool:
"""
Écrit les tags EXIF dans un fichier image via exiftool.
Args:
filepath: Chemin vers le fichier image.
tags: Dictionnaire de tags EXIF à écrire.
dry_run: Si True, affiche les commandes sans les exécuter.
Returns:
True si l'écriture a réussi.
Raises:
ExiftoolError: Si l'écriture échoue.
FileNotFoundError: Si le fichier n'existe pas.
"""
filepath = Path(filepath)
if not filepath.exists():
raise FileNotFoundError(f"Fichier image introuvable : {filepath}")
if not tags:
logger.warning(f"Aucun tag à écrire pour {filepath}")
return True
# Construire les arguments exiftool
args = _build_exiftool_args(tags)
# Ajouter les options de base
cmd = [
'exiftool',
'-overwrite_original', # Ne pas créer de backup
'-charset', 'utf8', # Support UTF-8
]
cmd.extend(args)
cmd.append(str(filepath))
logger.debug(f"Commande exiftool : {' '.join(cmd)}")
if dry_run:
logger.info(f"[DRY-RUN] exiftool sur {filepath.name} avec {len(tags)} tags")
return True
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60
)
if result.returncode != 0:
error_msg = result.stderr.strip() or result.stdout.strip()
raise ExiftoolError(f"Échec de l'écriture EXIF : {error_msg}")
# Vérifier le message de succès
if '1 image files updated' in result.stdout:
logger.info(f"Métadonnées écrites avec succès dans {filepath.name}")
return True
elif 'Warning' in result.stderr:
logger.warning(f"Avertissements lors de l'écriture : {result.stderr}")
return True
else:
logger.debug(f"Sortie exiftool : {result.stdout}")
return True
except subprocess.TimeoutExpired:
raise ExiftoolError(f"Timeout lors de l'écriture dans {filepath}")
except Exception as e:
raise ExiftoolError(f"Erreur lors de l'écriture EXIF : {e}")
def write_exif_with_fallback(
filepath: Path,
tags: dict,
xmp_writer_func: callable,
dry_run: bool = False
) -> tuple[bool, str]:
"""
Tente d'écrire les métadonnées EXIF avec fallback sur XMP sidecar.
Pour les formats avec support partiel (AVIF, HEIC, WebP), tente
d'abord l'écriture directe, puis crée un XMP sidecar en cas d'échec.
Args:
filepath: Chemin vers le fichier image.
tags: Dictionnaire de tags EXIF.
xmp_writer_func: Fonction pour écrire le fichier XMP sidecar.
dry_run: Mode simulation.
Returns:
Tuple (succès, méthode utilisée) où méthode est 'exif' ou 'xmp'.
"""
filepath = Path(filepath)
# Formats supportant pleinement l'EXIF intégré
if supports_embedded_exif(filepath):
success = write_exif_to_file(filepath, tags, dry_run)
return (success, 'exif')
# Formats avec support partiel : tentative puis fallback
if supports_partial_exif(filepath):
try:
success = write_exif_to_file(filepath, tags, dry_run)
if success:
return (True, 'exif')
except ExiftoolError as e:
logger.warning(
f"Échec de l'écriture EXIF pour {filepath.name} : {e}. "
f"Création d'un fichier XMP sidecar."
)
# Fallback : création du fichier XMP sidecar
try:
xmp_path = xmp_writer_func(filepath, tags, dry_run)
if xmp_path:
return (True, 'xmp')
except Exception as e:
logger.error(f"Échec de la création du XMP sidecar : {e}")
return (False, 'none')
return (False, 'none')
def _build_exiftool_args(tags: dict) -> list[str]:
"""
Construit les arguments de ligne de commande pour exiftool.
Args:
tags: Dictionnaire de tags EXIF.
Returns:
Liste d'arguments pour exiftool.
"""
args = []
for tag, value in tags.items():
if value is None:
continue
# Formatage de la valeur selon le type
formatted_value = _format_tag_value(tag, value)
# Construction de l'argument
args.append(f'-{tag}={formatted_value}')
return args
def _format_tag_value(tag: str, value: Any) -> str:
"""
Formate une valeur de tag pour exiftool.
Args:
tag: Nom du tag.
value: Valeur à formater.
Returns:
Valeur formatée en chaîne.
"""
# Gestion des types spéciaux
if isinstance(value, bool):
return '1' if value else '0'
if isinstance(value, float):
# Garder une précision raisonnable
if value == int(value):
return str(int(value))
return f'{value:.6g}'
if isinstance(value, (list, tuple)):
return ', '.join(str(v) for v in value)
return str(value)
def read_exif_from_file(filepath: Path) -> dict:
"""
Lit les métadonnées EXIF d'un fichier image.
Utile pour vérifier les métadonnées après écriture.
Args:
filepath: Chemin vers le fichier image.
Returns:
Dictionnaire des tags EXIF.
Raises:
ExiftoolError: Si la lecture échoue.
"""
filepath = Path(filepath)
if not filepath.exists():
raise FileNotFoundError(f"Fichier image introuvable : {filepath}")
try:
result = subprocess.run(
['exiftool', '-json', '-charset', 'utf8', str(filepath)],
capture_output=True,
text=True,
timeout=30
)
if result.returncode != 0:
raise ExiftoolError(f"Échec de la lecture EXIF : {result.stderr}")
import json
data = json.loads(result.stdout)
return data[0] if data else {}
except subprocess.TimeoutExpired:
raise ExiftoolError(f"Timeout lors de la lecture de {filepath}")
except json.JSONDecodeError as e:
raise ExiftoolError(f"Erreur de parsing JSON : {e}")

View File

@@ -0,0 +1,261 @@
"""
Module de lecture et validation des fichiers JSON Exif Notes.
Ce module fournit les fonctions nécessaires pour charger et valider
les fichiers JSON exportés par l'application Exif Notes.
"""
import json
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class ValidationError(Exception):
"""Exception levée lors d'une erreur de validation du JSON."""
pass
def load_json(filepath: Path) -> dict:
"""
Charge et parse un fichier JSON.
Args:
filepath: Chemin vers le fichier JSON à charger.
Returns:
Le contenu du fichier JSON sous forme de dictionnaire.
Raises:
FileNotFoundError: Si le fichier n'existe pas.
json.JSONDecodeError: Si le fichier n'est pas un JSON valide.
"""
filepath = Path(filepath)
if not filepath.exists():
raise FileNotFoundError(f"Fichier JSON introuvable : {filepath}")
logger.info(f"Chargement du fichier JSON : {filepath}")
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
logger.debug(f"JSON chargé avec succès : {len(str(data))} caractères")
return data
def validate_roll(data: dict) -> bool:
"""
Valide la structure d'un roll (pellicule) dans le JSON.
Vérifie la présence des champs obligatoires et leur type.
Args:
data: Dictionnaire représentant un roll.
Returns:
True si le roll est valide.
Raises:
ValidationError: Si la validation échoue.
"""
required_fields = ['id', 'frames']
for field in required_fields:
if field not in data:
raise ValidationError(f"Champ obligatoire manquant dans le roll : '{field}'")
# Validation de l'ID
if not isinstance(data['id'], (int, str)):
raise ValidationError(f"Le champ 'id' doit être un entier ou une chaîne, "
f"reçu : {type(data['id']).__name__}")
# Validation des frames
if not isinstance(data['frames'], list):
raise ValidationError(f"Le champ 'frames' doit être une liste, "
f"reçu : {type(data['frames']).__name__}")
if len(data['frames']) == 0:
raise ValidationError("Le roll ne contient aucun frame")
# Validation de la caméra (optionnel mais recommandé)
if 'camera' in data and data['camera'] is not None:
_validate_camera(data['camera'])
# Validation du film stock (optionnel)
if 'filmStock' in data and data['filmStock'] is not None:
_validate_film_stock(data['filmStock'])
# Validation de l'ISO (optionnel)
if 'iso' in data and data['iso'] is not None:
if not isinstance(data['iso'], (int, float)):
raise ValidationError(f"Le champ 'iso' doit être numérique, "
f"reçu : {type(data['iso']).__name__}")
# Validation de chaque frame
for i, frame in enumerate(data['frames']):
try:
validate_frame(frame)
except ValidationError as e:
raise ValidationError(f"Erreur dans le frame {i + 1} : {e}")
logger.info(f"Roll validé avec succès : {len(data['frames'])} frames")
return True
def validate_frame(frame: dict) -> bool:
"""
Valide un frame individuel.
Vérifie la présence des champs obligatoires et le format des données.
Args:
frame: Dictionnaire représentant un frame.
Returns:
True si le frame est valide.
Raises:
ValidationError: Si la validation échoue.
"""
if not isinstance(frame, dict):
raise ValidationError(f"Le frame doit être un dictionnaire, "
f"reçu : {type(frame).__name__}")
# Champs obligatoires
required_fields = ['id']
for field in required_fields:
if field not in frame:
raise ValidationError(f"Champ obligatoire manquant : '{field}'")
# Validation de l'ID du frame
if not isinstance(frame['id'], (int, str)):
raise ValidationError("Le champ 'id' doit être un entier ou une chaîne")
# Validation de la date (optionnel mais important)
if 'date' in frame and frame['date'] is not None:
_validate_date(frame['date'])
# Validation des coordonnées GPS (optionnel)
if 'location' in frame and frame['location'] is not None:
_validate_location(frame['location'])
# Validation de l'ouverture (optionnel)
if 'aperture' in frame and frame['aperture'] is not None:
_validate_aperture(frame['aperture'])
# Validation de la vitesse d'obturation (optionnel)
if 'shutter' in frame and frame['shutter'] is not None:
_validate_shutter(frame['shutter'])
# Validation de la focale (optionnel)
if 'focalLength' in frame and frame['focalLength'] is not None:
if not isinstance(frame['focalLength'], (int, float)):
raise ValidationError("Le champ 'focalLength' doit être numérique")
return True
def _validate_camera(camera: Any) -> None:
"""Valide les données de l'appareil photo."""
if not isinstance(camera, dict):
raise ValidationError("Le champ 'camera' doit être un dictionnaire")
# Make et model sont optionnels mais doivent être des chaînes si présents
if 'make' in camera and camera['make'] is not None:
if not isinstance(camera['make'], str):
raise ValidationError("camera.make doit être une chaîne")
if 'model' in camera and camera['model'] is not None:
if not isinstance(camera['model'], str):
raise ValidationError("camera.model doit être une chaîne")
def _validate_film_stock(film_stock: Any) -> None:
"""Valide les données du film."""
if not isinstance(film_stock, dict):
raise ValidationError("Le champ 'filmStock' doit être un dictionnaire")
if 'make' in film_stock and film_stock['make'] is not None:
if not isinstance(film_stock['make'], str):
raise ValidationError("filmStock.make doit être une chaîne")
if 'model' in film_stock and film_stock['model'] is not None:
if not isinstance(film_stock['model'], str):
raise ValidationError("filmStock.model doit être une chaîne")
def _validate_date(date: Any) -> None:
"""Valide le format de la date."""
if not isinstance(date, str):
raise ValidationError(f"La date doit être une chaîne, reçu : {type(date).__name__}")
# On accepte les formats ISO 8601 courants
# La validation complète sera faite lors du parsing
def _validate_location(location: Any) -> None:
"""Valide les coordonnées GPS."""
if not isinstance(location, dict):
raise ValidationError("Le champ 'location' doit être un dictionnaire")
if 'latitude' in location and location['latitude'] is not None:
if not isinstance(location['latitude'], (int, float)):
raise ValidationError("latitude doit être numérique")
if not -90 <= location['latitude'] <= 90:
raise ValidationError(f"latitude hors limites : {location['latitude']}")
if 'longitude' in location and location['longitude'] is not None:
if not isinstance(location['longitude'], (int, float)):
raise ValidationError("longitude doit être numérique")
if not -180 <= location['longitude'] <= 180:
raise ValidationError(f"longitude hors limites : {location['longitude']}")
def _validate_aperture(aperture: Any) -> None:
"""Valide l'ouverture."""
if isinstance(aperture, (int, float)):
if aperture <= 0:
raise ValidationError(f"L'ouverture doit être positive : {aperture}")
elif isinstance(aperture, str):
# Format accepté : "f/2.8" ou "2.8"
value = aperture.lower().replace('f/', '').replace('f', '')
try:
float(value)
except ValueError:
raise ValidationError(f"Format d'ouverture invalide : {aperture}")
else:
raise ValidationError("L'ouverture doit être numérique ou une chaîne")
def _validate_shutter(shutter: Any) -> None:
"""Valide la vitesse d'obturation."""
if isinstance(shutter, (int, float)):
if shutter <= 0:
raise ValidationError(f"La vitesse doit être positive : {shutter}")
elif isinstance(shutter, str):
# Mode Bulb : accepté tel quel
if shutter.lower() in ('b', 'bulb'):
return
# Formats acceptés : "1/125", "1/125s", "2s", "2", "2""
# Supprime les suffixes de temps (s, ", '')
value = shutter.lower().replace('"', '').replace("'", '').replace('s', '').strip()
if '/' in value:
parts = value.split('/')
if len(parts) != 2:
raise ValidationError(f"Format de vitesse invalide : {shutter}")
try:
float(parts[0])
float(parts[1])
except ValueError:
raise ValidationError(f"Format de vitesse invalide : {shutter}")
else:
try:
float(value)
except ValueError:
raise ValidationError(f"Format de vitesse invalide : {shutter}")
else:
raise ValidationError("La vitesse doit être numérique ou une chaîne")

View File

@@ -0,0 +1,386 @@
"""
Module de mapping des données JSON vers les tags EXIF.
Ce module convertit les données du format JSON Exif Notes vers
les tags EXIF standard utilisables par exiftool.
"""
import logging
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
# Fuseau horaire par défaut pour les dates
DEFAULT_TIMEZONE = ZoneInfo("Europe/Paris")
# Mapping des sources de lumière vers les valeurs EXIF standard
LIGHT_SOURCE_MAP = {
'daylight': 1,
'lumière du jour': 1,
'fluorescent': 2,
'tungsten': 3,
'incandescent': 3,
'flash': 4,
'fine weather': 9,
'beau temps': 9,
'cloudy': 10,
'nuageux': 10,
'shade': 11,
'ombre': 11,
'daylight fluorescent': 12,
'day white fluorescent': 13,
'cool white fluorescent': 14,
'white fluorescent': 15,
'warm white fluorescent': 16,
'standard light a': 17,
'standard light b': 18,
'standard light c': 19,
'iso studio tungsten': 24,
'other': 255,
'autre': 255,
}
def map_frame_to_exif(frame: dict, roll: dict) -> dict:
"""
Convertit un frame et son roll parent en dictionnaire de tags EXIF.
Args:
frame: Dictionnaire représentant un frame individuel.
roll: Dictionnaire représentant le roll parent.
Returns:
Dictionnaire de tags EXIF prêts pour exiftool.
"""
tags = {}
# Informations de l'appareil photo (depuis le roll)
camera = roll.get('camera') or {}
if camera.get('make'):
tags['Make'] = camera['make']
if camera.get('model'):
tags['Model'] = camera['model']
# Informations de l'objectif (depuis le roll ou le frame)
lens = frame.get('lens') or roll.get('lens') or {}
if lens.get('make'):
tags['LensMake'] = lens['make']
if lens.get('model'):
tags['LensModel'] = lens['model']
elif lens.get('make') and lens.get('model'):
tags['LensModel'] = f"{lens['make']} {lens['model']}"
# ISO (depuis le roll)
if roll.get('iso') is not None:
tags['ISO'] = int(roll['iso'])
# Date et heure
if frame.get('date'):
try:
dt = parse_date(frame['date'])
date_str = dt.strftime('%Y:%m:%d %H:%M:%S')
tags['DateTimeOriginal'] = date_str
tags['CreateDate'] = date_str
# Offset du fuseau horaire
offset = dt.strftime('%z')
if offset:
offset_formatted = f"{offset[:3]}:{offset[3:]}"
tags['OffsetTimeOriginal'] = offset_formatted
except (ValueError, TypeError) as e:
logger.warning(f"Impossible de parser la date '{frame['date']}' : {e}")
# Vitesse d'obturation
if frame.get('shutter'):
try:
exposure_time = parse_shutter_speed(frame['shutter'])
if exposure_time is not None:
tags['ExposureTime'] = exposure_time
tags['ShutterSpeedValue'] = frame['shutter']
except ValueError as e:
logger.warning(f"Vitesse d'obturation invalide '{frame['shutter']}' : {e}")
# Ouverture
if frame.get('aperture') is not None:
try:
aperture = parse_aperture(frame['aperture'])
tags['FNumber'] = aperture
tags['ApertureValue'] = aperture
except ValueError as e:
logger.warning(f"Ouverture invalide '{frame['aperture']}' : {e}")
# Focale
if frame.get('focalLength') is not None:
tags['FocalLength'] = float(frame['focalLength'])
# Coordonnées GPS
if frame.get('location'):
location = frame['location']
lat = location.get('latitude')
lon = location.get('longitude')
if lat is not None and lon is not None:
gps_tags = format_gps_for_exif(lat, lon)
tags.update(gps_tags)
# Altitude GPS (optionnel)
if location.get('altitude') is not None:
alt = float(location['altitude'])
tags['GPSAltitude'] = abs(alt)
tags['GPSAltitudeRef'] = 'Below Sea Level' if alt < 0 else 'Above Sea Level'
# Note / Description
if frame.get('note'):
tags['ImageDescription'] = frame['note']
tags['UserComment'] = frame['note']
# Flash
if frame.get('flashUsed') is not None:
# Valeurs EXIF Flash : 0 = non déclenché, 1 = déclenché
tags['Flash'] = 1 if frame['flashUsed'] else 0
# Source de lumière
if frame.get('lightSource'):
light_value = _parse_light_source(frame['lightSource'])
if light_value is not None:
tags['LightSource'] = light_value
# Film stock (métadonnées personnalisées)
film_stock = roll.get('filmStock') or {}
film_info = _format_film_stock(film_stock)
if film_info:
# Ajout dans un tag XMP personnalisé
tags['XMP-dc:Description'] = film_info
# Aussi dans le sujet pour la compatibilité
if 'ImageDescription' in tags:
tags['ImageDescription'] = f"{tags['ImageDescription']} | Film: {film_info}"
else:
tags['ImageDescription'] = f"Film: {film_info}"
# Numéro de frame
if frame.get('id') is not None:
tags['ImageNumber'] = frame['id']
# Compensation d'exposition
if frame.get('exposureComp') is not None:
tags['ExposureCompensation'] = float(frame['exposureComp'])
logger.debug(f"Mapping terminé : {len(tags)} tags générés")
return tags
def parse_shutter_speed(shutter: Any) -> float | None:
"""
Convertit une vitesse d'obturation en valeur décimale.
Args:
shutter: Vitesse sous forme de fraction ("1/125"), nombre, ou "B" pour bulb.
Returns:
Temps d'exposition en secondes, ou None pour le mode Bulb.
Raises:
ValueError: Si le format est invalide.
Examples:
>>> parse_shutter_speed("1/125")
0.008
>>> parse_shutter_speed("1/1000")
0.001
>>> parse_shutter_speed("2")
2.0
>>> parse_shutter_speed('2"')
2.0
>>> parse_shutter_speed("B")
None
>>> parse_shutter_speed(0.5)
0.5
"""
if isinstance(shutter, (int, float)):
return float(shutter)
if not isinstance(shutter, str):
raise ValueError(f"Format de vitesse non supporté : {type(shutter)}")
# Mode Bulb : pas de valeur numérique
if shutter.lower() in ('b', 'bulb'):
return None
# Nettoyage de la chaîne (supprime s, ", ')
value = shutter.lower().replace('s', '').replace('"', '').replace("'", '').strip()
if '/' in value:
parts = value.split('/')
if len(parts) != 2:
raise ValueError(f"Format de fraction invalide : {shutter}")
try:
numerator = float(parts[0])
denominator = float(parts[1])
if denominator == 0:
raise ValueError("Division par zéro")
return numerator / denominator
except (ValueError, ZeroDivisionError) as e:
raise ValueError(f"Impossible de parser '{shutter}' : {e}")
else:
try:
return float(value)
except ValueError:
raise ValueError(f"Format de vitesse invalide : {shutter}")
def parse_aperture(aperture: Any) -> float:
"""
Convertit une ouverture en valeur numérique f-stop.
Args:
aperture: Ouverture sous forme de nombre ou chaîne ("f/2.8", "2.8").
Returns:
Valeur f-stop.
Raises:
ValueError: Si le format est invalide.
Examples:
>>> parse_aperture("f/2.8")
2.8
>>> parse_aperture("f2.8")
2.8
>>> parse_aperture(5.6)
5.6
"""
if isinstance(aperture, (int, float)):
return float(aperture)
if not isinstance(aperture, str):
raise ValueError(f"Format d'ouverture non supporté : {type(aperture)}")
# Nettoyage : supprime "f/", "f" et les espaces
value = aperture.lower().replace('f/', '').replace('f', '').strip()
try:
return float(value)
except ValueError:
raise ValueError(f"Format d'ouverture invalide : {aperture}")
def parse_date(date_str: str) -> datetime:
"""
Parse une date ISO 8601 avec gestion du fuseau horaire.
Args:
date_str: Date au format ISO 8601.
Returns:
Objet datetime avec fuseau horaire.
Raises:
ValueError: Si le format est invalide.
Examples:
>>> parse_date("2024-03-15T14:30:00")
datetime(2024, 3, 15, 14, 30, tzinfo=ZoneInfo('Europe/Paris'))
>>> parse_date("2024-03-15T14:30:00+02:00")
datetime(2024, 3, 15, 14, 30, tzinfo=timezone(timedelta(hours=2)))
"""
if not isinstance(date_str, str):
raise ValueError(f"La date doit être une chaîne : {type(date_str)}")
# Formats à essayer
formats = [
'%Y-%m-%dT%H:%M:%S%z', # ISO avec timezone offset
'%Y-%m-%dT%H:%M:%S.%f%z', # ISO avec millisecondes et timezone
'%Y-%m-%dT%H:%M%z', # ISO sans secondes avec timezone
'%Y-%m-%dT%H:%M:%S', # ISO sans timezone
'%Y-%m-%dT%H:%M:%S.%f', # ISO avec millisecondes
'%Y-%m-%dT%H:%M', # ISO sans secondes
'%Y-%m-%d %H:%M:%S', # Format simple
'%Y-%m-%d %H:%M', # Format simple sans secondes
'%Y-%m-%d', # Date seule
]
# Normalisation du format timezone (remplace Z par +00:00)
normalized = date_str.replace('Z', '+00:00')
for fmt in formats:
try:
dt = datetime.strptime(normalized, fmt)
# Si pas de timezone, utiliser le fuseau par défaut
if dt.tzinfo is None:
dt = dt.replace(tzinfo=DEFAULT_TIMEZONE)
return dt
except ValueError:
continue
raise ValueError(f"Format de date non reconnu : {date_str}")
def format_gps_for_exif(lat: float, lon: float) -> dict:
"""
Formate les coordonnées GPS pour exiftool.
Args:
lat: Latitude en degrés décimaux (-90 à 90).
lon: Longitude en degrés décimaux (-180 à 180).
Returns:
Dictionnaire avec les tags GPS formatés.
Examples:
>>> format_gps_for_exif(48.8584, 2.2945)
{'GPSLatitude': 48.8584, 'GPSLatitudeRef': 'N',
'GPSLongitude': 2.2945, 'GPSLongitudeRef': 'E'}
"""
tags = {}
# Latitude
tags['GPSLatitude'] = abs(lat)
tags['GPSLatitudeRef'] = 'N' if lat >= 0 else 'S'
# Longitude
tags['GPSLongitude'] = abs(lon)
tags['GPSLongitudeRef'] = 'E' if lon >= 0 else 'W'
return tags
def _parse_light_source(light_source: Any) -> int | None:
"""
Convertit une source de lumière en valeur EXIF.
Args:
light_source: Source de lumière (chaîne ou entier).
Returns:
Valeur EXIF ou None si non reconnu.
"""
if isinstance(light_source, int):
return light_source
if isinstance(light_source, str):
key = light_source.lower().strip()
return LIGHT_SOURCE_MAP.get(key)
return None
def _format_film_stock(film_stock: dict) -> str:
"""
Formate les informations du film pour inclusion dans les métadonnées.
Args:
film_stock: Dictionnaire avec make et model du film.
Returns:
Chaîne formatée ou chaîne vide.
"""
parts = []
if film_stock.get('make'):
parts.append(film_stock['make'])
if film_stock.get('model'):
parts.append(film_stock['model'])
return ' '.join(parts)

View File

@@ -0,0 +1,254 @@
"""
Module de génération des fichiers XMP sidecar.
Ce module crée des fichiers XMP sidecar pour les images dont le format
ne supporte pas les métadonnées EXIF intégrées.
"""
import logging
from datetime import datetime
from pathlib import Path
from typing import Any
from xml.sax.saxutils import escape
logger = logging.getLogger(__name__)
# Template XMP de base
XMP_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="json-to-metadata">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:exif="http://ns.adobe.com/exif/1.0/"
xmlns:tiff="http://ns.adobe.com/tiff/1.0/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:aux="http://ns.adobe.com/exif/1.0/aux/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
{properties}
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>'''
def generate_xmp_content(tags: dict) -> str:
"""
Génère le contenu XML d'un fichier XMP à partir des tags EXIF.
Args:
tags: Dictionnaire de tags EXIF.
Returns:
Contenu XML du fichier XMP.
"""
properties = []
for tag, value in tags.items():
if value is None:
continue
xmp_property = _convert_exif_to_xmp(tag, value)
if xmp_property:
properties.append(xmp_property)
# Ajouter la date de modification
now = datetime.now().strftime('%Y-%m-%dT%H:%M:%S')
properties.append(f' <xmp:ModifyDate>{now}</xmp:ModifyDate>')
properties_str = '\n'.join(properties)
return XMP_TEMPLATE.format(properties=properties_str)
def write_xmp_sidecar(
image_path: Path,
tags: dict,
dry_run: bool = False
) -> Path:
"""
Crée un fichier XMP sidecar pour une image.
Le fichier XMP est créé avec le même nom que l'image mais avec
l'extension .xmp.
Args:
image_path: Chemin vers le fichier image.
tags: Dictionnaire de tags EXIF.
dry_run: Si True, affiche les actions sans les exécuter.
Returns:
Chemin vers le fichier XMP créé.
Raises:
IOError: Si l'écriture échoue.
"""
image_path = Path(image_path)
xmp_path = image_path.with_suffix('.xmp')
logger.debug(f"Génération du fichier XMP : {xmp_path}")
xmp_content = generate_xmp_content(tags)
if dry_run:
logger.info(f"[DRY-RUN] Création de {xmp_path.name}")
return xmp_path
try:
with open(xmp_path, 'w', encoding='utf-8') as f:
f.write(xmp_content)
logger.info(f"Fichier XMP créé : {xmp_path.name}")
return xmp_path
except IOError as e:
logger.error(f"Impossible de créer le fichier XMP : {e}")
raise
def _convert_exif_to_xmp(tag: str, value: Any) -> str | None:
"""
Convertit un tag EXIF en propriété XMP.
Args:
tag: Nom du tag EXIF.
value: Valeur du tag.
Returns:
Ligne XML pour la propriété XMP, ou None si non supporté.
"""
# Échapper les caractères spéciaux XML
str_value = escape(str(value))
# Mapping des tags simples (valeur string directe)
simple_tags = {
'Make': 'tiff:Make',
'Model': 'tiff:Model',
'LensMake': 'aux:LensMake',
'LensModel': 'aux:Lens',
'ShutterSpeedValue': 'exif:ShutterSpeedValue',
'GPSLatitude': 'exif:GPSLatitude',
'GPSLatitudeRef': 'exif:GPSLatitudeRef',
'GPSLongitude': 'exif:GPSLongitude',
'GPSLongitudeRef': 'exif:GPSLongitudeRef',
'UserComment': 'exif:UserComment',
'LightSource': 'exif:LightSource',
'ImageNumber': 'exif:ImageNumber',
}
if tag in simple_tags:
xmp_tag = simple_tags[tag]
return f' <{xmp_tag}>{str_value}</{xmp_tag}>'
# Tags avec format rationnel
rational_tags = {
'ExposureTime': 'exif:ExposureTime',
'FNumber': 'exif:FNumber',
'ApertureValue': 'exif:ApertureValue',
'FocalLength': 'exif:FocalLength',
'ExposureCompensation': 'exif:ExposureCompensation',
'GPSAltitude': 'exif:GPSAltitude',
}
if tag in rational_tags:
xmp_tag = rational_tags[tag]
return f' <{xmp_tag}>{_format_rational(value)}</{xmp_tag}>'
# Tags avec format spécial
if tag == 'ISO':
return f' <exif:ISOSpeedRatings>{_format_seq(value)}</exif:ISOSpeedRatings>'
if tag == 'DateTimeOriginal':
return f' <xmp:CreateDate>{_format_xmp_date(str(value))}</xmp:CreateDate>'
if tag == 'CreateDate':
date_val = _format_xmp_date(str(value))
return f' <photoshop:DateCreated>{date_val}</photoshop:DateCreated>'
if tag == 'GPSAltitudeRef':
ref_value = '0' if 'Above' in str(value) else '1'
return f' <exif:GPSAltitudeRef>{ref_value}</exif:GPSAltitudeRef>'
if tag == 'ImageDescription':
return f' <dc:description>{_format_alt(str_value)}</dc:description>'
if tag == 'Flash':
return f' {_format_flash(value)}'
# Tags XMP personnalisés (déjà préfixés)
if tag.startswith('XMP-'):
# Convertir XMP-dc:Description en dc:description
clean_tag = tag.replace('XMP-', '')
return f' <{clean_tag}>{_format_alt(str_value)}</{clean_tag}>'
logger.debug(f"Tag EXIF non mappé vers XMP : {tag}")
return None
def _format_rational(value: Any) -> str:
"""Formate une valeur en rationnel XMP."""
if isinstance(value, int):
return str(value)
if isinstance(value, float):
# Pour les temps d'exposition comme 1/125
if value < 1 and value > 0:
denominator = round(1 / value)
return f"1/{denominator}"
# Valeurs entières flottantes
if value == int(value):
return str(int(value))
return str(value)
return str(value)
def _format_seq(value: Any) -> str:
"""Formate une valeur en séquence RDF."""
return f'''
<rdf:Seq>
<rdf:li>{value}</rdf:li>
</rdf:Seq>
'''
def _format_alt(value: str) -> str:
"""Formate une valeur en alternative RDF (pour les textes multilingues)."""
return f'''
<rdf:Alt>
<rdf:li xml:lang="x-default">{value}</rdf:li>
</rdf:Alt>
'''
def _format_xmp_date(value: str) -> str:
"""
Convertit une date EXIF en format XMP.
EXIF: 2024:03:15 14:30:00
XMP: 2024-03-15T14:30:00
"""
if 'T' in value:
# Déjà au format ISO
return value
# Convertir le format EXIF
try:
parts = value.replace(':', '-', 2).split(' ')
if len(parts) == 2:
return f"{parts[0]}T{parts[1]}"
return parts[0]
except Exception:
return value
def _format_flash(value: Any) -> str:
"""
Formate la valeur du flash pour XMP.
Retourne une structure Flash complète.
"""
fired = bool(value) if not isinstance(value, bool) else value
return f'''
<exif:Flash>
<rdf:Description>
<exif:Fired>{'True' if fired else 'False'}</exif:Fired>
<exif:Mode>0</exif:Mode>
</rdf:Description>
</exif:Flash>
'''.strip()

54
pyproject.toml Normal file
View File

@@ -0,0 +1,54 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "json-to-metadata"
version = "0.1.0"
description = "Script de gestion des métadonnées EXIF pour photos argentiques"
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
authors = [
{name = "Antoine"}
]
keywords = ["exif", "metadata", "photography", "film"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Multimedia :: Graphics",
]
[project.scripts]
json-to-metadata = "json_to_metadata.cli:main"
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"flake8>=6.0",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["json_to_metadata*"]
[tool.flake8]
max-line-length = 100
exclude = [
".git",
"__pycache__",
"venv",
".venv",
"build",
"dist",
]
ignore = ["E501", "W503"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
pytest>=7.0
flake8>=6.0

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests pour json-to-metadata."""

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'

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

View File

@@ -0,0 +1,327 @@
"""Tests pour le module metadata_mapper."""
from zoneinfo import ZoneInfo
import pytest
from json_to_metadata.metadata_mapper import (
format_gps_for_exif,
map_frame_to_exif,
parse_aperture,
parse_date,
parse_shutter_speed,
)
class TestParseShutterSpeed:
"""Tests pour la fonction parse_shutter_speed."""
def test_fraction_format(self):
"""Parse une fraction simple."""
assert parse_shutter_speed('1/125') == pytest.approx(0.008, rel=0.01)
assert parse_shutter_speed('1/1000') == pytest.approx(0.001, rel=0.01)
assert parse_shutter_speed('1/60') == pytest.approx(0.0167, rel=0.01)
def test_fraction_with_suffix(self):
"""Parse une fraction avec suffixe 's'."""
assert parse_shutter_speed('1/125s') == pytest.approx(0.008, rel=0.01)
def test_whole_seconds(self):
"""Parse des temps en secondes entières."""
assert parse_shutter_speed('2') == 2.0
assert parse_shutter_speed('2s') == 2.0
assert parse_shutter_speed('30') == 30.0
def test_seconds_with_quote(self):
"""Parse des temps avec guillemet (notation photographique)."""
assert parse_shutter_speed('2"') == 2.0
assert parse_shutter_speed("4'") == 4.0
assert parse_shutter_speed('30"') == 30.0
def test_bulb_mode(self):
"""Retourne None pour le mode Bulb."""
assert parse_shutter_speed('B') is None
assert parse_shutter_speed('b') is None
assert parse_shutter_speed('Bulb') is None
assert parse_shutter_speed('bulb') is None
def test_numeric_input(self):
"""Accepte des valeurs numériques directes."""
assert parse_shutter_speed(0.5) == 0.5
assert parse_shutter_speed(2) == 2.0
def test_decimal_string(self):
"""Parse des chaînes décimales."""
assert parse_shutter_speed('0.5') == 0.5
def test_invalid_format(self):
"""Lève une exception pour un format invalide."""
with pytest.raises(ValueError):
parse_shutter_speed('fast')
def test_division_by_zero(self):
"""Lève une exception pour une division par zéro."""
with pytest.raises(ValueError):
parse_shutter_speed('1/0')
class TestParseAperture:
"""Tests pour la fonction parse_aperture."""
def test_f_stop_format(self):
"""Parse le format f/X.X."""
assert parse_aperture('f/2.8') == 2.8
assert parse_aperture('f/5.6') == 5.6
assert parse_aperture('f/16') == 16.0
def test_f_format_no_slash(self):
"""Parse le format fX.X."""
assert parse_aperture('f2.8') == 2.8
assert parse_aperture('F5.6') == 5.6
def test_numeric_string(self):
"""Parse une chaîne numérique simple."""
assert parse_aperture('2.8') == 2.8
assert parse_aperture('11') == 11.0
def test_numeric_input(self):
"""Accepte des valeurs numériques directes."""
assert parse_aperture(2.8) == 2.8
assert parse_aperture(8) == 8.0
def test_invalid_format(self):
"""Lève une exception pour un format invalide."""
with pytest.raises(ValueError):
parse_aperture('wide')
class TestParseDate:
"""Tests pour la fonction parse_date."""
def test_iso_format_with_timezone(self):
"""Parse le format ISO avec timezone."""
result = parse_date('2024-03-15T14:30:00+02:00')
assert result.year == 2024
assert result.month == 3
assert result.day == 15
assert result.hour == 14
assert result.minute == 30
assert result.tzinfo is not None
def test_iso_format_without_timezone(self):
"""Parse le format ISO sans timezone (utilise Europe/Paris)."""
result = parse_date('2024-03-15T14:30:00')
assert result.year == 2024
assert result.month == 3
assert result.day == 15
assert result.tzinfo == ZoneInfo('Europe/Paris')
def test_iso_format_with_z(self):
"""Parse le format ISO avec Z pour UTC."""
result = parse_date('2024-03-15T14:30:00Z')
assert result.tzinfo is not None
def test_iso_format_without_seconds(self):
"""Parse le format ISO sans secondes."""
result = parse_date('2025-11-16T16:47')
assert result.year == 2025
assert result.month == 11
assert result.day == 16
assert result.hour == 16
assert result.minute == 47
assert result.second == 0
def test_date_only(self):
"""Parse une date sans heure."""
result = parse_date('2024-03-15')
assert result.year == 2024
assert result.month == 3
assert result.day == 15
def test_simple_format(self):
"""Parse le format simple avec espace."""
result = parse_date('2024-03-15 14:30:00')
assert result.hour == 14
assert result.minute == 30
def test_invalid_format(self):
"""Lève une exception pour un format invalide."""
with pytest.raises(ValueError):
parse_date('15/03/2024')
class TestFormatGpsForExif:
"""Tests pour la fonction format_gps_for_exif."""
def test_north_east_coordinates(self):
"""Formate des coordonnées Nord-Est."""
result = format_gps_for_exif(48.8584, 2.2945)
assert result['GPSLatitude'] == 48.8584
assert result['GPSLatitudeRef'] == 'N'
assert result['GPSLongitude'] == 2.2945
assert result['GPSLongitudeRef'] == 'E'
def test_south_west_coordinates(self):
"""Formate des coordonnées Sud-Ouest."""
result = format_gps_for_exif(-22.9068, -43.1729)
assert result['GPSLatitude'] == 22.9068
assert result['GPSLatitudeRef'] == 'S'
assert result['GPSLongitude'] == 43.1729
assert result['GPSLongitudeRef'] == 'W'
def test_zero_coordinates(self):
"""Formate des coordonnées à zéro (équateur/méridien)."""
result = format_gps_for_exif(0, 0)
assert result['GPSLatitude'] == 0
assert result['GPSLatitudeRef'] == 'N'
assert result['GPSLongitude'] == 0
assert result['GPSLongitudeRef'] == 'E'
class TestMapFrameToExif:
"""Tests pour la fonction map_frame_to_exif."""
def test_basic_mapping(self):
"""Mappe les champs de base."""
frame = {'id': 1}
roll = {
'id': 'roll-1',
'frames': [frame],
'camera': {'make': 'Nikon', 'model': 'FM2'},
'iso': 400
}
result = map_frame_to_exif(frame, roll)
assert result['Make'] == 'Nikon'
assert result['Model'] == 'FM2'
assert result['ISO'] == 400
def test_exposure_mapping(self):
"""Mappe les paramètres d'exposition."""
frame = {
'id': 1,
'aperture': 'f/2.8',
'shutter': '1/125',
'focalLength': 50
}
roll = {'id': 1, 'frames': [frame]}
result = map_frame_to_exif(frame, roll)
assert result['FNumber'] == 2.8
assert result['ExposureTime'] == pytest.approx(0.008, rel=0.01)
assert result['FocalLength'] == 50.0
def test_date_mapping(self):
"""Mappe la date correctement."""
frame = {
'id': 1,
'date': '2024-03-15T14:30:00'
}
roll = {'id': 1, 'frames': [frame]}
result = map_frame_to_exif(frame, roll)
assert result['DateTimeOriginal'] == '2024:03:15 14:30:00'
assert result['CreateDate'] == '2024:03:15 14:30:00'
def test_gps_mapping(self):
"""Mappe les coordonnées GPS."""
frame = {
'id': 1,
'location': {
'latitude': 48.8584,
'longitude': 2.2945
}
}
roll = {'id': 1, 'frames': [frame]}
result = map_frame_to_exif(frame, roll)
assert result['GPSLatitude'] == 48.8584
assert result['GPSLatitudeRef'] == 'N'
assert result['GPSLongitude'] == 2.2945
assert result['GPSLongitudeRef'] == 'E'
def test_note_mapping(self):
"""Mappe la note en description."""
frame = {
'id': 1,
'note': 'Belle photo du coucher de soleil'
}
roll = {'id': 1, 'frames': [frame]}
result = map_frame_to_exif(frame, roll)
assert result['ImageDescription'] == 'Belle photo du coucher de soleil'
assert result['UserComment'] == 'Belle photo du coucher de soleil'
def test_flash_mapping(self):
"""Mappe l'utilisation du flash."""
frame_with_flash = {'id': 1, 'flashUsed': True}
frame_without_flash = {'id': 2, 'flashUsed': False}
roll = {'id': 1, 'frames': []}
result_with = map_frame_to_exif(frame_with_flash, roll)
result_without = map_frame_to_exif(frame_without_flash, roll)
assert result_with['Flash'] == 1
assert result_without['Flash'] == 0
def test_lens_mapping(self):
"""Mappe les informations de l'objectif."""
frame = {'id': 1}
roll = {
'id': 1,
'frames': [frame],
'lens': {'make': 'Nikon', 'model': 'Nikkor 50mm f/1.4'}
}
result = map_frame_to_exif(frame, roll)
assert result['LensMake'] == 'Nikon'
assert result['LensModel'] == 'Nikkor 50mm f/1.4'
def test_film_stock_mapping(self):
"""Mappe les informations du film."""
frame = {'id': 1}
roll = {
'id': 1,
'frames': [frame],
'filmStock': {'make': 'Kodak', 'model': 'Portra 400'}
}
result = map_frame_to_exif(frame, roll)
assert 'Kodak Portra 400' in result['ImageDescription']
def test_empty_frame(self):
"""Gère un frame vide."""
frame = {'id': 1}
roll = {'id': 1, 'frames': [frame]}
result = map_frame_to_exif(frame, roll)
assert result['ImageNumber'] == 1
def test_invalid_shutter_logged(self):
"""Les vitesses invalides sont ignorées avec un warning."""
frame = {
'id': 1,
'shutter': 'invalid'
}
roll = {'id': 1, 'frames': [frame]}
result = map_frame_to_exif(frame, roll)
assert 'ExposureTime' not in result

218
tests/test_xmp_writer.py Normal file
View File

@@ -0,0 +1,218 @@
"""Tests pour le module xmp_writer."""
from json_to_metadata.xmp_writer import (
_convert_exif_to_xmp,
_format_rational,
_format_xmp_date,
generate_xmp_content,
write_xmp_sidecar,
)
class TestGenerateXmpContent:
"""Tests pour la fonction generate_xmp_content."""
def test_basic_structure(self):
"""Génère une structure XMP valide."""
content = generate_xmp_content({})
assert '<?xml version="1.0"' in content
assert 'x:xmpmeta' in content
assert 'rdf:RDF' in content
assert 'rdf:Description' in content
def test_includes_namespaces(self):
"""Inclut les namespaces requis."""
content = generate_xmp_content({})
assert 'xmlns:dc=' in content
assert 'xmlns:exif=' in content
assert 'xmlns:tiff=' in content
assert 'xmlns:xmp=' in content
def test_maps_camera_info(self):
"""Mappe les informations de l'appareil."""
tags = {'Make': 'Nikon', 'Model': 'FM2'}
content = generate_xmp_content(tags)
assert '<tiff:Make>Nikon</tiff:Make>' in content
assert '<tiff:Model>FM2</tiff:Model>' in content
def test_maps_exposure(self):
"""Mappe les paramètres d'exposition."""
tags = {'ISO': 400, 'FNumber': 2.8}
content = generate_xmp_content(tags)
assert 'ISOSpeedRatings' in content
assert '400' in content
assert 'FNumber' in content
def test_escapes_special_characters(self):
"""Échappe les caractères spéciaux XML."""
tags = {'ImageDescription': 'Test <avec> des & caractères "spéciaux"'}
content = generate_xmp_content(tags)
assert '&lt;avec&gt;' in content
assert '&amp;' in content
def test_includes_modify_date(self):
"""Inclut la date de modification."""
content = generate_xmp_content({})
assert 'xmp:ModifyDate' in content
class TestWriteXmpSidecar:
"""Tests pour la fonction write_xmp_sidecar."""
def test_creates_xmp_file(self, tmp_path):
"""Crée un fichier XMP à côté de l'image."""
image = tmp_path / 'photo.tif'
image.write_bytes(b'fake image')
tags = {'Make': 'Nikon'}
xmp_path = write_xmp_sidecar(image, tags)
assert xmp_path.exists()
assert xmp_path.name == 'photo.xmp'
assert xmp_path.parent == tmp_path
def test_xmp_content_valid(self, tmp_path):
"""Le contenu du fichier XMP est valide."""
image = tmp_path / 'photo.jpg'
image.write_bytes(b'fake image')
tags = {'Make': 'Nikon', 'Model': 'FM2'}
xmp_path = write_xmp_sidecar(image, tags)
content = xmp_path.read_text(encoding='utf-8')
assert '<?xml version="1.0"' in content
assert '<tiff:Make>Nikon</tiff:Make>' in content
def test_dry_run_mode(self, tmp_path):
"""Le mode dry-run ne crée pas de fichier."""
image = tmp_path / 'photo.tif'
image.write_bytes(b'fake image')
xmp_path = write_xmp_sidecar(image, {'Make': 'Nikon'}, dry_run=True)
assert not xmp_path.exists()
assert xmp_path.name == 'photo.xmp'
def test_overwrites_existing_xmp(self, tmp_path):
"""Écrase un fichier XMP existant."""
image = tmp_path / 'photo.tif'
image.write_bytes(b'fake image')
xmp_path = tmp_path / 'photo.xmp'
xmp_path.write_text('old content')
write_xmp_sidecar(image, {'Make': 'Canon'})
content = xmp_path.read_text()
assert 'Canon' in content
assert 'old content' not in content
def test_handles_different_extensions(self, tmp_path):
"""Fonctionne avec différentes extensions d'images."""
for ext in ['.tif', '.avif', '.heic', '.webp', '.png']:
image = tmp_path / f'photo{ext}'
image.write_bytes(b'fake image')
xmp_path = write_xmp_sidecar(image, {'Make': 'Test'})
assert xmp_path.suffix == '.xmp'
assert xmp_path.stem == 'photo'
xmp_path.unlink() # Nettoyer
class TestConvertExifToXmp:
"""Tests pour la fonction _convert_exif_to_xmp."""
def test_make_mapping(self):
"""Mappe Make vers tiff:Make."""
result = _convert_exif_to_xmp('Make', 'Nikon')
assert '<tiff:Make>Nikon</tiff:Make>' in result
def test_iso_mapping(self):
"""Mappe ISO avec structure rdf:Seq."""
result = _convert_exif_to_xmp('ISO', 400)
assert 'ISOSpeedRatings' in result
assert 'rdf:Seq' in result
assert '400' in result
def test_date_mapping(self):
"""Mappe DateTimeOriginal vers xmp:CreateDate."""
result = _convert_exif_to_xmp('DateTimeOriginal', '2024:03:15 14:30:00')
assert 'xmp:CreateDate' in result
assert '2024-03-15T14:30:00' in result
def test_gps_mapping(self):
"""Mappe les coordonnées GPS."""
lat = _convert_exif_to_xmp('GPSLatitude', 48.8584)
lat_ref = _convert_exif_to_xmp('GPSLatitudeRef', 'N')
assert 'exif:GPSLatitude' in lat
assert '48.8584' in lat
assert 'exif:GPSLatitudeRef' in lat_ref
assert 'N' in lat_ref
def test_description_uses_alt(self):
"""ImageDescription utilise rdf:Alt pour le multilingue."""
result = _convert_exif_to_xmp('ImageDescription', 'Ma photo')
assert 'dc:description' in result
assert 'rdf:Alt' in result
assert 'x-default' in result
def test_unknown_tag_returns_none(self):
"""Les tags inconnus retournent None."""
result = _convert_exif_to_xmp('UnknownTag', 'value')
assert result is None
class TestFormatRational:
"""Tests pour la fonction _format_rational."""
def test_integer_value(self):
"""Formate un entier comme chaîne."""
assert _format_rational(50) == '50'
assert _format_rational(100.0) == '100'
def test_fraction_value(self):
"""Formate une fraction correctement."""
result = _format_rational(0.008) # 1/125
assert '1/125' in result
def test_decimal_passthrough(self):
"""Retourne les décimaux non-fractions tels quels."""
result = _format_rational(2.8)
assert '2.8' in result
class TestFormatXmpDate:
"""Tests pour la fonction _format_xmp_date."""
def test_exif_to_xmp_format(self):
"""Convertit le format EXIF en ISO."""
result = _format_xmp_date('2024:03:15 14:30:00')
assert result == '2024-03-15T14:30:00'
def test_iso_passthrough(self):
"""Laisse passer les dates déjà au format ISO."""
result = _format_xmp_date('2024-03-15T14:30:00')
assert result == '2024-03-15T14:30:00'
def test_date_only(self):
"""Gère les dates sans heure."""
result = _format_xmp_date('2024-03-15')
assert '2024-03-15' in result